From 4021ea58ad65df81d2c4e061ba457bb73ea322c4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 07:51:15 +0200 Subject: [PATCH] fix(codex): harden overflow binding recovery --- .../run-attempt.context-engine.test.ts | 72 +++++++++++++++++++ .../codex/src/app-server/run-attempt.ts | 44 ++++++++---- .../src/app-server/startup-binding.test.ts | 7 +- .../codex/src/app-server/startup-binding.ts | 42 ++++++----- 4 files changed, 127 insertions(+), 38 deletions(-) diff --git a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts index 7b3207fdc05..e17b98c4c80 100644 --- a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts +++ b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts @@ -1236,6 +1236,78 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-before"); }); + it("preserves a newer context-engine binding when a stale resumed thread overflows", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + SessionManager.open(sessionFile).appendMessage( + assistantMessage("pre-compaction context", Date.now()) as never, + ); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-old", + cwd: workspaceDir, + dynamicToolsFingerprint: "[]", + contextEngine: { + schemaVersion: 1, + engineId: "lossless-claw", + policyFingerprint: + '{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}', + projection: { + schemaVersion: 1, + mode: "thread_bootstrap", + epoch: "epoch-before", + }, + }, + }); + const compact = vi.fn(async () => ({ + ok: true, + compacted: true, + result: { summary: "summary", firstKeptEntryId: "entry-1", tokensBefore: 100_000 }, + })); + const assemble = vi.fn( + async ({ messages, prompt }: Parameters[0]) => ({ + messages: [...messages, userMessage(prompt ?? "", 11)], + estimatedTokens: 42, + systemPromptAddition: "context-engine system", + contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-before" }, + }), + ); + const contextEngine = createContextEngine({ assemble, compact }); + const harness = createStartedThreadHarness(async (method, requestParams) => { + const request = requireRecord(requestParams, `${method} params`); + if (method === "thread/resume") { + return threadStartResult("thread-old"); + } + if (method === "turn/start" && request.threadId === "thread-old") { + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-new", + cwd: workspaceDir, + dynamicToolsFingerprint: "[]", + }); + throw new Error("Codex ran out of room in the model's context window"); + } + if (method === "thread/start") { + return threadStartResult("thread-fresh"); + } + return undefined; + }); + const params = createParams(sessionFile, workspaceDir); + params.contextEngine = contextEngine; + params.contextTokenBudget = 400_000; + + await expect(runCodexAppServerAttempt(params)).rejects.toThrow( + "Codex ran out of room in the model's context window", + ); + + expect(compact).not.toHaveBeenCalled(); + expect(harness.requests.map((request) => request.method)).toEqual([ + "thread/resume", + "turn/start", + "thread/unsubscribe", + ]); + const savedBinding = await readCodexAppServerBinding(sessionFile); + expect(savedBinding?.threadId).toBe("thread-new"); + }); + it("clears a resumed context-engine binding when a turn terminally overflows", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index dd6f2c811ac..a7f21300fcb 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1738,19 +1738,33 @@ export async function runCodexAppServerAttempt( ); try { const preRetrySessionFile = activeSessionFile; - await clearCodexAppServerBinding(preRetrySessionFile); - if (activeSessionFile !== preRetrySessionFile) { - await clearCodexAppServerBinding(activeSessionFile); - } - thread = await restartContextEngineCodexThread(); - emitCodexAppServerEvent(params, { - stream: "codex_app_server.lifecycle", - data: { phase: "thread_ready_retry", threadId: thread.threadId }, - }); - try { - turn = await startCodexTurn(); - } catch (retryError) { - turnStartError = retryError; + const clearedPreRetryBinding = await clearCodexAppServerBindingForThread( + preRetrySessionFile, + thread.threadId, + ); + const clearedActiveBinding = + activeSessionFile !== preRetrySessionFile + ? await clearCodexAppServerBindingForThread(activeSessionFile, thread.threadId) + : false; + if (!clearedPreRetryBinding && !clearedActiveBinding) { + embeddedAgentLog.warn( + "codex app-server preserved newer context-engine binding after resume overflow; skipping fresh retry", + { + threadId: thread.threadId, + error: formatErrorMessage(turnStartError), + }, + ); + } else { + thread = await restartContextEngineCodexThread(); + emitCodexAppServerEvent(params, { + stream: "codex_app_server.lifecycle", + data: { phase: "thread_ready_retry", threadId: thread.threadId }, + }); + try { + turn = await startCodexTurn(); + } catch (retryError) { + turnStartError = retryError; + } } } catch (retrySetupError) { turnStartError = retrySetupError; @@ -2046,9 +2060,9 @@ export async function runCodexAppServerAttempt( }, ); const preClearSessionFile = activeSessionFile; - await clearCodexAppServerBinding(preClearSessionFile); + await clearCodexAppServerBindingForThread(preClearSessionFile, thread.threadId); if (activeSessionFile !== preClearSessionFile) { - await clearCodexAppServerBinding(activeSessionFile); + await clearCodexAppServerBindingForThread(activeSessionFile, thread.threadId); } } const refreshedUsageLimitPromptError = await refreshCodexUsageLimitPromptError({ diff --git a/extensions/codex/src/app-server/startup-binding.test.ts b/extensions/codex/src/app-server/startup-binding.test.ts index 05276ac35ca..fda0cff125f 100644 --- a/extensions/codex/src/app-server/startup-binding.test.ts +++ b/extensions/codex/src/app-server/startup-binding.test.ts @@ -300,8 +300,6 @@ describe("Codex app-server startup binding", () => { }), ].join("\n") + "\n", ); - const readFileSpy = vi.spyOn(fs, "readFile"); - const binding = await rotateOversizedCodexAppServerStartupBinding({ binding: await readCodexAppServerBinding(sessionFile), sessionFile, @@ -319,7 +317,6 @@ describe("Codex app-server startup binding", () => { }); expect(binding).toBeUndefined(); - expect(readFileSpy.mock.calls.some(([file]) => file === rolloutFile)).toBe(false); const savedBinding = await readCodexAppServerBinding(sessionFile); expect(savedBinding).toBeUndefined(); }); @@ -448,7 +445,7 @@ describe("Codex app-server startup binding", () => { await fs.mkdir(rolloutDir, { recursive: true }); const rolloutFile = path.join(rolloutDir, "rollout-thread-existing.jsonl"); await fs.writeFile(rolloutFile, "x".repeat(2_000)); - const readFileSpy = vi.spyOn(fs, "readFile"); + const openSpy = vi.spyOn(fs, "open"); const binding = await rotateOversizedCodexAppServerStartupBinding({ binding: await readCodexAppServerBinding(sessionFile), @@ -467,7 +464,7 @@ describe("Codex app-server startup binding", () => { }); expect(binding).toBeUndefined(); - expect(readFileSpy.mock.calls.some(([file]) => file === rolloutFile)).toBe(false); + expect(openSpy.mock.calls.some(([file]) => String(file) === rolloutFile)).toBe(false); const savedBinding = await readCodexAppServerBinding(sessionFile); expect(savedBinding).toBeUndefined(); }); diff --git a/extensions/codex/src/app-server/startup-binding.ts b/extensions/codex/src/app-server/startup-binding.ts index a2c0f6f6a28..1d504cd2a44 100644 --- a/extensions/codex/src/app-server/startup-binding.ts +++ b/extensions/codex/src/app-server/startup-binding.ts @@ -296,6 +296,29 @@ export async function rotateOversizedCodexAppServerStartupBinding(params: { binding.threadId, params.codexHome, ); + const compaction = readCompactionConfig(params.config); + const shouldDeferByteGuard = + compaction?.truncateAfterCompaction === true && + params.contextEngineActive === true && + hasContextEngineThreadBootstrapProjection(binding); + if (compaction?.truncateAfterCompaction === true && !shouldDeferByteGuard) { + const maxBytes = parseCodexAppServerByteLimit(compaction.maxActiveTranscriptBytes); + if (maxBytes !== undefined) { + const oversizedFiles = rolloutFiles.filter((file) => file.bytes >= maxBytes); + if (oversizedFiles.length > 0) { + embeddedAgentLog.warn( + "codex app-server native transcript exceeded active byte limit; starting a fresh thread", + { + threadId: binding.threadId, + maxBytes, + files: oversizedFiles.map((file) => ({ path: file.path, bytes: file.bytes })), + }, + ); + await clearCodexAppServerBinding(params.sessionFile); + return undefined; + } + } + } const nativeTokenSnapshots = await Promise.all( rolloutFiles.map(async (file) => readCodexAppServerRolloutTokenSnapshot(file.path)), ); @@ -342,11 +365,10 @@ export async function rotateOversizedCodexAppServerStartupBinding(params: { await clearCodexAppServerBinding(params.sessionFile); return undefined; } - const compaction = readCompactionConfig(params.config); if (compaction?.truncateAfterCompaction !== true) { return binding; } - if (params.contextEngineActive === true && hasContextEngineThreadBootstrapProjection(binding)) { + if (shouldDeferByteGuard) { embeddedAgentLog.debug( "codex app-server deferring native transcript byte guard for context-engine thread bootstrap", { @@ -358,22 +380,6 @@ export async function rotateOversizedCodexAppServerStartupBinding(params: { ); return binding; } - const maxBytes = parseCodexAppServerByteLimit(compaction.maxActiveTranscriptBytes); - if (maxBytes !== undefined) { - const oversizedFiles = rolloutFiles.filter((file) => file.bytes >= maxBytes); - if (oversizedFiles.length > 0) { - embeddedAgentLog.warn( - "codex app-server native transcript exceeded active byte limit; starting a fresh thread", - { - threadId: binding.threadId, - maxBytes, - files: oversizedFiles.map((file) => ({ path: file.path, bytes: file.bytes })), - }, - ); - await clearCodexAppServerBinding(params.sessionFile); - return undefined; - } - } return binding; }