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) |
dead — BUDGET_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
- 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.
- 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.
- The handoff reason appears in the instance chat thread, not only in the notification tray.
- 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.
The population, first
I pulled
GET /v1/instances/:id/loopfor all 32 instances on the owner's account (2026-08-13).80 autonomous runs, 30 failed (37.5%). Full class distribution:
BUDGET_ENFORCEoff since d951b5f, 2026-08-10T08:16ZWorkflowInternalErrorstuck not resolved in time$50daily ceiling[object Object]d951b5f("demote AI circuit breakers to observe-only") landed 23 minutes after the last budgetdeath (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 timeis 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):(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-checkwas 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:stuck, handoff opensThe run died 41 minutes before the resource it was explicitly waiting for came back, having
waited 15. Run
ebc70ac1is the same shape: handoff at +24s, death at +15m15s.start → handoffwas 16s, 23s, 24s and 24s across the four. These runs never did any work at all —the very first
decidesaw 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 canreturn:
There is no
wait_until/retry_after.CodingOutcome(coding-loop.ts:75) is"done" | "stuck" | "needs_input" | "failed" | "max_steps" | "cancelled"— no paused/deferred stateeither. Faced with "the CLI is rate-limited until 22:30",
request_humanis the only honest movethe brain has. It behaved correctly.
2.
stuckis bound to a 15-minute human deadline.workers/api/src/workflows/coding-session.ts:54:and
coding-session.ts:643-658: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
The only rate-limit handling in the coding path is
coding-failure.ts:155, and that classifies thePilot'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
subscriptionauth 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
stuckrun at 15 minutes; report it as parked.When the wait expires, end with an outcome that says "waiting on you" rather than
failed. He isre-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_untilverb. Addcase "wait_until": return { waitUntil: { at, why } }to
toDecision, aCodingOutcomeofwaiting, and have the workflowstep.sleepto that instant(Workflows sleep durably — this is exactly what
step.sleepis for; the handoff loop already usesit at
coding-session.ts:644). Cap it — 6 hours is more than a Claude 5-hour rolling window — andresume 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.tscould surfaceusageLimitResetsAton 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
stuckhandoff 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) onlycalls
notifyUser(line 627). Push fired for all four (verified:pushed_atset on every one), sothis 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
HANDOFF_WAIT_POLLS. Rejected: this is the fix [bug] A 4-second retry budget kills long runs — Heartfull died at iteration 2 of 50 on a runner blip the runner itself recovers from #341 explicitly argued against forthe runner case — "a bigger number with the same terminal behaviour only moves the cliff"
(
coding-session.ts:318-321). A 5-hour rolling window can reset anywhere; and a genuinehuman-needed handoff should time out in minutes, not hours. Raising the number degrades the case
it is right for to fix the case it is wrong for.
against
maxStepsre-discovering the same wall, and The Pilot answers a CLI safety refusal by repeating the instruction — 8 escalating sends, 2 with a byte-identical command, then "Loop complete" on the subset the owner did not ask for #522 just landed machinery specifically tostop the Pilot repeating itself. A known reset instant deserves a sleep, not a poll.
does not help the run that exhausts the window mid-objective, which is the more expensive case.
Reasonable as a later addition.
Acceptance criteria
failedwithin 15minutes. It either sleeps to the reset and continues, or ends in a non-
failed"waiting" state.still distinguishable from (1) in
check_instance_loop'sstopReason.runCodingLoopyieldswaiting, notstuck.Regression risk
wait_untilthe brain sets wrongly parks a run for hours. Bound it (≤6h), require a reasonstring, make it cancellable (
isCancelRequestedis already read every capture,coding-session.ts:~390), and count the sleep against the run's wall-clock budget.sweepStaleRunspresumes a run dead afterSTALE_RUN_MS = 3h(lib/run-sweeper.ts:30). Asleeping Pilot must tick
last_progress_ator the sweeper will close it — the handoff wait alreadyhas this hazard and handles it via
tick(coding-session.ts:335-338); await_untilneeds thesame. This is the one that will bite. Test: a run that sleeps 4h is still
runningafterwards.outcomefor expired handoffs touchescloseDelegation's reason mapping(
coding-session.ts:194-200) andshouldEndSessionAfterRun's error/ended choice(
coding-session.ts:754) — awaitingoutcome must not close the session aserror.Provenance
Runs and timings:
/v1/instances/:id/loop+/v1/notifications, production, 2026-08-13 — all fourruns predate nothing, they are current.
elapsedMs/phase for the two long ones came fromerror_logrows written by ba67afc (#529), which only exists from 2026-08-12 ~10:00 UTC; the two15m15s 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.