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
Nobody has claimed this yet.
- 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_session → prompt; 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_session → prompt 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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