Repository navigation
fix(server): settling a Codex thread stops the background commands its agent left running #25863
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c5a9ef7
f7a0a23
c7724da
ced2c32
c5ae67a
dc603e0
9f5606a
d8a4110
503d797
f70255b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1224,6 +1224,7 @@ interface ActiveCodexTurnContext { | |
| capacityRetryAttempt?: number; | ||
| capacityRetryPending?: Deferred.Deferred<ActiveCodexTurnContext | undefined>; | ||
| closedCommandItemIds?: ReadonlySet<string>; | ||
| unloading?: boolean; | ||
| latestProviderFailure?: { | ||
| readonly nativeMessage: string; | ||
| readonly nativeCode: string | null; | ||
|
|
@@ -5100,36 +5101,40 @@ 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. | ||
| // 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()); | ||
| } | ||
| 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) => | ||
| Effect.gen(function* () { | ||
| const closing = threadClosings.get(payload.threadId); | ||
| threadClosings.delete(payload.threadId); | ||
| if (closing !== undefined) yield* Deferred.succeed(closing, undefined); | ||
| 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); | ||
| } | ||
| }).pipe(Effect.orDie, turnTerminalizationPermit.withPermits(1)), | ||
| yield* terminalizeClosedThreadCommands(payload.threadId); | ||
| }).pipe(Effect.orDie), | ||
| ); | ||
|
|
||
| yield* client.handleServerNotification("item/started", (payload) => | ||
|
|
@@ -5353,6 +5358,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( | ||
|
|
@@ -7813,12 +7819,32 @@ 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); | ||
| // Codex can report cleaned commands before it answers the clean; they must not wake the agent. | ||
| 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( | ||
| Effect.timeout("5 seconds"), | ||
| Effect.andThen(terminalizeClosedThreadCommands(nativeThreadId)), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Terminalize settled commands after an archive whose cleanup failed. When Terminalize the settled turns after the archive succeeds: Suggested fix
|
||
| Effect.catch((cause) => | ||
| Effect.logWarning("orchestration-v2.codex-background-terminal-clean-failed", { | ||
| providerThreadId: unloadInput.providerThread.id, | ||
| cause, | ||
| }), | ||
| ), | ||
| ); | ||
| // Archive also unloads, inline and without thread/closed; resumeNativeThread | ||
| // unarchives it on the next turn. | ||
| if (unloadInput.archive === true) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: pingdotgg/t3code
Length of output: 8211
Script executed:
Repository: pingdotgg/t3code
Length of output: 23160
Script executed:
Repository: pingdotgg/t3code
Length of output: 23695
Script executed:
Repository: pingdotgg/t3code
Length of output: 12278
Script executed:
Repository: pingdotgg/t3code
Length of output: 11064
Script executed:
Repository: pingdotgg/t3code
Length of output: 27030
Script executed:
Repository: pingdotgg/t3code
Length of output: 9598
Script executed:
Repository: pingdotgg/t3code
Length of output: 20192
Script executed:
Repository: pingdotgg/t3code
Length of output: 1507
Script executed:
Repository: pingdotgg/t3code
Length of output: 3885
Script executed:
Repository: pingdotgg/t3code
Length of output: 7992
Script executed:
Repository: pingdotgg/t3code
Length of output: 12515
Script executed:
Repository: pingdotgg/t3code
Length of output: 11377
Script executed:
Repository: pingdotgg/t3code
Length of output: 15613
Script executed:
Repository: pingdotgg/t3code
Length of output: 10306
Script executed:
Repository: pingdotgg/t3code
Length of output: 10747
Reserve time for the unsubscribe response.
If clean times out and unsubscribe takes more than the remaining five seconds, the manager can time out before unsubscribe completes. It then retains the old linked-environments record, which can cause an unnecessary unload on a later resume. Give unsubscribe its own response budget.