Skip to content

Commit 24ea4a1

Browse files
committed
fix(codex): recover raw missing thread compaction failures
1 parent fcbc254 commit 24ea4a1

6 files changed

Lines changed: 160 additions & 24 deletions

File tree

src/agents/command/cli-compaction.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,86 @@ describe("runCliTurnCompactionLifecycle", () => {
769769
expect(updatedEntry?.compactionCount).toBe(1);
770770
});
771771

772+
it("falls back to context-engine compaction when Codex native compaction returns a raw missing thread reason", async () => {
773+
const sessionKey = "agent:main:codex-raw-stale-binding";
774+
const sessionId = "session-codex-raw-stale-binding";
775+
const sessionFile = path.join(tmpDir, "session-codex-raw-stale-binding.jsonl");
776+
const storePath = path.join(tmpDir, "sessions-codex-raw-stale-binding.json");
777+
await writeSessionFile({ sessionFile, sessionId });
778+
779+
const sessionEntry: SessionEntry = {
780+
sessionId,
781+
updatedAt: Date.now(),
782+
sessionFile,
783+
contextTokens: 1_000,
784+
totalTokens: 950,
785+
totalTokensFresh: true,
786+
agentHarnessId: "codex",
787+
};
788+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
789+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
790+
791+
const compactCalls: Array<Parameters<ContextEngine["compact"]>[0]> = [];
792+
const compactAgentHarnessSession = vi.fn(async () => ({
793+
ok: false,
794+
compacted: false,
795+
reason: "thread not found: thread-raw",
796+
}));
797+
const maintenance = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
798+
const recordCliCompactionInStore = vi.fn(async () => ({
799+
...sessionEntry,
800+
compactionCount: 1,
801+
}));
802+
setCliCompactionTestDeps({
803+
resolveContextEngine: async () => buildContextEngine({ compactCalls }),
804+
ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined),
805+
maybeCompactAgentHarnessSession: compactAgentHarnessSession as never,
806+
createPreparedEmbeddedAgentSettingsManager: async () => ({
807+
getCompactionReserveTokens: () => 200,
808+
getCompactionKeepRecentTokens: () => 0,
809+
applyOverrides: () => {},
810+
}),
811+
shouldPreemptivelyCompactBeforePrompt: () => ({
812+
route: "fits",
813+
shouldCompact: false,
814+
estimatedPromptTokens: 600,
815+
promptBudgetBeforeReserve: 800,
816+
overflowTokens: 0,
817+
toolResultReducibleChars: 0,
818+
effectiveReserveTokens: 200,
819+
}),
820+
resolveLiveToolResultMaxChars: () => 20_000,
821+
runContextEngineMaintenance: maintenance,
822+
recordCliCompactionInStore,
823+
});
824+
825+
const updatedEntry = await runCliTurnCompactionLifecycle({
826+
cfg: {} as OpenClawConfig,
827+
sessionId,
828+
sessionKey,
829+
sessionEntry,
830+
sessionStore,
831+
storePath,
832+
sessionAgentId: "main",
833+
workspaceDir: tmpDir,
834+
agentDir: tmpDir,
835+
provider: "codex",
836+
model: "gpt-5.5",
837+
});
838+
839+
expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1);
840+
expect(compactCalls).toHaveLength(1);
841+
expect(maintenance).toHaveBeenCalledTimes(1);
842+
expect(recordCliCompactionInStore).toHaveBeenCalledWith(
843+
expect.objectContaining({
844+
provider: "codex",
845+
sessionKey,
846+
tokensAfter: undefined,
847+
}),
848+
);
849+
expect(updatedEntry?.compactionCount).toBe(1);
850+
});
851+
772852
it("keeps successful context-engine fallback when post-compaction maintenance fails", async () => {
773853
const sessionKey = "agent:main:codex-stale-maintenance";
774854
const sessionId = "session-codex-stale-maintenance";

src/agents/command/cli-compaction.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { runContextEngineMaintenance as runContextEngineMaintenanceImpl } from "
2222
import { shouldPreemptivelyCompactBeforePrompt as shouldPreemptivelyCompactBeforePromptImpl } from "../embedded-agent-runner/run/preemptive-compaction.js";
2323
import { resolveLiveToolResultMaxChars as resolveLiveToolResultMaxCharsImpl } from "../embedded-agent-runner/tool-result-truncation.js";
2424
import type { EmbeddedAgentCompactResult } from "../embedded-agent-runner/types.js";
25+
import { isRecoverableNativeHarnessBindingFailure } from "../harness/compaction-recovery.js";
2526
import { ensureSelectedAgentHarnessPlugin as ensureSelectedAgentHarnessPluginImpl } from "../harness/runtime-plugin.js";
2627
import { maybeCompactAgentHarnessSession as maybeCompactAgentHarnessSessionImpl } from "../harness/selection.js";
2728
import type { AgentMessage } from "../runtime/index.js";
@@ -174,16 +175,6 @@ function isUnsupportedNativeHarnessCompaction(
174175
return result?.ok === false && result.failure?.reason === "unsupported_harness_compaction";
175176
}
176177

177-
function isRecoverableNativeHarnessCompactionFailure(
178-
result: EmbeddedAgentCompactResult | undefined,
179-
): boolean {
180-
return (
181-
result?.ok === false &&
182-
(result.failure?.reason === "missing_thread_binding" ||
183-
result.failure?.reason === "stale_thread_binding")
184-
);
185-
}
186-
187178
function readAgentIdFromSessionKey(sessionKey: string): string | undefined {
188179
const parts = sessionKey.trim().split(":");
189180
return parts[0] === "agent" && parts[1]?.trim() ? parts[1].trim() : undefined;
@@ -411,7 +402,7 @@ async function compactNativeHarnessCliTranscript(params: {
411402
if (!result?.compacted) {
412403
const fallbackToContextEngine =
413404
isUnsupportedNativeHarnessCompaction(result) ||
414-
isRecoverableNativeHarnessCompactionFailure(result);
405+
isRecoverableNativeHarnessBindingFailure(result);
415406
log.warn(
416407
`CLI native harness compaction did not reduce context for ${params.provider}/${params.model}: ${result?.reason ?? "nothing to compact"}`,
417408
);

src/agents/embedded-agent-runner/compact.queued.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { resolveUserPath } from "../../utils.js";
2020
import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js";
2121
import { resolveContextWindowInfo } from "../context-window-guard.js";
2222
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
23+
import { isRecoverableNativeHarnessBindingFailure } from "../harness/compaction-recovery.js";
2324
import {
2425
maybeCompactAgentHarnessSession,
2526
resolveAgentHarnessPolicy,
@@ -53,11 +54,7 @@ import { normalizeContextTokenBudget } from "./utils.js";
5354
function shouldFallbackAfterHarnessCompaction(
5455
result: EmbeddedAgentCompactResult | undefined,
5556
): boolean {
56-
return (
57-
result?.ok === false &&
58-
(result.failure?.reason === "missing_thread_binding" ||
59-
result.failure?.reason === "stale_thread_binding")
60-
);
57+
return isRecoverableNativeHarnessBindingFailure(result);
6158
}
6259

6360
const DEFERRED_CONTEXT_ENGINE_COMPACTION_SCHEDULE_FAILURE_REASON =
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { EmbeddedAgentCompactResult } from "../embedded-agent-runner/types.js";
2+
3+
export function isRecoverableNativeHarnessBindingReason(reason: unknown): boolean {
4+
if (typeof reason !== "string") {
5+
return false;
6+
}
7+
const normalized = reason.trim().toLowerCase();
8+
return (
9+
normalized === "missing_thread_binding" ||
10+
normalized === "stale_thread_binding" ||
11+
normalized.includes("thread not found") ||
12+
normalized.includes("no thread binding")
13+
);
14+
}
15+
16+
export function isRecoverableNativeHarnessBindingFailure(
17+
result: EmbeddedAgentCompactResult | undefined,
18+
): boolean {
19+
return (
20+
result?.ok === false &&
21+
(isRecoverableNativeHarnessBindingReason(result.failure?.reason) ||
22+
isRecoverableNativeHarnessBindingReason(result.reason))
23+
);
24+
}

src/auto-reply/reply/agent-runner-memory.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,57 @@ describe("runMemoryFlushIfNeeded", () => {
929929
},
930930
);
931931

932+
it("continues after an unstructured thread-not-found preflight compaction failure", async () => {
933+
const sessionFile = path.join(rootDir, "session.jsonl");
934+
await fs.writeFile(
935+
sessionFile,
936+
`${JSON.stringify({ message: { role: "user", content: "x".repeat(5_000) } })}\n`,
937+
"utf8",
938+
);
939+
registerMemoryFlushPlanResolverForTest(() => ({
940+
softThresholdTokens: 1,
941+
forceFlushTranscriptBytes: 1_000_000_000,
942+
reserveTokensFloor: 0,
943+
prompt: "Pre-compaction memory flush.\nNO_REPLY",
944+
systemPrompt: "Write memory to memory/YYYY-MM-DD.md.",
945+
relativePath: "memory/2023-11-14.md",
946+
}));
947+
compactEmbeddedAgentSessionMock.mockResolvedValueOnce({
948+
ok: false,
949+
compacted: false,
950+
reason: "thread not found: <codex-thread-id>",
951+
});
952+
const sessionEntry: SessionEntry = {
953+
sessionId: "session",
954+
sessionFile,
955+
updatedAt: Date.now(),
956+
totalTokens: 120,
957+
totalTokensFresh: true,
958+
};
959+
const sessionStore = { "agent:main:telegram:group:redacted": sessionEntry };
960+
961+
const entry = await runPreflightCompactionIfNeeded({
962+
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
963+
followupRun: createTestFollowupRun({
964+
sessionId: "session",
965+
sessionFile,
966+
sessionKey: "agent:main:telegram:group:redacted",
967+
}),
968+
defaultModel: "anthropic/claude-opus-4-6",
969+
agentCfgContextTokens: 100,
970+
sessionEntry,
971+
sessionStore,
972+
sessionKey: "agent:main:telegram:group:redacted",
973+
storePath: path.join(rootDir, "sessions.json"),
974+
isHeartbeat: false,
975+
replyOperation: createReplyOperation(),
976+
});
977+
978+
expect(entry).toBe(sessionEntry);
979+
expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1);
980+
expect(incrementCompactionCountMock).not.toHaveBeenCalled();
981+
});
982+
932983
it("still fails preflight compaction for non-binding native harness failures", async () => {
933984
const sessionFile = path.join(rootDir, "session.jsonl");
934985
await fs.writeFile(

src/auto-reply/reply/agent-runner-memory.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
classifyCompactionReason,
88
DEFERRED_CONTEXT_ENGINE_COMPACTION_REASON,
99
} from "../../agents/embedded-agent-runner/compact-reasons.js";
10+
import { isRecoverableNativeHarnessBindingFailure } from "../../agents/harness/compaction-recovery.js";
1011
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
1112
import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js";
1213
import { runWithModelFallback } from "../../agents/model-fallback.js";
@@ -129,14 +130,6 @@ const memoryDeps = {
129130
now: () => Date.now(),
130131
};
131132

132-
function isRecoverableNativeHarnessBindingFailure(result: unknown): boolean {
133-
if (!result || typeof result !== "object") {
134-
return false;
135-
}
136-
const failure = (result as { failure?: { reason?: unknown } }).failure;
137-
return failure?.reason === "missing_thread_binding" || failure?.reason === "stale_thread_binding";
138-
}
139-
140133
export function setAgentRunnerMemoryTestDeps(overrides?: Partial<typeof memoryDeps>): void {
141134
Object.assign(memoryDeps, {
142135
runWithModelFallback,

0 commit comments

Comments
 (0)