Skip to content

Turn timeout should measure agent-idle time, not total turn wall-clock #40

Description

@axisrow

Problem

--turn-timeout-ms / CODEX_TURN_TIMEOUT_MS currently bound the entire turn, not agent inactivity. In captureTurn (plugins/codex/scripts/lib/codex.mjs ~617-650), a single setTimeout is armed before startRequest() and races the whole turn lifecycle — it never resets while the turn is actively producing item/started/item/completed notifications.

This is the wrong metric. gpt-5.6-sol at effort=xhigh on adversarial-review routinely does real, visibly-progressing work for well over 10 minutes — it is not stalled, it's just slow at high effort on a large diff. A fixed wall-clock budget forces an impossible choice:

Design: idle-timeout instead of wall-clock budget

Replace the fixed turn budget with an inactivity (idle) timeout: the deadline resets on every turn-lifecycle event (turn/started, item/started, item/completed), so a turn that's continuously producing output is never killed no matter how long it runs in total — only a period of silence (no events) longer than the budget triggers the timeout.

  1. captureTurn (~617): replace the one-shot setTimeout-backed deadline promise with a resettable deadline stored on the turn-capture state (arm/reset/clear helpers), instead of a single race set up before startRequest().
  2. applyTurnNotification (~548) is the single choke point all turn notifications already flow through — reset the idle deadline there on turn/started, item/started, item/completed.
  3. Keep a coarse wall-clock ceiling as a second, generous failsafe (~30-60 min) — a backstop against a turn that resets the idle timer forever without ever actually finishing (e.g. a runaway tool-call loop), distinct from the idle budget.
  4. Update --turn-timeout-ms help text / comments: the meaning changes from "budget for the whole turn" to "how long the turn may go silent between events before it's considered dead."

Why this doesn't regress the openai#376 fix

openai#376's actual fix was making the completion await reject-able at all (dead code rejectCompletion was never wired) plus resolving the budget at call time instead of import time. Both of those are orthogonal to what the timer measures and are preserved as-is — this issue only changes the deadline from "single shot at turn start" to "resettable, keyed off actual turn activity."

Interaction with upstream openai#407 (structured-retry loop)

Upstream openai#407 (open PR, not yet in this fork) adds an opt-in structuredRetryPrompt retry loop to runAppServerTurn: if the captured message isn't valid JSON, it resumes the same thread with a JSON-only prompt, up to structuredRetryAttempts (default 3) additional captureTurn calls on top of the original.

Under the current wall-clock design, if that retry loop is ever ported here, each retry attempt gets its own fresh DEFAULT_TURN_TIMEOUT_MS/--turn-timeout-ms budget — so a worst case of 1 initial + 3 retries could consume up to 4x the configured turn budget before finally failing, silently multiplying the caller's stated timeout.

Under an idle-timeout design this doesn't happen: each captureTurn invocation (initial or retry) is bounded by its own idle-silence window, not by an aggregate clock, so there's nothing to multiply — the retry loop's total wall-clock time is bounded by (attempts × real work time), which is exactly what the caller wants, not (attempts × worst-case-budget).

Scope

This is a separate concern from #38 (turnTimeoutMs not forwarded to the adversarial-review call site at all — fixed in #39). This issue is about what the timeout measures, once it is forwarded everywhere.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions