pingdotgg / pingdotgg/t3code

Pi: resumed thread runs its turn with an invalidated extension ctx — T3's own pi-t3-mcp-extension hooks throw 'stale after session replacement' on every provider call

Open
#12,467 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

accepted bug via-triage
Dominant language
TypeScript
Stars
23k
Forks
5.9k
Avg merge
11h 14m
Merged PRs (30d)
357

Description

What happened

Resumed a day-old Pi thread in T3 Code (work Mac, Orchestrator V2 branch build) and sent a message. The turn ran, but every Pi extension hook that touched its ctx threw This extension ctx is stale after session replacement or reload…, rendered as red rows in the thread:

  • cmux-session failed during before_agent_start (1×)
  • index failed during before_agent_start (1×, a third-party extension)
  • pi-t3-mcp-extension failed during before_provider_request (3× — once per provider call in the turn)

The last one is T3's own injected extension (piT3McpExtensionSource.ts). Its before_provider_request handler reads only the hook-passed ctx (ctx.model?.provider) and captures nothing across sessions, so the ctx handed to the handler was already invalidated — this is a dispatch-side problem, not an extension holding a stale reference. Every T3-owned hook on that turn silently no-op'd: the OpenRouter max_tokens cap and, on other paths, the tool_call permission gate.

Resuming the same Pi session from a terminal (pi --session <file>) and sending the same message: no errors.

Diagnosis

T3 resumes a Pi thread by spawning pi --mode rpc (no --session), which opens a default session and loads all extensions against it, then sends switch_session to the persisted file (PiAdapterV2.ts registerThread, existing.nativeThreadRef.nativeId). In Pi 0.85.1 that switch is a full session replacement: teardownCurrent()session.dispose()extensionRunner.invalidate(...), then createRuntime() builds a fresh resource loader, re-runs every extension factory and installs a new runner. Any hook dispatched through the old runner after that point gets an invalidated ctx and throws on first access.

Driving this exact sequence by hand against pi --mode rpc (spawn → switch_sessionprompt; also with the full global extension set; also after the default session had already run a turn) does not reproduce — the replaced runner is fresh and every hook is fine. So the plain switch is sound; the failing process had T3-specific history (kept alive across a day; whatever RPC sequence T3 issued to it between yesterday's last turn and today's resume) that left the dispatching runner invalidated while a turn still ran through it. I could not extract that sequence: the server trace does not record Pi RPC commands or the Pi child's stderr, and extension_error rows are not persisted in the event store, so the evidence is the UI only.

Two observations from the probes that may help whoever knows the pool lifecycle: (1) session_start with reason: "resume" fires twice per switch_session in RPC mode — rpc-mode.ts rebinds once via runtimeHost.setRebindSession inside finishSessionReplacement and again in case "switch_session", and each bindExtensions emits session_start; harmless in isolation, but it means every T3 resume double-runs every extension's session_start. (2) The whole class exists only because T3 replaces a session inside a live process. Spawning the resume as pi --mode rpc --session <file> (Pi's native resume; --session accepts the file path T3 already stores as nativeThreadRef.nativeId) would load extensions once against the right session and remove switch_session from the resume path entirely — no runner invalidation can then reach a turn. Every other CLI harness T3 drives is resumed by passing the session id on the command line; Pi supports the same.

Steps to reproduce

Not deterministic yet. Observed twice (2026-09-10 and 2026-09-18) with the same shape: a Pi thread idle for ≥ 1 day, reopened in T3, first message sent. A raw-RPC replay of spawn → switch_sessionprompt does not trigger it (see Diagnosis).

Version

0.0.42 — Orchestrator V2 branch (t3code/codex-turn-mapping), head 934da5749, packaged desktop build.

Environment

macOS 15 (arm64), Node 24, Pi @earendil-works/pi-coding-agent 0.85.1 on both machines (one reproduced, one is the probe machine).

Evidence

UI rows (verbatim prefix; full text is Pi's standard stale-ctx message):

cmux-session failed during before_agent_start. This extension ctx is stale after session replacement or r…
index failed during before_agent_start. This extension ctx is stale after session replacement or reload. D…
pi-t3-mcp-extension failed during before_provider_request. This extension ctx is stale after session repl…
pi-t3-mcp-extension failed during before_provider_request. This extension ctx is stale after session repl…
pi-t3-mcp-extension failed during before_provider_request. This extension ctx is stale after session repl…

Pi source (0.85.1) for the replacement path: dist/core/agent-session-runtime.js switchSession()teardownCurrent()dist/core/agent-session.js dispose() line 595 this._extensionRunner.invalidate(...); dist/modes/rpc/rpc-mode.js case "switch_session"rebindSession().

Clean probe transcript (spawn → switch → prompt, probe extension logging the session file from the hook ctx):

[probe] factory run ulnjx pid=77202
[probe:ulnjx] session_start reason=startup … 2026-09-18T15-34-03 (default session)
[probe] factory run de71e pid=77202          ← factories re-run on switch
[probe:de71e] session_start reason=resume … 2026-09-18T15-33-34 (target)
[probe:de71e] session_start reason=resume … (fired twice)
{"id":"2","type":"response","command":"switch_session","success":true,"data":{"cancelled":false}}
[probe:de71e] before_agent_start ok
[probe:de71e] before_provider_request ok
Related issues

None found for stale ctx / switch_session. #12285 (delegated-wake cap) and #11168 (mode:"wait") are different V2 contract problems from the same setup; not duplicates.

Fix applied or workaround

User-side: every hand-built extension here now wraps pi.on with a guard that logs the stale throw once and no-ops afterwards (that is why cmux-session shows 1 row instead of one per call). That hides the symptom for our extensions only; T3's own extension and third-party ones still throw, and the guarded hooks still do nothing for the turn. Reliable recovery is to resume the session from a terminal instead of T3.

Filed by

claude (opus-5) via a Pi thread inside T3 Code, following the t3 triage report structure.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in PiAdapterV2.ts at registerThread and the resume path that spawns pi --mode rpc, then compare it with the native --session invocation described in the issue. Reproduce or instrument the RPC lifecycle around switch_session and prompt, including extension errors and child stderr. Done means resumed turns no longer dispatch stale extension contexts and T3-owned hooks still apply their protections.

Written by the indexing model from the issue text.

Assessment

Tech stack
node.js, typescript
Domain
backend, desktop
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.