From c5a9ef77f35436a165bcfec09c6e56d8fe156406 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 08:10:50 +0000 Subject: [PATCH 1/6] test(server): reproduce Codex background terminals surviving thread unload (#25062) These tests reproduce #25062: unloading (settling) a Codex thread only sends thread/unsubscribe, so a background command the turn left running is never cleaned. The replay expects thread/backgroundTerminals/clean for the native thread before the unsubscribe, and fails on main. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 602e1a4d56f29..60af4e6c79818 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -2884,6 +2884,60 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + // Codex never closes an unsubscribed thread on its own, so its background + // terminals outlive the settle unless unloading cleans them. + it.effect("cleans the thread's background terminals when unloading it", () => + Effect.gen(function* () { + const transcript = makeCodexReplayTranscript({ + scenario: "codex-background-unload-clean", + entries: [ + ...backgroundExecTranscript.entries.slice(0, -1), + { + type: "expect_outbound", + label: "clean-background-terminals", + frame: { + id: 4, + method: "thread/backgroundTerminals/clean", + params: { threadId: BG_NATIVE_THREAD }, + }, + }, + { + type: "emit_inbound", + label: "clean-background-terminals-result", + frame: { id: 4, result: {} }, + }, + { + type: "expect_outbound", + label: "unsubscribe", + frame: { id: 5, method: "thread/unsubscribe", params: { threadId: BG_NATIVE_THREAD } }, + }, + { + type: "emit_inbound", + label: "unsubscribe-result", + frame: { id: 5, result: { status: "unsubscribed" } }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-codex-unload-clean"), + text: BG_PROMPT, + }), + ); + yield* harness.firstTerminal; + yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + const backgroundStopCases = [true, false, "still_running", "thread_unloaded"] as const; const makeBackgroundStopTranscript = (terminated: (typeof backgroundStopCases)[number]) => { const stillRunning = terminated === "still_running"; From f7a0a23e56e7c6a5c3f2e4b3e8c501ac72047787 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 08:19:48 +0000 Subject: [PATCH 2/6] fix(server): settling a Codex thread stops its background terminals Unloading a thread from the shared Codex app-server only sent thread/unsubscribe, and Codex keeps an unsubscribed thread loaded indefinitely, so background commands the agent started outlived the settle (and piled up as shells on Windows). unloadThread now sends thread/backgroundTerminals/clean before unsubscribing. A failed clean (older Codex, exited process, missing thread) is logged and the unsubscribe still runs. After a successful clean the thread's running command items are terminalized the same way thread/closed does, since Codex's late item/completed can race the unsubscribe. Fixes pingdotgg/t3code#25062 Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 53 ++++++++++++-- .../Adapters/CodexAdapterV2.ts | 70 +++++++++++-------- 2 files changed, 89 insertions(+), 34 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 60af4e6c79818..f181f8cb87d7a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -2778,18 +2778,37 @@ describe("CodexAdapterV2 post-settle continuation", () => { arguments: { persistent: true, command: "tail -f" }, }, }); - // Replies fence notification handling on the same protocol reader. + // Replies fence notification handling on the same protocol reader. Codex + // builds without background terminal cleanup leave closure to thread/closed. const unsubscribe = (id: number, notifications: ReturnType[] = []) => [ + { + type: "expect_outbound" as const, + label: `clean-${id}`, + frame: { + id, + method: "thread/backgroundTerminals/clean", + params: { threadId: BG_NATIVE_THREAD }, + }, + }, + { + type: "emit_inbound" as const, + label: `clean-error-${id}`, + frame: { id, error: { code: -32601, message: "method not found" } }, + }, { type: "expect_outbound" as const, label: `unsubscribe-${id}`, - frame: { id, method: "thread/unsubscribe", params: { threadId: BG_NATIVE_THREAD } }, + frame: { + id: id + 1, + method: "thread/unsubscribe", + params: { threadId: BG_NATIVE_THREAD }, + }, }, ...notifications, { type: "emit_inbound" as const, label: `unsubscribe-result-${id}`, - frame: { id, result: { status: "notSubscribed" } }, + frame: { id: id + 1, result: { status: "notSubscribed" } }, }, ]; const closed = notification("thread/closed", { threadId: BG_NATIVE_THREAD }); @@ -2800,9 +2819,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { ...(retainContext ? [monitor("inProgress")] : []), backgroundExecTranscript.entries.at(-2)!, ...unsubscribe(4), - ...unsubscribe(5, [notification("thread/closed", { threadId: "unrelated-thread" })]), - ...unsubscribe(6, [closed]), - ...unsubscribe(7, [ + ...unsubscribe(6, [notification("thread/closed", { threadId: "unrelated-thread" })]), + ...unsubscribe(8, [closed]), + ...unsubscribe(10, [ closed, notification("item/completed", { threadId: BG_NATIVE_THREAD, @@ -2911,6 +2930,18 @@ describe("CodexAdapterV2 post-settle continuation", () => { label: "unsubscribe", frame: { id: 5, method: "thread/unsubscribe", params: { threadId: BG_NATIVE_THREAD } }, }, + { + type: "emit_inbound", + label: "item/completed/command-cleaned", + frame: { + method: "item/completed", + params: { + item: backgroundCommandItem("completed"), + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + }, + }, + }, { type: "emit_inbound", label: "unsubscribe-result", @@ -2930,6 +2961,16 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); yield* harness.firstTerminal; yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread }); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === BG_COMMAND_ITEM + ? [event.turnItem.status] + : [], + ), + ["running", "interrupted"], + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.scoped, Effect.provide( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 6dba639b63b01..e95cfe173e295 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -4573,33 +4573,35 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); }); - // Unloading an idle thread kills its terminals without completing - // their command items, while the shared provider session stays alive. - yield* client.handleServerNotification("thread/closed", (payload) => - Effect.gen(function* () { - const completedAt = yield* DateTime.now; - for (const context of (yield* Ref.get(settledTurns)).values()) { - if (context.providerThread.nativeThreadRef?.nativeId !== payload.threadId) continue; - const commands = (yield* Ref.get(runningCommandItemsByTurn)).get( - context.nativeTurnId, - ); - if (commands !== undefined) { - context.closedCommandItemIds = new Set(commands.keys()); - } - yield* terminalizeRunningCommandItems( - context, - context.nativeTurnId, - "interrupted", - completedAt, - ); - yield* Ref.update(runningCommandItemsByTurn, (current) => { - const updated = new Map(current); - updated.delete(context.nativeTurnId); - return updated; - }); - yield* releaseSettledTurnIfIdle(context.nativeTurnId); + // Closing or cleaning a thread kills its terminals, but their command + // items may never complete while the shared provider session stays alive. + const terminalizeClosedThreadCommands = Effect.fn( + "CodexAdapterV2.terminalizeClosedThreadCommands", + )(function* (nativeThreadId: string) { + const completedAt = yield* DateTime.now; + for (const context of (yield* Ref.get(settledTurns)).values()) { + if (context.providerThread.nativeThreadRef?.nativeId !== nativeThreadId) continue; + const commands = (yield* Ref.get(runningCommandItemsByTurn)).get(context.nativeTurnId); + if (commands !== undefined) { + context.closedCommandItemIds = new Set(commands.keys()); } - }).pipe(Effect.orDie, turnTerminalizationPermit.withPermits(1)), + yield* terminalizeRunningCommandItems( + context, + context.nativeTurnId, + "interrupted", + completedAt, + ); + yield* Ref.update(runningCommandItemsByTurn, (current) => { + const updated = new Map(current); + updated.delete(context.nativeTurnId); + return updated; + }); + yield* releaseSettledTurnIfIdle(context.nativeTurnId); + } + }, turnTerminalizationPermit.withPermits(1)); + + yield* client.handleServerNotification("thread/closed", (payload) => + terminalizeClosedThreadCommands(payload.threadId).pipe(Effect.orDie), ); yield* client.handleServerNotification("item/started", (payload) => @@ -6799,12 +6801,24 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ), ), - // Dropping this connection's subscription lets the shared app-server - // shut the native thread (and its MCP servers) down once it is idle. + // Unsubscribing lets the shared app-server shut the native thread and its + // MCP servers down eventually, so background terminals are cleaned first. unloadThread: Effect.fn("CodexAdapterV2.unloadThread")( function* (unloadInput) { const nativeThreadId = yield* getNativeThreadId(unloadInput.providerThread); sessionApprovals.delete(nativeThreadId); + yield* client.raw + .request("thread/backgroundTerminals/clean", { threadId: nativeThreadId }) + .pipe( + Effect.timeout("5 seconds"), + Effect.andThen(terminalizeClosedThreadCommands(nativeThreadId)), + Effect.catch((cause) => + Effect.logWarning("orchestration-v2.codex-background-terminal-clean-failed", { + providerThreadId: unloadInput.providerThread.id, + cause, + }), + ), + ); yield* client.request("thread/unsubscribe", { threadId: nativeThreadId }); }, (effect, unloadInput) => From c7724da6a702559fa21e9bf269454c3ec15429c2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 08:42:21 +0000 Subject: [PATCH 3/6] fix(server): cleaned Codex commands cannot wake a thread being unloaded Codex can report the commands it killed before answering thread/backgroundTerminals/clean. Mark the thread's running commands closed before sending the clean so those completions cannot offer a post-settle continuation, and reopen them if the clean fails. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 142 ++++++++++-------- .../Adapters/CodexAdapterV2.ts | 36 ++++- 2 files changed, 108 insertions(+), 70 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index f181f8cb87d7a..ab281e6774f9c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -2905,78 +2905,88 @@ describe("CodexAdapterV2 post-settle continuation", () => { // Codex never closes an unsubscribed thread on its own, so its background // terminals outlive the settle unless unloading cleans them. - it.effect("cleans the thread's background terminals when unloading it", () => - Effect.gen(function* () { - const transcript = makeCodexReplayTranscript({ - scenario: "codex-background-unload-clean", - entries: [ - ...backgroundExecTranscript.entries.slice(0, -1), - { - type: "expect_outbound", - label: "clean-background-terminals", - frame: { - id: 4, - method: "thread/backgroundTerminals/clean", - params: { threadId: BG_NATIVE_THREAD }, + // Codex may report the killed command before or after it answers the clean. + it.effect.each(["before-clean-result", "after-unsubscribe"] as const)( + "cleans the thread's background terminals when unloading it (completion %s)", + (completionAt) => + Effect.gen(function* () { + const cleanedCompletion = { + type: "emit_inbound" as const, + label: "item/completed/command-cleaned", + frame: { + method: "item/completed", + params: { + item: backgroundCommandItem("completed"), + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, }, }, - { - type: "emit_inbound", - label: "clean-background-terminals-result", - frame: { id: 4, result: {} }, - }, - { - type: "expect_outbound", - label: "unsubscribe", - frame: { id: 5, method: "thread/unsubscribe", params: { threadId: BG_NATIVE_THREAD } }, - }, - { - type: "emit_inbound", - label: "item/completed/command-cleaned", - frame: { - method: "item/completed", - params: { - item: backgroundCommandItem("completed"), - threadId: BG_NATIVE_THREAD, - turnId: BG_NATIVE_TURN, + }; + const transcript = makeCodexReplayTranscript({ + scenario: `codex-background-unload-clean-${completionAt}`, + entries: [ + ...backgroundExecTranscript.entries.slice(0, -1), + { + type: "expect_outbound", + label: "clean-background-terminals", + frame: { + id: 4, + method: "thread/backgroundTerminals/clean", + params: { threadId: BG_NATIVE_THREAD }, }, }, - }, - { - type: "emit_inbound", - label: "unsubscribe-result", - frame: { id: 5, result: { status: "unsubscribed" } }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-codex-unload-clean"), - text: BG_PROMPT, - }), - ); - yield* harness.firstTerminal; - yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread }); - assert.deepEqual( - harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === BG_COMMAND_ITEM - ? [event.turnItem.status] - : [], + ...(completionAt === "before-clean-result" ? [cleanedCompletion] : []), + { + type: "emit_inbound", + label: "clean-background-terminals-result", + frame: { id: 4, result: {} }, + }, + { + type: "expect_outbound", + label: "unsubscribe", + frame: { + id: 5, + method: "thread/unsubscribe", + params: { threadId: BG_NATIVE_THREAD }, + }, + }, + ...(completionAt === "after-unsubscribe" ? [cleanedCompletion] : []), + { + type: "emit_inbound", + label: "unsubscribe-result", + frame: { id: 5, result: { status: "unsubscribed" } }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-codex-unload-clean"), + text: BG_PROMPT, + }), + ); + yield* harness.firstTerminal; + yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread }); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === BG_COMMAND_ITEM + ? [event.turnItem.status] + : [], + ), + ["running", "interrupted"], + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.equal(harness.continuationRequests.length, 0); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), - ["running", "interrupted"], - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), - ), ); const backgroundStopCases = [true, false, "still_running", "thread_unloaded"] as const; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index e95cfe173e295..736f4da98670a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -4600,6 +4600,29 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } }, turnTerminalizationPermit.withPermits(1)); + // Codex can report cleaned commands before it answers the clean, and those + // completions must not wake the agent; the returned effect reopens them. + const closeThreadCommands = Effect.fn("CodexAdapterV2.closeThreadCommands")(function* ( + nativeThreadId: string, + ) { + const running = yield* Ref.get(runningCommandItemsByTurn); + const reopen: Array | undefined]> = + []; + for (const context of (yield* Ref.get(settledTurns)).values()) { + if (context.providerThread.nativeThreadRef?.nativeId !== nativeThreadId) continue; + const commands = running.get(context.nativeTurnId); + if (commands === undefined) continue; + reopen.push([context, context.closedCommandItemIds]); + context.closedCommandItemIds = new Set(commands.keys()); + } + return Effect.sync(() => { + for (const [context, closed] of reopen) { + if (closed === undefined) delete context.closedCommandItemIds; + else context.closedCommandItemIds = closed; + } + }); + }, turnTerminalizationPermit.withPermits(1)); + yield* client.handleServerNotification("thread/closed", (payload) => terminalizeClosedThreadCommands(payload.threadId).pipe(Effect.orDie), ); @@ -6807,16 +6830,21 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( function* (unloadInput) { const nativeThreadId = yield* getNativeThreadId(unloadInput.providerThread); sessionApprovals.delete(nativeThreadId); + const reopenCommands = yield* closeThreadCommands(nativeThreadId); yield* client.raw .request("thread/backgroundTerminals/clean", { threadId: nativeThreadId }) .pipe( Effect.timeout("5 seconds"), Effect.andThen(terminalizeClosedThreadCommands(nativeThreadId)), Effect.catch((cause) => - Effect.logWarning("orchestration-v2.codex-background-terminal-clean-failed", { - providerThreadId: unloadInput.providerThread.id, - cause, - }), + reopenCommands.pipe( + Effect.andThen( + Effect.logWarning( + "orchestration-v2.codex-background-terminal-clean-failed", + { providerThreadId: unloadInput.providerThread.id, cause }, + ), + ), + ), ), ); yield* client.request("thread/unsubscribe", { threadId: nativeThreadId }); From ced2c32f672a78dad42cd130e7e2f4eccfa8a5b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 09:04:46 +0000 Subject: [PATCH 4/6] fix(server): unloading a Codex thread suppresses wakes instead of dropping completions Marking running commands closed before the clean and reopening them on failure lost completions that arrived in between, and could undo a thread/closed that landed mid-clean. Flag the thread's settled turns as unloading instead: completions still update their items, but they no longer offer a post-settle continuation. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 16 ++++--- .../Adapters/CodexAdapterV2.ts | 44 +++++-------------- 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index ab281e6774f9c..8c5950d6a12e1 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -2905,8 +2905,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { // Codex never closes an unsubscribed thread on its own, so its background // terminals outlive the settle unless unloading cleans them. - // Codex may report the killed command before or after it answers the clean. - it.effect.each(["before-clean-result", "after-unsubscribe"] as const)( + // Codex may report the killed command before or after it answers the clean, + // and an older Codex without the clean method still unsubscribes. + it.effect.each(["before-clean-result", "after-unsubscribe", "before-clean-failure"] as const)( "cleans the thread's background terminals when unloading it (completion %s)", (completionAt) => Effect.gen(function* () { @@ -2935,11 +2936,14 @@ describe("CodexAdapterV2 post-settle continuation", () => { params: { threadId: BG_NATIVE_THREAD }, }, }, - ...(completionAt === "before-clean-result" ? [cleanedCompletion] : []), + ...(completionAt === "after-unsubscribe" ? [] : [cleanedCompletion]), { type: "emit_inbound", label: "clean-background-terminals-result", - frame: { id: 4, result: {} }, + frame: + completionAt === "before-clean-failure" + ? { id: 4, error: { code: -32601, message: "method not found" } } + : { id: 4, result: {} }, }, { type: "expect_outbound", @@ -2977,7 +2981,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { ? [event.turnItem.status] : [], ), - ["running", "interrupted"], + completionAt === "after-unsubscribe" + ? ["running", "interrupted"] + : ["running", "completed"], ); assert.isFalse(yield* harness.hasPendingBackgroundWork); assert.equal(harness.continuationRequests.length, 0); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 736f4da98670a..7343e259dcbef 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1110,6 +1110,7 @@ function withCodexManagedFailure( interface ActiveCodexTurnContext { closedCommandItemIds?: ReadonlySet; + unloading?: boolean; latestProviderFailure?: { readonly nativeMessage: string; readonly nativeCode: string | null; @@ -4600,29 +4601,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } }, turnTerminalizationPermit.withPermits(1)); - // Codex can report cleaned commands before it answers the clean, and those - // completions must not wake the agent; the returned effect reopens them. - const closeThreadCommands = Effect.fn("CodexAdapterV2.closeThreadCommands")(function* ( - nativeThreadId: string, - ) { - const running = yield* Ref.get(runningCommandItemsByTurn); - const reopen: Array | undefined]> = - []; - for (const context of (yield* Ref.get(settledTurns)).values()) { - if (context.providerThread.nativeThreadRef?.nativeId !== nativeThreadId) continue; - const commands = running.get(context.nativeTurnId); - if (commands === undefined) continue; - reopen.push([context, context.closedCommandItemIds]); - context.closedCommandItemIds = new Set(commands.keys()); - } - return Effect.sync(() => { - for (const [context, closed] of reopen) { - if (closed === undefined) delete context.closedCommandItemIds; - else context.closedCommandItemIds = closed; - } - }); - }, turnTerminalizationPermit.withPermits(1)); - yield* client.handleServerNotification("thread/closed", (payload) => terminalizeClosedThreadCommands(payload.threadId).pipe(Effect.orDie), ); @@ -4828,6 +4806,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if ( context.subagent === null && continuationRequests !== undefined && + context.unloading !== true && !(yield* Ref.get(interruptingNativeTurns)).has(payload.turnId) ) { const alreadyOffered = yield* Ref.modify( @@ -6830,21 +6809,22 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( function* (unloadInput) { const nativeThreadId = yield* getNativeThreadId(unloadInput.providerThread); sessionApprovals.delete(nativeThreadId); - const reopenCommands = yield* closeThreadCommands(nativeThreadId); + // Codex can report cleaned commands before it answers the clean; they must not wake the agent. + for (const context of (yield* Ref.get(settledTurns)).values()) { + if (context.providerThread.nativeThreadRef?.nativeId === nativeThreadId) { + context.unloading = true; + } + } yield* client.raw .request("thread/backgroundTerminals/clean", { threadId: nativeThreadId }) .pipe( Effect.timeout("5 seconds"), Effect.andThen(terminalizeClosedThreadCommands(nativeThreadId)), Effect.catch((cause) => - reopenCommands.pipe( - Effect.andThen( - Effect.logWarning( - "orchestration-v2.codex-background-terminal-clean-failed", - { providerThreadId: unloadInput.providerThread.id, cause }, - ), - ), - ), + Effect.logWarning("orchestration-v2.codex-background-terminal-clean-failed", { + providerThreadId: unloadInput.providerThread.id, + cause, + }), ), ); yield* client.request("thread/unsubscribe", { threadId: nativeThreadId }); From c5ae67aca8b60fde05ac9131e22b155e31910edf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 09:28:10 +0000 Subject: [PATCH 5/6] fix(server): mark unloading Codex turns under the terminalization permit A turn publishes its terminal event before it enters settledTurns, so an unload scanning without the permit could miss it and let a cleaned command wake the agent. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/CodexAdapterV2.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 7343e259dcbef..a2ed6281897a2 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -6810,11 +6810,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const nativeThreadId = yield* getNativeThreadId(unloadInput.providerThread); sessionApprovals.delete(nativeThreadId); // Codex can report cleaned commands before it answers the clean; they must not wake the agent. - for (const context of (yield* Ref.get(settledTurns)).values()) { - if (context.providerThread.nativeThreadRef?.nativeId === nativeThreadId) { - context.unloading = true; + yield* Effect.gen(function* () { + for (const context of (yield* Ref.get(settledTurns)).values()) { + if (context.providerThread.nativeThreadRef?.nativeId === nativeThreadId) { + context.unloading = true; + } } - } + }).pipe(turnTerminalizationPermit.withPermits(1)); yield* client.raw .request("thread/backgroundTerminals/clean", { threadId: nativeThreadId }) .pipe( From d8a4110d3b3142321bf4a02bb6a88a44cfd075b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:15:32 +0000 Subject: [PATCH 6/6] test(server): provide NodeCrypto to the Codex unload-clean tests Main's adapter now needs Digest, which the merged cases did not provide. AI-assisted; verified with the tests and tools listed above. --- .../src/orchestration-v2/Adapters/CodexAdapterV2.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 53008759c3f0c..e0f0ba53a832c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -4766,7 +4766,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), );