Skip to content

Commit 54e6e3d

Browse files
committed
fix(codex): time out silent app-server turns
1 parent 126dcb0 commit 54e6e3d

4 files changed

Lines changed: 110 additions & 0 deletions

File tree

docs/concepts/agent-loop.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ wired end-to-end.
3232
- resolves model + auth profile and builds the pi session
3333
- subscribes to pi events and streams assistant/tool deltas
3434
- enforces timeout -> aborts run if exceeded
35+
- for Codex app-server turns, aborts an accepted turn that stops producing app-server progress before a terminal event
3536
- returns payloads + usage metadata
3637
4. `subscribeEmbeddedPiSession` bridges pi-agent-core events to OpenClaw `agent` stream:
3738
- tool events => `stream: "tool"`

docs/concepts/queue.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ keys.
114114

115115
- If commands seem stuck, enable verbose logs and look for “queued for …ms” lines to confirm the queue is draining.
116116
- If you need queue depth, enable verbose logs and watch for queue timing lines.
117+
- Codex app-server runs that accept a turn and then stop emitting progress are interrupted by the Codex adapter so the active session lane can release instead of waiting for the outer run timeout.
117118
- When diagnostics are enabled, sessions that remain in `processing` past `diagnostics.stuckSessionWarnMs` log a stuck-session warning. Active embedded runs, active reply operations, and active lane tasks remain warning-only by default; stale startup bookkeeping with no active session work can release the affected session lane so queued work drains.
118119

119120
## Related

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,33 @@ describe("runCodexAppServerAttempt", () => {
443443
expect(queueAgentHarnessMessage("session-1", "after timeout")).toBe(false);
444444
});
445445

446+
it("releases the session when Codex accepts a turn but never sends progress", async () => {
447+
const harness = createStartedThreadHarness();
448+
const params = createParams(
449+
path.join(tempDir, "session.jsonl"),
450+
path.join(tempDir, "workspace"),
451+
);
452+
params.timeoutMs = 60_000;
453+
454+
const run = runCodexAppServerAttempt(params, { turnTerminalIdleTimeoutMs: 5 });
455+
await harness.waitForMethod("turn/start");
456+
457+
await expect(run).resolves.toMatchObject({
458+
aborted: true,
459+
timedOut: true,
460+
promptError: "codex app-server turn idle timed out waiting for turn/completed",
461+
});
462+
await vi.waitFor(
463+
() =>
464+
expect(harness.request).toHaveBeenCalledWith("turn/interrupt", {
465+
threadId: "thread-1",
466+
turnId: "turn-1",
467+
}),
468+
{ interval: 1 },
469+
);
470+
expect(queueAgentHarnessMessage("session-1", "after silent turn")).toBe(false);
471+
});
472+
446473
it("applies before_prompt_build to Codex developer instructions and turn input", async () => {
447474
const beforePromptBuild = vi.fn(async () => ({
448475
systemPrompt: "custom codex system",

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import { filterToolsForVisionInputs } from "./vision-tools.js";
8787

8888
const CODEX_DYNAMIC_TOOL_TIMEOUT_MS = 30_000;
8989
const CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS = 60_000;
90+
const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
9091
const CODEX_STEER_ALL_DEBOUNCE_MS = 500;
9192

9293
type OpenClawCodingToolsOptions = NonNullable<
@@ -226,6 +227,7 @@ export async function runCodexAppServerAttempt(
226227
hookTimeoutSec?: number;
227228
};
228229
turnCompletionIdleTimeoutMs?: number;
230+
turnTerminalIdleTimeoutMs?: number;
229231
} = {},
230232
): Promise<EmbeddedRunAttemptResult> {
231233
const attemptStartedAt = Date.now();
@@ -471,8 +473,13 @@ export async function runCodexAppServerAttempt(
471473
const turnCompletionIdleTimeoutMs = resolveCodexTurnCompletionIdleTimeoutMs(
472474
options.turnCompletionIdleTimeoutMs,
473475
);
476+
const turnTerminalIdleTimeoutMs = resolveCodexTurnTerminalIdleTimeoutMs(
477+
options.turnTerminalIdleTimeoutMs,
478+
);
474479
let turnCompletionIdleTimer: ReturnType<typeof setTimeout> | undefined;
475480
let turnCompletionIdleWatchArmed = false;
481+
let turnTerminalIdleTimer: ReturnType<typeof setTimeout> | undefined;
482+
let turnTerminalIdleWatchArmed = false;
476483
let turnCompletionLastActivityAt = Date.now();
477484
let turnCompletionLastActivityReason = "startup";
478485
let activeAppServerTurnRequests = 0;
@@ -484,6 +491,13 @@ export async function runCodexAppServerAttempt(
484491
}
485492
};
486493

494+
const clearTurnTerminalIdleTimer = () => {
495+
if (turnTerminalIdleTimer) {
496+
clearTimeout(turnTerminalIdleTimer);
497+
turnTerminalIdleTimer = undefined;
498+
}
499+
};
500+
487501
const fireTurnCompletionIdleTimeout = () => {
488502
if (
489503
completed ||
@@ -520,6 +534,42 @@ export async function runCodexAppServerAttempt(
520534
runAbortController.abort("turn_completion_idle_timeout");
521535
};
522536

537+
const fireTurnTerminalIdleTimeout = () => {
538+
if (
539+
completed ||
540+
runAbortController.signal.aborted ||
541+
!turnTerminalIdleWatchArmed ||
542+
activeAppServerTurnRequests > 0
543+
) {
544+
return;
545+
}
546+
const idleMs = Math.max(0, Date.now() - turnCompletionLastActivityAt);
547+
if (idleMs < turnTerminalIdleTimeoutMs) {
548+
scheduleTurnTerminalIdleWatch();
549+
return;
550+
}
551+
timedOut = true;
552+
turnCompletionIdleTimedOut = true;
553+
turnCompletionIdleTimeoutMessage =
554+
"codex app-server turn idle timed out waiting for turn/completed";
555+
projector?.markTimedOut();
556+
trajectoryRecorder?.recordEvent("turn.terminal_idle_timeout", {
557+
threadId: thread.threadId,
558+
turnId,
559+
idleMs,
560+
timeoutMs: turnTerminalIdleTimeoutMs,
561+
lastActivityReason: turnCompletionLastActivityReason,
562+
});
563+
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for terminal event", {
564+
threadId: thread.threadId,
565+
turnId,
566+
idleMs,
567+
timeoutMs: turnTerminalIdleTimeoutMs,
568+
lastActivityReason: turnCompletionLastActivityReason,
569+
});
570+
runAbortController.abort("turn_terminal_idle_timeout");
571+
};
572+
523573
function scheduleTurnCompletionIdleWatch() {
524574
clearTurnCompletionIdleTimer();
525575
if (
@@ -536,13 +586,30 @@ export async function runCodexAppServerAttempt(
536586
turnCompletionIdleTimer.unref?.();
537587
}
538588

589+
function scheduleTurnTerminalIdleWatch() {
590+
clearTurnTerminalIdleTimer();
591+
if (
592+
completed ||
593+
runAbortController.signal.aborted ||
594+
!turnTerminalIdleWatchArmed ||
595+
activeAppServerTurnRequests > 0
596+
) {
597+
return;
598+
}
599+
const elapsedMs = Math.max(0, Date.now() - turnCompletionLastActivityAt);
600+
const delayMs = Math.max(1, turnTerminalIdleTimeoutMs - elapsedMs);
601+
turnTerminalIdleTimer = setTimeout(fireTurnTerminalIdleTimeout, delayMs);
602+
turnTerminalIdleTimer.unref?.();
603+
}
604+
539605
const touchTurnCompletionActivity = (reason: string, options?: { arm?: boolean }) => {
540606
turnCompletionLastActivityAt = Date.now();
541607
turnCompletionLastActivityReason = reason;
542608
if (options?.arm) {
543609
turnCompletionIdleWatchArmed = true;
544610
}
545611
scheduleTurnCompletionIdleWatch();
612+
scheduleTurnTerminalIdleWatch();
546613
};
547614

548615
const emitLifecycleStart = () => {
@@ -595,6 +662,7 @@ export async function runCodexAppServerAttempt(
595662
}
596663
completed = true;
597664
clearTurnCompletionIdleTimer();
665+
clearTurnTerminalIdleTimer();
598666
resolveCompletion?.();
599667
}
600668
}
@@ -839,6 +907,7 @@ export async function runCodexAppServerAttempt(
839907
abort: () => runAbortController.abort("aborted"),
840908
};
841909
setActiveEmbeddedRun(params.sessionId, handle, params.sessionKey);
910+
turnTerminalIdleWatchArmed = true;
842911
touchTurnCompletionActivity("turn:start");
843912

844913
const timeout = setTimeout(
@@ -1005,6 +1074,7 @@ export async function runCodexAppServerAttempt(
10051074
userInputBridge?.cancelPending();
10061075
clearTimeout(timeout);
10071076
clearTurnCompletionIdleTimer();
1077+
clearTurnTerminalIdleTimer();
10081078
notificationCleanup();
10091079
requestCleanup();
10101080
nativeHookRelay?.unregister();
@@ -1305,6 +1375,16 @@ function resolveCodexTurnCompletionIdleTimeoutMs(value: number | undefined): num
13051375
return Math.max(1, Math.floor(value));
13061376
}
13071377

1378+
function resolveCodexTurnTerminalIdleTimeoutMs(value: number | undefined): number {
1379+
if (value === undefined) {
1380+
return CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS;
1381+
}
1382+
if (!Number.isFinite(value)) {
1383+
return CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS;
1384+
}
1385+
return Math.max(1, Math.floor(value));
1386+
}
1387+
13081388
function readDynamicToolCallParams(
13091389
value: JsonValue | undefined,
13101390
): CodexDynamicToolCallParams | undefined {
@@ -1417,6 +1497,7 @@ function handleApprovalRequest(params: {
14171497
export const __testing = {
14181498
CODEX_DYNAMIC_TOOL_TIMEOUT_MS,
14191499
CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS,
1500+
CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS,
14201501
buildCodexNativeHookRelayId,
14211502
filterToolsForVisionInputs,
14221503
handleDynamicToolCallWithTimeout,

0 commit comments

Comments
 (0)