Skip to content

[bug] The dominant live cause of dead runs: the Engine's own subscription usage limit is handled as a 15-minute human takeover, so a run dies 41 minutes before the CLI comes back #541

Description

@serge-ivo

The population, first

I pulled GET /v1/instances/:id/loop for all 32 instances on the owner's account (2026-08-13).
80 autonomous runs, 30 failed (37.5%). Full class distribution:

n class live?
8 account token circuit breaker (250M/24h) deadBUDGET_ENFORCE off since d951b5f, 2026-08-10T08:16Z
5 WorkflowInternalError live
4 stuck not resolved in time live
3 $50 daily ceiling dead — same commit
3 CF subrequest ceiling (#523) live
2 provider stall mid-reply live
2 no runner connected live
1 each relay 504, session not running, [object Object] live

d951b5f ("demote AI circuit breakers to observe-only") landed 23 minutes after the last budget
death
(last budget kill 2026-08-10T07:53:19Z; commit 2026-08-10T08:16:32Z). So 11 of the 30
historical failures — 37% — cannot recur. Filtering to the live era leaves 11 failures, and
stuck not resolved in time is the largest class at 4 (36%)
. All four are in the last 35 hours.
It is what is killing his runs now.

What it actually is: the Engine's own subscription limit, handled as a human takeover

Three of the four are the same thing, and it is not "the agent got stuck on a widget". Quoting the
handoff notifications the platform itself sent (/v1/notifications, verbatim):

2026-08-12 11:34:16Z — heartfull/platform: The Claude CLI session has hit its usage limit and
resets at 10:30pm Australia/Sydney time. I cannot proceed until the session limit resets.

2026-08-12 12:06:16Z — dev/aipa: The Claude CLI session has hit its usage limit and will reset
at 10:30pm Australia/Melbourne time.

2026-08-12 12:08:56Z — dev/aipa: The Claude CLI has hit its session limit and cannot process
any more requests until it resets at 10:30pm (Australia/Melbourne time).

(The fourth, 2026-08-12T22:41:08Z, is different and genuinely human-resolvable: Codex refusing with
"Not inside a trusted directory and --skip-git-repo-check was not specified".)

The platform knows the reset time. It is in the sentence it wrote. It does nothing with it.

The measurement that makes this concrete

Run 6550f673 (Heartfull), the clean case with no workflow retry muddying the clock:

event UTC local (AEST, UTC+10)
run starts 11:34:00 21:34
Pilot declares stuck, handoff opens 11:34:16 (+16s) 21:34
run declared failed 11:49:31 (+15m15s) 21:49
CLI usage limit resets 22:30

The run died 41 minutes before the resource it was explicitly waiting for came back, having
waited 15. Run ebc70ac1 is the same shape: handoff at +24s, death at +15m15s.

start → handoff was 16s, 23s, 24s and 24s across the four. These runs never did any work at all —
the very first decide saw a rate-limited CLI and escalated.

Mechanism — two individually-correct decisions

1. The Pilot's decision vocabulary has no way to say "wait".

workers/api/src/lib/coding-loop.ts:444-461, toDecision — the complete tool surface the brain can
return:

case "send_message":      return { action: { kind: "message", text: str(a.text) } };
case "finish":            return { finish: { status: ... } };
case "request_human":     return { stuck: { why: str(a.why) || "needs a human" } };
case "request_user_info": return { needsInput: { field: ..., why: ... } };

There is no wait_until / retry_after. CodingOutcome (coding-loop.ts:75) is
"done" | "stuck" | "needs_input" | "failed" | "max_steps" | "cancelled" — no paused/deferred state
either. Faced with "the CLI is rate-limited until 22:30", request_human is the only honest move
the brain has. It behaved correctly.

2. stuck is bound to a 15-minute human deadline.

workers/api/src/workflows/coding-session.ts:54:

const HANDOFF_WAIT_POLLS = 180; // 180 × 5s = 15 min

and coding-session.ts:643-658:

for (let poll = 0; poll < HANDOFF_WAIT_POLLS && !resolved; poll++) {  }
if (!resolved) {
    result = { outcome: "failed", detail: `${reason} not resolved in time`,};
    break;
}

15 minutes is a sensible bound for "a human needs to click something". It is the wrong bound for "a
subscription window reopens in 56 minutes", and the two are indistinguishable once both are stuck.

Neither decision is wrong on its own. The composition kills the run.

The absence, grepped

$ grep -rniE "usage limit|session limit|limit (will )?reset|resets at" workers/api/src packages/browser-runner/src
(no matches)

The only rate-limit handling in the coding path is coding-failure.ts:155, and that classifies the
Pilot's own BYOK provider error, not the Engine CLI's subscription window. The platform has no
concept of the Engine having a limit — which is odd, because on a subscription auth mode
(lib/coding-engines.ts) that is the expected steady-state failure, not an exception.

What to do — cheapest first

(a) One-line-ish: don't kill a stuck run at 15 minutes; report it as parked.
When the wait expires, end with an outcome that says "waiting on you" rather than failed. He is
re-typing "Retry" on runs that were never broken. This alone removes the "Retry" loop even without
(b) or (c).

(b) Give the brain a wait_until verb. Add case "wait_until": return { waitUntil: { at, why } }
to toDecision, a CodingOutcome of waiting, and have the workflow step.sleep to that instant
(Workflows sleep durably — this is exactly what step.sleep is for; the handoff loop already uses
it at coding-session.ts:644). Cap it — 6 hours is more than a Claude 5-hour rolling window — and
resume the same objective at the same step. The reset time is already in the model's hands: it read
it off the pane to write the sentence quoted above.

(c) Detect it below the brain, so it costs no decision at all. The runner sees the CLI's stdout;
packages/browser-runner/src/coding/headless.ts could surface usageLimitResetsAt on the snapshot,
and the loop could park without spending a BYOK decision. More work; do it only if (b) proves the
brain's extraction unreliable.

(d) Separately: the stuck handoff never reaches the chat thread. grep -n "postToChat" coding-session.ts → lines 218, 330, 527, 532, 537. A runner disconnect is announced in chat
(announce: postToChat, line 330); a human handoff is not — the handoff block (620-660) only
calls notifyUser (line 627). Push fired for all four (verified: pushed_at set on every one), so
this is not "no surface" — but the thread the owner started the run from says nothing while the run
waits on him, then reports it failed.

Alternatives considered and rejected

Acceptance criteria

  1. A Pilot run whose Engine reports a usage limit with a reset time does not end failed within 15
    minutes. It either sleeps to the reset and continues, or ends in a non-failed "waiting" state.
  2. A run parked on a genuine human handoff (the Codex trusted-directory case) still times out and is
    still distinguishable from (1) in check_instance_loop's stopReason.
  3. The handoff reason appears in the instance chat thread, not only in the notification tray.
  4. Replaying the three 2026-08-12 transcripts through runCodingLoop yields waiting, not stuck.

Regression risk

  • A wait_until the brain sets wrongly parks a run for hours. Bound it (≤6h), require a reason
    string, make it cancellable (isCancelRequested is already read every capture,
    coding-session.ts:~390), and count the sleep against the run's wall-clock budget.
  • sweepStaleRuns presumes a run dead after STALE_RUN_MS = 3h (lib/run-sweeper.ts:30). A
    sleeping Pilot must tick last_progress_at or the sweeper will close it — the handoff wait already
    has this hazard and handles it via tick (coding-session.ts:335-338); a wait_until needs the
    same. This is the one that will bite. Test: a run that sleeps 4h is still running afterwards.
  • Changing the terminal outcome for expired handoffs touches closeDelegation's reason mapping
    (coding-session.ts:194-200) and shouldEndSessionAfterRun's error/ended choice
    (coding-session.ts:754) — a waiting outcome must not close the session as error.

Provenance

Runs and timings: /v1/instances/:id/loop + /v1/notifications, production, 2026-08-13 — all four
runs predate nothing, they are current. elapsedMs/phase for the two long ones came from
error_log rows written by ba67afc (#529), which only exists from 2026-08-12 ~10:00 UTC; the two
15m15s runs are timed from run rows alone and need no post-#529 data. Everything above is measured
except the claim that (b) would have saved these three runs, which is inferred from the reset times
the model itself reported.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions