Skip to content

Commit a059309

Browse files
fix(agents): bound plugin-owned context-engine compaction with a safety timeout (#84083)
Merged via squash. Prepared head SHA: 9121a1a Co-authored-by: 100yenadmin <239388517+100yenadmin@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman
1 parent 3bc728e commit a059309

17 files changed

Lines changed: 752 additions & 89 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
3535
- CLI/TUI: include gateway plugin slash commands in TUI autocomplete, so connected sessions can suggest plugin-owned commands exposed by the running Gateway. (#83640) Thanks @se7en-agent.
3636
- Gateway/mobile: restore QR setup-code handoff of bounded operator tokens for iOS and Android onboarding while keeping admin and pairing scopes out of bootstrap. (#83684) Thanks @ngutman.
3737
- iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman.
38+
- Agents/compaction: bound plugin-owned CLI transcript compaction with the host safety timeout so a hung context engine can no longer stall post-turn cleanup. (#84083) Thanks @100yenadmin.
3839

3940
## 2026.5.19
4041

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
c3d3f4331b8e49a5f54aa4e322a0b03ab057715ed4f50b2b3e20fbbcbaf332db plugin-sdk-api-baseline.json
2-
7b925ff856294bc8afc54aea9bf12a038d73821b4df297c60908032e1a4d85d9 plugin-sdk-api-baseline.jsonl
1+
81675fa8adf4a3a7cc696ba77760e69224dadd15255daab2bdc83dfd8d290fed plugin-sdk-api-baseline.json
2+
78d3e47f075a6645b771071aaa27832b25b97c797cdb4777a697614157d944ca plugin-sdk-api-baseline.jsonl

extensions/codex/src/app-server/compact.test.ts

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -461,17 +461,20 @@ describe("maybeCompactCodexAppServerSession", () => {
461461
expect(details.codexThreadBindingInvalidated).toBe(true);
462462
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
463463
expect(compact).toHaveBeenCalledTimes(1);
464-
expect(compact).toHaveBeenCalledWith({
465-
sessionId: "session-1",
466-
sessionKey: "agent:main:session-1",
467-
sessionFile,
468-
tokenBudget: 777,
469-
currentTokenCount: 123,
470-
compactionTarget: "threshold",
471-
customInstructions: undefined,
472-
force: true,
473-
runtimeContext: { workspaceDir: tempDir, provider: "codex" },
474-
});
464+
expect(compact).toHaveBeenCalledWith(
465+
expect.objectContaining({
466+
sessionId: "session-1",
467+
sessionKey: "agent:main:session-1",
468+
sessionFile,
469+
tokenBudget: 777,
470+
currentTokenCount: 123,
471+
compactionTarget: "threshold",
472+
customInstructions: undefined,
473+
force: true,
474+
runtimeContext: { workspaceDir: tempDir, provider: "codex" },
475+
abortSignal: expect.any(AbortSignal),
476+
}),
477+
);
475478
expect(maintain).toHaveBeenCalledTimes(1);
476479
const [maintainCall] = maintain.mock.calls[0] ?? [];
477480
const maintainParams = maintainCall as
@@ -683,6 +686,100 @@ describe("maybeCompactCodexAppServerSession", () => {
683686
expect(compactResult.reason).toBe("below threshold");
684687
expect(maintain).not.toHaveBeenCalled();
685688
});
689+
690+
describe("owning context-engine compaction safety timeout", () => {
691+
afterEach(() => {
692+
vi.useRealTimers();
693+
});
694+
695+
it("bounds a hung owning context-engine compact() and reports a clean ok:false", async () => {
696+
const sessionFile = await writeTestBinding();
697+
const compact = vi.fn<ContextEngine["compact"]>(() => new Promise(() => {}));
698+
const contextEngine: ContextEngine = {
699+
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
700+
assemble: vi.fn() as never,
701+
ingest: vi.fn() as never,
702+
compact,
703+
};
704+
705+
vi.useFakeTimers();
706+
const pendingResult = maybeCompactCodexAppServerSession({
707+
sessionId: "session-1",
708+
sessionKey: "agent:main:session-1",
709+
sessionFile,
710+
workspaceDir: tempDir,
711+
contextEngine,
712+
// 1 s host-resolved compaction timeout.
713+
config: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } },
714+
});
715+
716+
await vi.advanceTimersByTimeAsync(1_000);
717+
const result = requireCompactResult(await pendingResult);
718+
719+
expect(result.ok).toBe(false);
720+
expect(result.compacted).toBe(false);
721+
expect(result.reason).toContain("timed out");
722+
expect(compact).toHaveBeenCalledTimes(1);
723+
expect(vi.getTimerCount()).toBe(0);
724+
});
725+
726+
it("threads a composed caller abort signal into the owning context-engine compact()", async () => {
727+
const sessionFile = await writeTestBinding();
728+
const controller = new AbortController();
729+
const compact = vi.fn<ContextEngine["compact"]>(async () => ({
730+
ok: true,
731+
compacted: false,
732+
reason: "below threshold",
733+
}));
734+
const contextEngine: ContextEngine = {
735+
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
736+
assemble: vi.fn() as never,
737+
ingest: vi.fn() as never,
738+
compact,
739+
};
740+
741+
await maybeCompactCodexAppServerSession({
742+
sessionId: "session-1",
743+
sessionKey: "agent:main:session-1",
744+
sessionFile,
745+
workspaceDir: tempDir,
746+
contextEngine,
747+
abortSignal: controller.signal,
748+
});
749+
750+
expect(compact).toHaveBeenCalledTimes(1);
751+
expect(compact.mock.calls[0]?.[0]?.abortSignal).toBeInstanceOf(AbortSignal);
752+
});
753+
754+
it("aborts a hung owning context-engine compact() when the caller signal fires", async () => {
755+
const sessionFile = await writeTestBinding();
756+
const controller = new AbortController();
757+
const compact = vi.fn<ContextEngine["compact"]>(() => new Promise(() => {}));
758+
const contextEngine: ContextEngine = {
759+
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
760+
assemble: vi.fn() as never,
761+
ingest: vi.fn() as never,
762+
compact,
763+
};
764+
765+
const pendingResult = maybeCompactCodexAppServerSession({
766+
sessionId: "session-1",
767+
sessionKey: "agent:main:session-1",
768+
sessionFile,
769+
workspaceDir: tempDir,
770+
contextEngine,
771+
abortSignal: controller.signal,
772+
});
773+
774+
controller.abort(new Error("run aborted"));
775+
const result = requireCompactResult(await pendingResult);
776+
777+
expect(result.ok).toBe(false);
778+
expect(result.compacted).toBe(false);
779+
expect(result.reason).toContain("run aborted");
780+
expect(compact).toHaveBeenCalledTimes(1);
781+
});
782+
});
686783
});
687784

688785
function createFakeCodexClient(): {

extensions/codex/src/app-server/compact.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
2+
compactContextEngineWithSafetyTimeout,
23
embeddedAgentLog,
34
formatErrorMessage,
45
isActiveHarnessContextEngine,
6+
resolveCompactionTimeoutMs,
57
resolveContextEngineOwnerPluginId,
68
runHarnessContextEngineMaintenance,
79
type CompactEmbeddedPiSessionParams,
@@ -79,17 +81,27 @@ async function compactOwningContextEngine(
7981
});
8082
let result: Awaited<ReturnType<typeof contextEngine.compact>>;
8183
try {
82-
result = await contextEngine.compact({
83-
sessionId: params.sessionId,
84-
sessionKey: params.sessionKey,
85-
sessionFile: params.sessionFile,
86-
tokenBudget: params.contextTokenBudget,
87-
currentTokenCount: params.currentTokenCount,
88-
compactionTarget: params.trigger === "manual" ? "threshold" : "budget",
89-
customInstructions: params.customInstructions,
90-
force: params.trigger === "manual",
91-
runtimeContext: params.contextEngineRuntimeContext,
92-
});
84+
// Bound the plugin-owned compaction with the same finite safety timeout
85+
// that protects native runtime compaction, and thread the caller's abort
86+
// signal through, so a slow/hung plugin compact() cannot hang the Codex
87+
// compaction lane indefinitely. A timeout/abort (or any thrown error) is
88+
// converted to a clean { ok: false } result by the catch below.
89+
result = await compactContextEngineWithSafetyTimeout(
90+
contextEngine,
91+
{
92+
sessionId: params.sessionId,
93+
sessionKey: params.sessionKey,
94+
sessionFile: params.sessionFile,
95+
tokenBudget: params.contextTokenBudget,
96+
currentTokenCount: params.currentTokenCount,
97+
compactionTarget: params.trigger === "manual" ? "threshold" : "budget",
98+
customInstructions: params.customInstructions,
99+
force: params.trigger === "manual",
100+
runtimeContext: params.contextEngineRuntimeContext,
101+
},
102+
resolveCompactionTimeoutMs(params.config),
103+
params.abortSignal,
104+
);
93105
} catch (error) {
94106
embeddedAgentLog.warn("context-engine-owned Codex app-server compaction failed", {
95107
sessionId: params.sessionId,

extensions/codex/src/app-server/run-attempt.context-engine.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,99 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
842842
expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-after");
843843
});
844844

845+
it("bounds a hung owning context-engine compaction during Codex overflow recovery", async () => {
846+
const sessionFile = path.join(tempDir, "session.jsonl");
847+
const workspaceDir = path.join(tempDir, "workspace");
848+
SessionManager.open(sessionFile).appendMessage(
849+
assistantMessage("pre-compaction context", Date.now()) as never,
850+
);
851+
await writeCodexAppServerBinding(sessionFile, {
852+
threadId: "thread-old",
853+
cwd: workspaceDir,
854+
dynamicToolsFingerprint: "[]",
855+
contextEngine: {
856+
schemaVersion: 1,
857+
engineId: "lossless-claw",
858+
policyFingerprint:
859+
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
860+
projection: {
861+
schemaVersion: 1,
862+
mode: "thread_bootstrap",
863+
epoch: "epoch-before",
864+
},
865+
},
866+
});
867+
// Owning-engine compaction that never settles. Without the safety timeout
868+
// the awaited compact() would hang the whole Codex overflow-recovery turn;
869+
// with it the call is bounded and forced compaction reports failure so the
870+
// run still proceeds on a fresh thread.
871+
const compact = vi.fn<ContextEngine["compact"]>(() => new Promise(() => {}));
872+
const assemble = vi.fn(
873+
async ({ messages, prompt }: Parameters<ContextEngine["assemble"]>[0]) => ({
874+
messages: [...messages, userMessage(prompt ?? "", 11)],
875+
estimatedTokens: 42,
876+
systemPromptAddition: "context-engine system",
877+
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-before" },
878+
}),
879+
);
880+
const contextEngine = createContextEngine({ assemble, compact });
881+
const harness = createStartedThreadHarness(async (method, requestParams) => {
882+
const request = requireRecord(requestParams, `${method} params`);
883+
if (method === "thread/resume") {
884+
return threadStartResult("thread-old");
885+
}
886+
if (method === "turn/start" && request.threadId === "thread-old") {
887+
throw new Error("Codex ran out of room in the model's context window");
888+
}
889+
if (method === "thread/start") {
890+
return threadStartResult("thread-fresh");
891+
}
892+
if (method === "turn/start" && request.threadId === "thread-fresh") {
893+
return turnStartResult("turn-fresh");
894+
}
895+
return undefined;
896+
});
897+
const params = createParams(sessionFile, workspaceDir);
898+
params.contextEngine = contextEngine;
899+
params.contextTokenBudget = 400_000;
900+
// 1 s host-resolved compaction timeout so the hung compact() is bounded
901+
// well within the 5 s run timeout used by this harness.
902+
params.config = {
903+
agents: { defaults: { compaction: { timeoutSeconds: 1 } } },
904+
} as EmbeddedRunAttemptParams["config"];
905+
906+
const run = runCodexAppServerAttempt(params);
907+
await vi.waitFor(
908+
() =>
909+
expect(harness.requests.map((request) => request.method)).toEqual([
910+
"thread/resume",
911+
"turn/start",
912+
"thread/start",
913+
"turn/start",
914+
]),
915+
{ timeout: 4_000 },
916+
);
917+
await harness.notify({
918+
method: "turn/completed",
919+
params: {
920+
threadId: "thread-fresh",
921+
turnId: "turn-fresh",
922+
turn: {
923+
id: "turn-fresh",
924+
status: "completed",
925+
items: [{ type: "agentMessage", id: "msg-1", text: "fresh answer" }],
926+
},
927+
},
928+
});
929+
const result = await run;
930+
931+
expect(result.assistantTexts).toContain("fresh answer");
932+
expect(compact).toHaveBeenCalledTimes(1);
933+
// The run-level abort signal is threaded into the owning-engine compact()
934+
// so a cooperating engine can cancel its own in-flight work.
935+
expect(compact.mock.calls[0]?.[0]?.abortSignal).toBeInstanceOf(AbortSignal);
936+
});
937+
845938
it("keeps current inbound context at the front of the Codex context-engine prompt", async () => {
846939
const sessionFile = path.join(tempDir, "session.jsonl");
847940
const workspaceDir = path.join(tempDir, "workspace");

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
buildHarnessContextEngineRuntimeContextFromUsage,
1111
buildEmbeddedAttemptToolRunContext,
1212
clearActiveEmbeddedRun,
13+
compactContextEngineWithSafetyTimeout,
1314
embeddedAgentLog,
1415
emitAgentEvent as emitGlobalAgentEvent,
1516
finalizeHarnessContextEngineTurn,
@@ -21,6 +22,7 @@ import {
2122
normalizeAgentRuntimeTools,
2223
resolveAttemptSpawnWorkspaceDir,
2324
resolveAgentHarnessBeforePromptBuildResult,
25+
resolveCompactionTimeoutMs,
2426
resolveModelAuthMode,
2527
resolveContextEngineOwnerPluginId,
2628
resolveSandboxContext,
@@ -2224,21 +2226,31 @@ export async function runCodexAppServerAttempt(
22242226
try {
22252227
const runtimeContext = buildActiveContextEngineRuntimeContext();
22262228
const overflowTokenCount = params.contextTokenBudget ?? params.contextWindowInfo?.tokens;
2227-
const compactResult = await activeContextEngine.compact({
2228-
sessionId: activeSessionId,
2229-
sessionKey: sandboxSessionKey,
2230-
sessionFile: activeSessionFile,
2231-
tokenBudget: params.contextTokenBudget,
2232-
force: true,
2233-
...(overflowTokenCount ? { currentTokenCount: overflowTokenCount } : {}),
2234-
compactionTarget: "threshold",
2235-
runtimeContext: overflowTokenCount
2236-
? {
2237-
...runtimeContext,
2238-
currentTokenCount: overflowTokenCount,
2239-
}
2240-
: runtimeContext,
2241-
});
2229+
// Bound the plugin-owned compaction with the same finite safety timeout
2230+
// that protects native runtime compaction, and thread the run-level
2231+
// abort signal through, so a slow/hung plugin compact() cannot stall
2232+
// Codex overflow recovery indefinitely. A timeout/abort surfaces as a
2233+
// thrown error handled by the catch below.
2234+
const compactResult = await compactContextEngineWithSafetyTimeout(
2235+
activeContextEngine,
2236+
{
2237+
sessionId: activeSessionId,
2238+
sessionKey: sandboxSessionKey,
2239+
sessionFile: activeSessionFile,
2240+
tokenBudget: params.contextTokenBudget,
2241+
force: true,
2242+
...(overflowTokenCount ? { currentTokenCount: overflowTokenCount } : {}),
2243+
compactionTarget: "threshold",
2244+
runtimeContext: overflowTokenCount
2245+
? {
2246+
...runtimeContext,
2247+
currentTokenCount: overflowTokenCount,
2248+
}
2249+
: runtimeContext,
2250+
},
2251+
resolveCompactionTimeoutMs(params.config),
2252+
runAbortController.signal,
2253+
);
22422254
embeddedAgentLog.info("codex app-server context-engine forced compaction result", {
22432255
sessionId: activeSessionId,
22442256
sessionKey: sandboxSessionKey,

0 commit comments

Comments
 (0)