Skip to content

Commit 414ecd2

Browse files
100yenadminSedrak-HovhannisyanEvafuller-stack-dev
authored
Preserve Codex output after missing turn completion (#99217)
* Preserve Codex output after missing turn completion * fix: narrow Codex completion-timeout output recovery * test(codex): narrow binding path guard * fix: narrow Codex completion-timeout output recovery * test(codex): narrow binding path guard * fix(codex): preserve id-less post-tool replies --------- Co-authored-by: Sedrak-Hovhannisyan <[email protected]> Co-authored-by: Eva <[email protected]> Co-authored-by: Jason (Json) <[email protected]>
1 parent 4abdf0f commit 414ecd2

56 files changed

Lines changed: 4386 additions & 346 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/plugins/codex-harness.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -662,9 +662,16 @@ guard instead of releasing the session lane immediately. Only
662662
final/non-commentary completed `agentMessage` items and pre-tool raw
663663
assistant completions arm the assistant-output release: if Codex then goes quiet
664664
without `turn/completed`, OpenClaw best-effort interrupts the native turn and
665-
releases the session lane. Replay-safe stdio app-server failures, including
666-
turn-completion idle timeouts without assistant, tool, active-item, or
667-
side-effect evidence, are retried once on a fresh app-server attempt. Unsafe
665+
releases the session lane. If another turn watch wins that release race,
666+
OpenClaw still accepts the completed final assistant item once no native
667+
request, item, or dynamic tool completion remains active and the
668+
assistant-output release still belongs to the latest completed item, with no
669+
later item completion. This can preserve the final answer after completed tool
670+
work without replaying the turn. Partial assistant deltas, stale earlier
671+
replies, and empty later completions do not qualify. Replay-safe stdio
672+
app-server failures,
673+
including turn-completion idle timeouts without assistant, tool, active-item,
674+
or side-effect evidence, are retried once on a fresh app-server attempt. Unsafe
668675
timeouts still retire the stuck app-server client and release the OpenClaw
669676
session lane. They also clear the stale native thread binding instead of being
670677
replayed automatically. Completion-watch timeouts surface Codex-specific timeout

extensions/codex/src/app-server/attempt-turn-watches.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ describe("Codex app-server attempt turn watches", () => {
2525
let activeRequests = 0;
2626
let activeItems = 0;
2727
let activeCompletionBlockers = 0;
28+
let activeFinalizationHooks = 0;
29+
let canReleaseAssistantCompletionIdle = true;
2830
const interrupts: Array<Record<string, unknown>> = [];
2931
const timeouts: Array<Record<string, unknown>> = [];
3032
const events: Array<{ name: string; fields: Record<string, unknown> }> = [];
@@ -39,6 +41,8 @@ describe("Codex app-server attempt turn watches", () => {
3941
getActiveAppServerTurnRequests: () => activeRequests,
4042
getActiveTurnItemCount: () => activeItems,
4143
getActiveCompletionBlockerItemCount: () => activeCompletionBlockers,
44+
getActiveFinalizationHookCount: () => activeFinalizationHooks,
45+
canReleaseAssistantCompletionIdle: () => canReleaseAssistantCompletionIdle,
4246
turnCompletionIdleTimeoutMs: 10,
4347
turnAssistantCompletionIdleTimeoutMs: 10,
4448
turnAttemptIdleTimeoutMs: 10,
@@ -75,6 +79,12 @@ describe("Codex app-server attempt turn watches", () => {
7579
set activeCompletionBlockers(value: number) {
7680
activeCompletionBlockers = value;
7781
},
82+
set activeFinalizationHooks(value: number) {
83+
activeFinalizationHooks = value;
84+
},
85+
set canReleaseAssistantCompletionIdle(value: boolean) {
86+
canReleaseAssistantCompletionIdle = value;
87+
},
7888
interrupts,
7989
timeouts,
8090
events,
@@ -198,6 +208,19 @@ describe("Codex app-server attempt turn watches", () => {
198208
expect(harness.events[0]?.name).toBe("turn.assistant_completion_idle_release");
199209
});
200210

211+
it("does not release when a later completed item supersedes the assistant", () => {
212+
const harness = createController();
213+
214+
harness.controller.armAssistantCompletionIdleWatch({ method: "item/completed" });
215+
harness.canReleaseAssistantCompletionIdle = false;
216+
vi.advanceTimersByTime(10);
217+
218+
expect(harness.completed).toBe(false);
219+
expect(harness.controller.isAssistantCompletionIdleWatchArmed()).toBe(false);
220+
expect(harness.interrupts).toEqual([]);
221+
expect(harness.events).toEqual([]);
222+
});
223+
201224
it("waits for active turn items before assistant idle release", () => {
202225
const harness = createController();
203226
harness.activeItems = 1;
@@ -212,6 +235,23 @@ describe("Codex app-server attempt turn watches", () => {
212235
expect(harness.completed).toBe(true);
213236
});
214237

238+
it("waits for active finalization hooks before assistant idle release", () => {
239+
const harness = createController();
240+
241+
harness.controller.armAssistantCompletionIdleWatch();
242+
harness.activeFinalizationHooks = 1;
243+
vi.advanceTimersByTime(10);
244+
245+
expect(harness.completed).toBe(false);
246+
expect(harness.interrupts).toEqual([]);
247+
248+
harness.activeFinalizationHooks = 0;
249+
harness.controller.armAssistantCompletionIdleWatch();
250+
vi.advanceTimersByTime(10);
251+
252+
expect(harness.completed).toBe(true);
253+
});
254+
215255
it("records attempt progress activity separately from completion-only activity", () => {
216256
const harness = createController();
217257

extensions/codex/src/app-server/attempt-turn-watches.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export function createCodexAttemptTurnWatchController(params: {
3737
getActiveAppServerTurnRequests: () => number;
3838
getActiveTurnItemCount: () => number;
3939
getActiveCompletionBlockerItemCount: () => number;
40+
getActiveFinalizationHookCount: () => number;
41+
canReleaseAssistantCompletionIdle: () => boolean;
4042
turnCompletionIdleTimeoutMs: number;
4143
turnAssistantCompletionIdleTimeoutMs: number;
4244
turnAttemptIdleTimeoutMs: number;
@@ -136,7 +138,12 @@ export function createCodexAttemptTurnWatchController(params: {
136138

137139
function scheduleAssistantCompletionIdleWatch() {
138140
clearAssistantCompletionIdleTimer();
139-
if (params.isCompleted() || params.signal.aborted || !assistantCompletionIdleWatchArmed) {
141+
if (
142+
params.isCompleted() ||
143+
params.signal.aborted ||
144+
!assistantCompletionIdleWatchArmed ||
145+
params.getActiveFinalizationHookCount() > 0
146+
) {
140147
return;
141148
}
142149
const elapsedMs = Math.max(0, Date.now() - assistantCompletionLastActivityAt);
@@ -216,10 +223,20 @@ export function createCodexAttemptTurnWatchController(params: {
216223
if (params.isCompleted() || params.signal.aborted || !assistantCompletionIdleWatchArmed) {
217224
return;
218225
}
219-
if (params.getActiveAppServerTurnRequests() > 0 || params.getActiveTurnItemCount() > 0) {
226+
if (
227+
params.getActiveAppServerTurnRequests() > 0 ||
228+
params.getActiveTurnItemCount() > 0 ||
229+
params.getActiveFinalizationHookCount() > 0
230+
) {
220231
scheduleAssistantCompletionIdleWatch();
221232
return;
222233
}
234+
if (!params.canReleaseAssistantCompletionIdle()) {
235+
assistantCompletionIdleWatchArmed = false;
236+
assistantCompletionLastActivityDetails = undefined;
237+
clearAssistantCompletionIdleTimer();
238+
return;
239+
}
223240
const idleMs = Math.max(0, Date.now() - assistantCompletionLastActivityAt);
224241
if (idleMs < turnAssistantCompletionIdleTimeoutMs) {
225242
scheduleAssistantCompletionIdleWatch();

0 commit comments

Comments
 (0)