Repository navigation
fix(server): detaching a Codex thread unloads it from the shared app-server - #14187
Conversation
…server All Codex threads share one app-server. Detaching a thread (settle, archive, workspace or selection change) only dropped it from T3's bookkeeping; the native thread stayed loaded in the app-server with its MCP servers running until the whole runtime was released, so idle processes piled up over a day. Add an optional unloadThread to the adapter session runtime. Codex implements it with thread/unsubscribe, which lets the app-server shut the native thread down once it is idle and unsubscribed. ProviderSessionManager calls it for the detached thread's provider threads when a shared runtime stays up for others. Failures are logged and do not fail the detach. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ApprovabilityVerdict: Approved at Macroscope's review found this PR approvable — This is a bounded Codex lifecycle fix that unloads detached native threads without closing shared runtimes, with explicit timeout handling and regression coverage for reattachment races. Error propagation is also tested, and no product defaults, schemas, security-sensitive paths, or static-analysis suppressions are changed. Notes:
You can add or adjust custom eligibility rules. Learn more. |
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
Detach removed the attachment, then called unloadThread. The same thread's next turn could re-attach and resume in between, and the late unload then tore down the reloaded native thread. Serialize a thread's attach with the detach's unload on a per-session, per-thread lock: an unload skips a thread that already re-attached, and an attach that arrives mid-unload waits for it, so its resume reloads the thread. The unload is bounded so a wedged provider cannot hold the next attach. Also keep the app-server failure as the cause of a rejected Codex unload instead of storing it as the protocol error's payload. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Addresses #13331 (item 4).
All Codex threads share one
codex app-server(supportsMultipleProviderThreadsPerSession: true). Detaching a thread from that shared runtime (settle, archive, workspace change, selection change, handoff) only removed it fromattachedThreadIdsandloadedProviderThreadKeyByThread. Nothing told the app-server, so the native thread stayed loaded along with its MCP servers until the whole runtime was released. The reporter measured about 80 idle processes and 9 GB over a day.What changed
ProviderAdapterV2SessionRuntimegets an optionalunloadThread({ providerThread }). Only runtimes shared by several app threads need it.CodexAdapterV2implements it withthread/unsubscribe. Codex 0.156 source (codex-rs/app-server/src/request_processors/thread_lifecycle.rs,UnloadingState): a thread with no subscribers that is notRunningis shut down afterthread_unload_delay(default 60s,core/src/config/mod.rs), which also cancels its pending server requests and emitsthread/closed. A running turn is not cut off; Codex waits until it is idle. A laterthread/resumereloads the thread, as the existing resume path already does after an idle release.ProviderSessionManager.detach: when the shared runtime stays up after the detach, callunloadThreadfor the detaching app thread's provider threads on that session (those with a native ref). This runs after the existing interrupt of their running turns. A failed unload is logged (detach-unload-failed) and does not fail the detach. Exclusive runtimes are unchanged: they are released outright when their last thread detaches.The other adapters are not shared multi-thread runtimes, so they don't implement it and nothing changes for them.
Verification
ProviderSessionManager.test.ts: the existing "opens one shared runtime … detaches threads independently" test now recordsunloadThreadcalls in its fake runtime. It asserts that the thread with a persisted provider thread is unloaded after its turn is interrupted, and that the one without is not. Fails on the base branch, passes with the fix.CodexAdapterV2.test.ts, new replay testunsubscribes from the native thread when it is unloaded: it expects the exact outbound frame{"method":"thread/unsubscribe","params":{"threadId":…}}. The inbound{"status":"unsubscribed"}response is the one recorded from a livecodex app-server0.156.1. A live probe returnedunsubscribed, thennotSubscribedon a repeat, thennotLoadedfor an unknown id. Fails on the base branch, passes with the fix.vp test run src/orchestration-v2/ProviderSessionManager.test.ts src/orchestration-v2/Adapters/CodexAdapterV2.test.ts: 158 passed.vp exec tsc --noEmit -p .inapps/server: no errors.vp linton the touched files: only two pre-existing unused-variable warnings inCodexAdapterV2.ts.vp run knip:check: clean.Model: Claude Opus 5.5 (Claude Code)
🤖 Generated with Claude Code