Bound a remote-control turn by silence, not by a 30-minute stopwatch - #171
Conversation
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
Update: the wait is now driven by the session's reported status, not by a timerThe 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
...d.status && {status: EMm(d.status)},
...d.status === "waiting" && d.waitingFor && {waitingFor: d.waitingFor}where 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 Each state now gets the bound its evidence deserves:
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 reviewSplitting the clocks matters more than it looks. Coverage42 pass in the package (15 in the remote-control file). New: a Repo-wide still zero new failures (6 pre-existing failing files, same as clean |
Fixes the agent timeouts reported on #149.
Root cause
runClaudeTurnRemoteControlledwas the only bound in the system that measured duration rather than silence, and it was the shortest by two orders of magnitude:activeDeadlineSecondsawaitReplyThe one-shot
claude -ppath 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:This exact bug class was already fixed one layer up —
values-production.yamlraisedpollTimeoutMsfrom 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
activeDeadlineSecondsis the ceiling, as it already claims to be.maxWaitMssurvives 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:
-ppath already emits), and only says "still running" during real silence, naming how long the silence has lasted.childExitedcheck never fired for it.agents --json --alllists 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 aturn_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:maxWaitMsis passed, because there is no absolute cap)maxWaitMsstill capsThe fakes grew a sentinel so a session only appears in
claude agentsonce 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 workspacedist).Notes for review
e2e/README.mdstubs the Claude Code CLI withstub-agentby design ("a real agent run needs a real paid credential"), so the suite structurally cannot execute this path.resilience.e2e.tsalready 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 intovalues-ci-all.yamlsovalidate-crdsrenders them.pollTimeoutMsis still 15 min, overridden to 8h5m only invalues-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