Skip to content

Bound a remote-control turn by silence, not by a 30-minute stopwatch - #171

Merged
imaustink merged 2 commits into
mainfrom
fix-agent-timeout-bug
Jul 28, 2026
Merged

Bound a remote-control turn by silence, not by a 30-minute stopwatch#171
imaustink merged 2 commits into
mainfrom
fix-agent-timeout-bug

Conversation

@imaustink

Copy link
Copy Markdown
Owner

Fixes the agent timeouts reported on #149.

Root cause

runClaudeTurnRemoteControlled was the only bound in the system that measured duration rather than silence, and it was the shortest by two orders of magnitude:

Layer Bound
Gateway poll budget 8h5m
Job activeDeadlineSeconds 8h
Orchestrator awaitReply 10 min of silence, reset by any up-message
Remote-control runner 30 min WALL CLOCK ← the outlier

The one-shot claude -p path has no wall-clock cap at all; it waits for the child. A feature-sized coding task sits comfortably past 30 minutes, so the cap turned finished work into a reported failure:

Something went wrong processing this: agent failed (agent_error): The coding agent reported an error: Timed out after 1800000ms waiting for the remote-control session to finish

This exact bug class was already fixed one layer up — values-production.yaml raised pollTimeoutMs from 15 min to 8h5m with the note "a bound shorter than the work it's bounding turns a success into a visible failure." The runner was the last layer that hadn't gotten the memo.

The fix

An idle bound over the session's own JSONL transcript, which gains an entry for every assistant message, tool call, and tool result: growth is proof of life, and a static transcript is the only honest definition of stuck. Deliberately not measured from the pty output, which a TUI redraws continuously whether or not anything is happening — treating that as activity would make the bound unable to ever fire.

There is no absolute cap by default any more; the Job's activeDeadlineSeconds is the ceiling, as it already claims to be. maxWaitMs survives as an opt-in escape hatch, and both bounds are env/Helm tunable so a wrong default costs a values change rather than a rebuild.

Three defects fall out of the same rewrite:

  • The heartbeat was lying. It fired every 20s whether or not anything was happening, so a genuinely wedged session looked alive for the full 30 minutes. It now narrates each new transcript entry as it appears (the tool-call trail the -p path already emits), and only says "still running" during real silence, naming how long the silence has lasted.
  • Giving up now says which way it failed. "Timed out after 1800000ms" could not distinguish a session that worked and then wedged from one whose prompt never landed — and not being able to tell those apart is most of why this issue survived four attempts. Startup also gets its own, much shorter bound: before a session registers there is no transcript to measure silence against, and a CLI wedged on an unexpected first-run prompt never exits, so the existing childExited check never fired for it.
  • agents --json --all lists ended sessions too. On a second turn in the same pod the previous turn's session is still listed at the same cwd — and its transcript already carries a turn_duration. Matching it would hand the previous turn's answer back as this turn's, instantly and silently. Pre-existing sessions are now snapshotted before we spawn ours, and skipped.

Tests

39 pass in apps/claude-code-swe-agent (12 in the remote-control file, 6 new), all against real subprocesses and a real transcript on disk. New coverage:

  • a turn that runs many times longer than the idle window while working is not killed (no maxWaitMs is passed, because there is no absolute cap)
  • a worked-then-wedged session is reported as a stall naming the silence
  • a registered-but-never-started session is reported distinctly
  • a never-registering session gives up on the startup bound, not the idle one
  • an explicit maxWaitMs still caps
  • a prior turn's session is not mistaken for this one's

The fakes grew a sentinel so a session only appears in claude agents once its process has started — what a real one does, and what the snapshot relies on.

Repo-wide, this adds zero new failures: the 27 typecheck errors and 6 failing test files in my checkout are identical on clean main (stale workspace dist).

Notes for review

  • No e2e, deliberately. e2e/README.md stubs the Claude Code CLI with stub-agent by design ("a real agent run needs a real paid credential"), so the suite structurally cannot execute this path. resilience.e2e.ts already proves the orchestrator bounds silence not duration; these unit tests prove the same for the runner at the only level that can actually run it. The new Helm branches are wired into values-ci-all.yaml so validate-crds renders them.
  • Unrelated footgun, left alone: the chart default for pollTimeoutMs is still 15 min, overridden to 8h5m only in values-production. Same bug class for any non-production install. Happy to fix in a follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyKcQdvju68R3edo4GXD3T

imaustink and others added 2 commits July 27, 2026 20:04
An agent turn on issue #149 ran correctly for half an hour and was then
killed and reported to the user as "Timed out after 1800000ms waiting for
the remote-control session to finish". The run had never once been idle.

`runClaudeTurnRemoteControlled` was the only bound in the system that
measured DURATION rather than SILENCE, and it was the shortest one by two
orders of magnitude:

  - gateway poll budget       8h5m   (values-production, fixed for this
                                      exact bug class: "a bound shorter
                                      than the work it's bounding turns a
                                      success into a visible failure")
  - Job activeDeadlineSeconds 8h     ("a real coding task ... can
                                      legitimately take hours")
  - orchestrator awaitReply   10min  of silence, reset by any up-message
  - remote-control runner     30min  WALL CLOCK  <-- the outlier

The one-shot `claude -p` path has no wall-clock cap at all; it waits for
the child. A feature-sized coding task sits comfortably past 30 minutes,
so the cap turned finished work into a reported failure.

Replace it with an idle bound over the session's own transcript, which
gains an entry for every assistant message, tool call, and tool result:
growth is proof of life, and a static transcript is the only honest
definition of stuck. Deliberately not measured from the pty output, which
a TUI redraws continuously whether or not anything is happening. There is
no absolute cap by default any more -- the Job's activeDeadlineSeconds is
the ceiling, as it already claims to be. `maxWaitMs` survives as an opt-in
escape hatch, and both bounds are env/Helm tunable so a wrong default
costs a values change rather than a rebuild.

Three defects fall out of the same rewrite:

  - The heartbeat was a content-free ticker that fired every 20s whether
    or not anything was happening, so a genuinely wedged session looked
    alive for the full 30 minutes. It now narrates each new transcript
    entry as it appears (the tool-call trail the `-p` path already
    emits), and only says "still running" during real silence, naming
    how long the silence has lasted.

  - Giving up now says WHICH way it failed. "Timed out after 1800000ms"
    could not distinguish a session that worked and then wedged from one
    whose prompt never landed -- and not being able to tell those apart
    is most of why this issue survived four attempts at it. Startup gets
    its own, much shorter bound, because before a session registers there
    is no transcript to measure silence against and a CLI wedged on an
    unexpected first-run prompt never exits.

  - `agents --json --all` lists ended sessions too, so on a second turn
    in the same pod the previous turn's session is still listed at the
    same cwd -- and its transcript already carries a `turn_duration`.
    Matching it would hand the previous turn's answer back as this
    turn's, instantly and silently. The pre-existing sessions are now
    snapshotted before we spawn ours, and skipped.

The fakes in claude-runner-remote-control.test.ts grew a sentinel so a
session only appears in `claude agents` once its process has started,
which is what a real one does and what the snapshot above relies on.
New coverage, all against real subprocesses and a real transcript on
disk: a turn that runs many times longer than the idle window while
working is not killed (and no `maxWaitMs` is passed, because there is no
absolute cap); a worked-then-wedged session is reported as a stall naming
the silence; a registered-but-never-started session is reported
distinctly; a never-registering session gives up on the startup bound
rather than the idle one; an explicit `maxWaitMs` still caps; and a prior
turn's session is not mistaken for this one's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyKcQdvju68R3edo4GXD3T
…om silence

The idle bound in the previous commit could not tell a wedged session from a
slow one: a single long tool call writes `tool_use` when it STARTS and
`tool_result` when it FINISHES and nothing in between, so a 40-minute test
suite is 40 minutes of a completely static transcript belonging to a session
that is plainly alive. 20 minutes was a guess sitting between those two cases.

`claude agents --json` already answers the question directly. Confirmed
against a real CLI (v2.1.220), live and by reading the bundle, rather than
assumed -- the listing builds each interactive entry with

    ...d.status && {status: EMm(d.status)},
    ...d.status === "waiting" && d.waitingFor && {waitingFor: d.waitingFor}

where EMm is total:

    EMm(e) = e === "idle" ? "idle" : e === "waiting" ? "waiting" : "busy"

so the field is exactly those three values or ABSENT, and an unrecognized
internal status degrades to "busy" -- the safe direction. The statuses are
computed as: `waiting` when a prompt or dialog is up (with `waitingFor` naming
it: "input needed", "dialog open", "sandbox request", "worker request", or the
dialog's own label), `busy` when `isLoading || delegatedActive`, `idle`
otherwise. Sampled live every 3s across a 36s Bash call, the session reported
`busy` for all twelve samples.

So each state now gets the bound its evidence deserves:

  busy    -> none. It is working; duration is not our business. The Job's
             activeDeadlineSeconds is the ceiling, as it already claims.
  waiting -> 5 min. Nothing in a headless Job will ever answer the prompt, so
             this could be zero; it is not, because the run's own first comment
             says "take over the session here" and a human who does needs a
             window. Bounded anyway -- an unattended run at 3am must not hold a
             pod until the Job deadline waiting for someone who is asleep.
  idle    -> 90s. Not silence to wait out: the session is saying it is not
             working. The grace exists only because a session is briefly idle
             between registering and picking up its prompt, and any `busy`
             sample resets it, so a blip costs nothing.
  absent  -> the previous commit's 20-minute transcript-silence bound, now
             demoted to the fallback it should always have been.

Giving up now names which signal ended the wait ("reported itself idle for
Ns" vs "produced no transcript activity for Ns (status not reported)"), so a
future report says whether the session was asked or inferred about.

Splitting the clocks matters more than it looks. `busy` resets the stall clock
on every poll, so a heartbeat sharing that clock would go silent for exactly
the case that most needs narrating -- and the orchestrator ends a turn after
10 minutes without an up-message, which would have killed the long turn from
the other side while this code sat happily waiting. The heartbeat now runs off
"when did we last say anything", and reports the status it is reporting about.

New coverage: a `busy` session with a permanently static transcript survives
both silence bounds set to 50ms (only an explicit absolute cap ends the test)
and keeps heartbeating `[busy]` throughout; a `waiting` session fails fast
naming what it is blocked on; a listing with no status falls back to silence
and says so. The long-turn regression test from the previous commit now pins
both silence bounds, since leaving the status grace at its default let it pass
without testing anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyKcQdvju68R3edo4GXD3T
@imaustink

Copy link
Copy Markdown
Owner Author

Update: the wait is now driven by the session's reported status, not by a timer

The 20-minute idle bound in the first commit had a real weakness, and it's worth naming: it could not tell a wedged session from a slow one. A single long tool call writes tool_use when it starts and tool_result when it finishes and nothing in between, so a 40-minute test suite is 40 minutes of a completely static transcript belonging to a session that is plainly alive. 20 minutes was a guess sitting between those two cases.

claude agents --json already answers the question directly. Confirmed against a real CLI (v2.1.220) — live, and by reading the bundle — rather than assumed. The listing builds each interactive entry with:

...d.status && {status: EMm(d.status)},
...d.status === "waiting" && d.waitingFor && {waitingFor: d.waitingFor}

where EMm is total:

EMm(e) = e === "idle" ? "idle" : e === "waiting" ? "waiting" : "busy"

So the field is exactly those three values or absent, and an unrecognized internal status degrades to "busy" — the safe direction for us. The statuses are computed as waiting when a prompt or dialog is up (with waitingFor naming it: "input needed", "dialog open", "sandbox request", "worker request", or the dialog's own label), busy when isLoading || delegatedActive, and idle otherwise. Sampled live every 3s across a 36s Bash call, the session reported busy for all twelve samples.

Each state now gets the bound its evidence deserves:

Reported Bound Why
busy none It is working; duration is not our business. The Job's activeDeadlineSeconds is the ceiling, as it already claims to be.
waiting 5 min Nothing in a headless Job will ever answer the prompt, so this could be zero. It isn't, because the run's own first comment says "take over the session here" and a human who does needs a window. Bounded anyway — an unattended run at 3am must not hold a pod until the Job deadline waiting for someone who is asleep.
idle 90s Not silence to wait out: the session is saying it is not working. The grace exists only because a session is briefly idle between registering and picking up its prompt, and any busy sample resets it, so a blip costs nothing.
absent 20 min of transcript silence The first commit's bound, demoted to the fallback it should always have been.

Giving up now names which signal ended the wait — "reported itself idle for Ns" vs "produced no transcript activity for Ns (status not reported)" — so a future report says whether the session was asked or inferred about.

One trap worth flagging for review

Splitting the clocks matters more than it looks. busy resets the stall clock on every poll, so a heartbeat sharing that clock would go silent for exactly the case that most needs narrating — and the orchestrator ends a turn after 10 minutes without an up-message, which would have killed the long turn from the other side while this code sat happily waiting. The heartbeat now runs off "when did we last say anything," and reports the status it's reporting about. There's a test pinning this (beats.length >= 2 with [busy] in the text) because it's the kind of thing that silently regresses.

Coverage

42 pass in the package (15 in the remote-control file). New: a busy session with a permanently static transcript survives both silence bounds set to 50ms (only an explicit absolute cap ends the test) and keeps heartbeating [busy] throughout; a waiting session fails fast naming what it's blocked on; a listing with no status falls back to silence and says so. The long-turn regression test now pins both silence bounds — leaving the status grace at its default let it pass without testing anything.

Repo-wide still zero new failures (6 pre-existing failing files, same as clean main).

@imaustink
imaustink merged commit d550b32 into main Jul 28, 2026
1 check passed
@imaustink
imaustink deleted the fix-agent-timeout-bug branch July 28, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant