fix: measure turn timeout as agent idle time, not total wall-clock - #41
Conversation
Closes #40. captureTurn raced the whole turn lifecycle against a single deadline armed before startRequest, so --turn-timeout-ms / CODEX_TURN_TIMEOUT_MS bounded total turn duration rather than agent inactivity. That's the wrong metric: gpt-5.6-sol at effort=xhigh on adversarial-review routinely does real, progressing work for well over 10 minutes. A fixed budget forces choosing between killing long-but-live reviews or tolerating a truly stalled app-server for just as long. Replace the one-shot deadline with a resettable idle timer, reset in applyTurnNotification on turn/started, item/started, and item/completed. A turn producing events can run indefinitely; only a gap of silence longer than the budget trips it. A separate, generous wall-clock ceiling (45 min) still backstops a turn that resets its own idle timer forever without completing. Test-first: added spaced-events-idle-ok (events spaced under the idle budget but summing past it — must not time out) and goes-silent-after-first-event (one event then silence — must time out via the idle gap) fixture behaviors, alongside the existing stalled-turn-start regression test. npm test: 176/179 pass (3 pre-existing state-dir/job-listing flakes, reproduce identically on fork/main). npm run build passes.
🔍 Local review (cycle 1)Reviewed locally (
Triage note on the FIX finding: verified against the generated app-server protocol types ( |
The idle deadline was only reset on turn/started, item/started, and item/completed — not on the streaming progress notifications a single long-running item emits while active (item/commandExecution/outputDelta, item/mcpToolCall/progress, item/agentMessage/delta, etc). A large command execution or MCP tool call that runs past the idle budget while continuously streaming output was interrupted mid-flight despite being genuinely active — exactly the failure mode this PR set out to eliminate. Found by Codex adversarial review during cycle-review round 1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e deadline Follow-up to 93f2aee, which extended the idle-deadline reset to in-item progress/delta notifications but left two doc comments listing only the turn/item lifecycle events. Comment-only; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQBJirbWNKWhnpFUnWogB8
🔍 Local review (cycle 2)Reviewed locally (
On the Codex findingThe finding is real and correctly diagnosed — I verified the whole chain in the code, and it is worse than a timeout question: the broker never issues It is, however, not a regression from this PR. Verified against the base commit Filed as #42 with the full chain, file:line references, and two concrete fix sites (broker interrupt-on-disconnect, and On the fix I tried and revertedI first attempted a guard in
An explicit Scope of this PR after cycle 2Unchanged from cycle 1 plus one comment-only commit ( |
--turn-timeout-ms / CODEX_TURN_TIMEOUT_MS change meaning in this release: they now bound agent idle time (silence between turn events) rather than total turn wall-clock. A behavioral change to a user-facing knob, so it gets its own version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQBJirbWNKWhnpFUnWogB8
This reverts commit 06d6d88.
🔍 Local review (cycle 1)Reviewed locally (
On the Codex finding vs. this PR's "Out of scope" sectionThe out-of-scope note argues every link in that chain predates the PR. Three of the four do, verified byte-identical against The fourth link is new. At base, So the mechanism is pre-existing, but the reachability is introduced here. Fix direction: keep an independent, non-resettable foreground ceiling below the host kill and interrupt before it; let idle-reset govern the background/detached path, where no external ceiling applies. On the two UNVERIFIED items
|
|
Review cycle stopped after cycle 1 by author decision. The two FIX findings, the resolution of both UNVERIFIED items (protocol research against codex-cli 0.146.0 binary + rust-v0.146.0 sources), and the two deferred SKIPs are tracked in follow-up #43 with concrete fix/test plans. |
Summary
Closes #40.
captureTurn's deadline (--turn-timeout-ms/CODEX_TURN_TIMEOUT_MS) currently bounds the entire turn, racing a singlesetTimeoutarmed beforestartRequest()against the whole lifecycle. That's the wrong metric:gpt-5.6-solateffort=xhighonadversarial-reviewroutinely does real, progressing work for well over 10 minutes — it isn't stalled, it's just slow at high effort on a large diff. A fixed wall-clock budget forces an impossible choice: set it low enough to catch a genuinely dead app-server quickly and it kills legitimately long-running reviews mid-flight; set it high enough to tolerate long reviews and a truly stalled app-server now hangs just as long before surfacing.Fix
Replace the fixed turn budget with an idle (inactivity) timeout:
captureTurn(plugins/codex/scripts/lib/codex.mjs): the one-shot deadlinesetTimeoutis replaced with a resettablearmIdleDeadlineclosure stored on the turn-capture state asstate.resetIdleDeadline.applyTurnNotification— the single choke point all turn notifications already flow through — callsstate.resetIdleDeadline()onturn/started,item/started, anditem/completed. A turn that keeps producing events can run indefinitely; only a gap of silence longer than the budget trips it.applyTurnNotificationalso resets the deadline on in-item progress/delta notifications (item/commandExecution/outputDelta,item/fileChange/outputDelta,item/mcpToolCall/progress,item/agentMessage/delta,item/plan/delta, the reasoning deltas,command/exec/outputDelta,process/outputDelta). Without this a single long-running item — a big command execution or MCP tool call streaming output for minutes — emits no item boundary in between and is killed mid-flight despite being demonstrably alive.HARD_WALL_CLOCK_CEILING_MS, 45 min) is kept as a second failsafe — a backstop against a turn that resets its own idle timer forever without ever completing (e.g. a runaway tool-call loop), independent of the tunable idle budget.--turn-timeout-msupdated: the meaning changes from "budget for the whole turn" to "how long the turn may go silent between events before it's considered dead."This preserves the actual fix from upstream openai#376 (the completion
awaitbeing reject-able at all —rejectCompletionwas dead code — plus resolving the budget at call time instead of import time). Both are orthogonal to what the timer measures and are untouched.Relation to other issues
structuredRetryPromptretry loop — up tostructuredRetryAttempts(default 3) additionalcaptureTurncalls when the captured message isn't valid JSON. Under the old wall-clock design, each retry gets its own fresh full-turn budget, so a worst case of 1 initial + 3 retries could silently consume up to 4x the caller's configured timeout before failing. Under this idle-timeout design that multiplication doesn't happen: eachcaptureTurninvocation is bounded by its own idle-silence window, not an aggregate clock, so a retry loop's total wall-clock time is bounded by (attempts × real work time) rather than (attempts × worst-case budget).Test plan
tests/fake-codex-fixture.mjs—spaced-events-idle-ok(6 events spaced 700ms apart, ~4.2s total, each gap short),goes-silent-after-first-event(one event, then permanent silence), andsingle-item-progress-idle-ok(oneitem/started, then 6item/commandExecution/outputDelta700ms apart, then completion) — modeled on the existingstalled-turn-startbehavior from fork PR fix: turn-timeout gaps — interrupt, early deadline, options threading (closes #27) #28.tests/runtime.test.mjs, four assertions, each confirmed red-before / green-after:task with spaced-out events longer than the budget does NOT time out (idle, not wall-clock)— red against the pre-fix code (failed at ~2.1s total despite gaps well under the 2s budget).task that goes silent after the first event times out via the idle budget, measured from the last event— still fails via the idle budget, close to it, not hanging.task with in-item output-delta progress longer than the budget does NOT time out (single long item)— red against the round-1 code withcodex turn exceeded the 2000ms turn budget.while the item was still streaming.task with stalled turn/start times out via --turn-timeout-ms instead of hanging forever(pre-existing, from PR fix: turn-timeout gaps — interrupt, early deadline, options threading (closes #27) #28) — still passes; the app-server-alive-but-not-responding case (Finding fix: turn-timeout gaps — no interrupt on deadline, stalled turn/start uncovered, background ignores --turn-timeout-ms #27-2) remains bounded.npm test: 95 tests, 92 pass, 3 fail. The 3 failures are pre-existingstatus/resultjob-listing flakes — reproduce identically on unmodifiedfork/main(verified by stashing this branch's changes and re-running).npm run build: passes (tsc -p tsconfig.app-server.json), no new TS errors.npm run check-version: all version metadata is consistent.Version
No version bump in this PR — the manifests stay at
1.0.6-fork.6. Suggestion for whoever cuts the next release: this is a behavioral change to a user-facing knob (--turn-timeout-ms/CODEX_TURN_TIMEOUT_MSstop bounding total turn wall-clock and start bounding agent idle time), so it likely deserves its own version rather than riding along unversioned.Out of scope
Codex's adversarial review raised that a continuously-active foreground turn can now outlive Claude Code's Bash-tool SIGKILL ceiling (~120s), orphaning a live app-server turn that
/codex:cancelcannot reach. The finding is real, but every link in that chain predates this PR — verified byte-identical against the base commit15f22e6: the broker never issuesturn/interrupton socket close (broker-controller.mjs:353-370),/cancelfiltersqueued|runningafter reconciliation flips the job tofailed(job-control.mjs:326-329,state.mjs:151-196), andadversarial-reviewalready runs foreground with the 600000ms default because it dropsturnTimeoutMs(codex-companion.mjs:465, tracked as #38 / #39). Filed as #42 with the full chain and two concrete fix sites; deliberately not folded in here.🤖 Generated with Claude Code