fix(codex): harden overflow binding recovery

This commit is contained in:
Peter Steinberger
2026-05-30 07:51:15 +02:00
parent ef94eb0c31
commit 4021ea58ad
4 changed files with 127 additions and 38 deletions

View File

@@ -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<ContextEngine["compact"]>(async () => ({
ok: true,
compacted: true,
result: { summary: "summary", firstKeptEntryId: "entry-1", tokensBefore: 100_000 },
}));
const assemble = vi.fn(
async ({ messages, prompt }: Parameters<ContextEngine["assemble"]>[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");

View File

@@ -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({

View File

@@ -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();
});

View File

@@ -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;
}