Repository navigation
A platform deploy kills in-flight coding runs, and the Pilot writes retryable:true for a reader that does not exist #583
Description
Our own deploys kill in-flight coding runs, and the Pilot records retryable:true without retrying
Run b9d9c051-662c-47f6-a5ef-da0fab7aa6cd (instance bd43f4de-…, 2026-08-15) recorded two
terminal errors, both retryable: true, and was marked failed:
| ts | class | phase | steps | message |
|---|---|---|---|---|
| 00:25:20 | infra_transient |
s9-waitidle |
2 | Durable Object reset because its code was updated. |
| 00:28:27 | provider_stall |
s21-decide |
4 | AI provider stopped sending mid-reply — 20s of silence |
Both carry "retryable": true and "resumableRound": false.
1. The first cause is us
Durable Object reset because its code was updated is a platform deploy. It is not the
owner's network, not their machine, not the provider. Deploying the API worker evicts the DO and
kills whatever coding run was mid-flight.
$ gh run list --workflow=deploy-api.yml → 8 successful API deploys in the 24h to 2026-08-15
Eight self-inflicted kill windows in one day, on a platform whose flagship agent runs for hours.
The owner experienced this as agents dying for no reason.
2. retryable: true has no consumer on this path, and the code says so
thinkWithAutoResume (lib/resumable-round.ts:170) is the #518 auto-retry — "on a retryable
failure that carried a round, run it ONCE more from that round." Its call sites:
$ grep -rn thinkWithAutoResume workers/api/src --include=*.ts | grep -v '\.test\.'
agent-do.ts:566 ← chat turns
coding-failure.ts:362 ← a comment, not a call
coding-failure.ts:362 states it plainly:
// The Pilot has no thinkWithAutoResume (#518) — this is expected to be false
So the Pilot classifies a failure as retryable, records that verdict, and then fails the
run. The verdict is written for a reader that does not exist. A chat turn dropped by the same
provider stall retries automatically; an hours-long autonomous run does not.
autoResumableRoundOf (:163) already encodes the safe-to-retry rule — retryable and carrying
a round — so the decision logic exists and is unused here.
3. One run, two deaths
Both rows above share runId b9d9c051. This is the double-filing #546 reports; noting it
because the classification has since improved (infra_transient / provider_stall rather than
unknown) while the double-write has not. #546 should be re-read against this, not re-derived.
What a fix must decide
An infra reset and a provider stall are both retryable but not alike:
- DO reset from a deploy is ours, cheap to retry, and arguably should not end a run at all —
the workflow is durable and the session survives. Retrying is close to free. - Provider stall costs the owner's credit on every attempt. [bug] #442 stores a resumable round the user cannot reach — the gate is byte-equal text, the owner speaks, and the console clears the composer before sending #518's "exactly two attempts, not a
loop" reasoning applies unchanged: "a turn that retries until it succeeds spends someone else's
credit deciding when to stop."
Whether a deploy should defer while runs are in flight, or runs should simply survive a reset, is
the larger question and belongs in this issue's discussion rather than being assumed.
Acceptance criteria
- A coding run killed by a DO code-update reset resumes rather than terminating, or the issue
records why terminating is correct and the owner is told the cause in the run's own detail —
today it reads as an unexplained failure. - The Pilot consumes the retryable verdict it already computes, reusing
autoResumableRoundOf
rather than a second rule. Attempt count bounded and justified against [bug] #442 stores a resumable round the user cannot reach — the gate is byte-equal text, the owner speaks, and the console clears the composer before sending #518's reasoning. - Retry policy differs by failure class where the cost differs, and the difference is stated.
- A test asserts a
retryable:truecoding failure is retried — the property [bug] #442 stores a resumable round the user cannot reach — the gate is byte-equal text, the owner speaks, and the console clears the composer before sending #518 was written to
prove is that the stored round is consumed, and the same standard applies here. - A guard that a failure class marked retryable has a consumer on every driver, stated with
its denominator per ADR 0002. A verdict computed and read by nobody is the defect this issue is
really about.
Verified vs inferred
- Verified: both error rows with full context (
retryable,resumableRound,failureClass,
phase,steps) read live fromagent_trace; the 8 deploy-api runs; the complete call-site list
forthinkWithAutoResume;coding-failure.ts:362's comment verbatim;resumable-round.ts:163
and:170. - Inferred: that the DO reset was caused by one of those 8 deploys specifically. The reset
message names a code update and 8 code updates shipped that day, but the reset was not
correlated to a particular deploy by timestamp. If some other mechanism resets the DO, AC1's
framing changes and this should be re-checked before implementation.
Activity
serge-ivo commentedon Aug 15, 2026
The inferred correlation is now exact — and it was one of my own commits
The issue said the DO reset "was not correlated to a particular deploy by timestamp." It is now.
Deploy API Workerfor4c0826b, successb9d9c051starts/tmpscratch act (see #582)infra_transient— "Durable Object reset because its code was updated."56 seconds between the deploy finishing and the DO reset.
4c0826bis #571's commit — landedby an agent working this backlog. We killed the owner's run with our own fix.
It was not one deploy, it was a storm
API deploys in the 45 minutes surrounding that run:
Seven deploy runs in 48 minutes. The run existed entirely inside that window and had no chance of
surviving it. This is not a rare race — on an active development day, a long-running coding run is
close to guaranteed to be evicted.
The second failure (
provider_stall, 00:28:27) falls insidef126db6's deploy window but is adifferent class and is not claimed here as deploy-caused.
What this sharpens
AC1 asked whether a run should resume or terminate on a DO reset. The measured deploy cadence
answers it: terminating is not viable. A platform that ships 7 API deploys in 48 minutes cannot
also treat a code update as a run-ending event, or its flagship hours-long agent is unusable on any
day the team is working. Resumption is the requirement, not one of two options.
It also adds a case AC5's guard should cover: an
infra_transientfailure whose cause is our owndeploy is categorically different from one caused by the owner's network, and should not consume
the owner's retry budget or credit at all.
Verified vs inferred, updated
gh run list --workflow=deploy-api.yml; the run's own start and both error timestamps convertedfrom the epoch-ms values in
agent_trace; the 56-second gap.4c0826b's deploy is the specific eviction rather than a laterrollout step of the same release — the eviction moment itself is not logged, only its
consequence. The 56-second gap and the absence of any other candidate in that minute make it the
only plausible one, and nothing in the fix depends on which of the seven it was.
fix(coding): a run our own deploy interrupted is replayed, not buried…
serge-ivo commentedon Aug 15, 2026
Landed — the Pilot now consumes the verdict it was already writing
Commit
03762fd(CI green incl.test:e2e; API deploy31876808933succeeded, migration0127applied).
What the resume actually is
Rethrowing. An error escaping
run()makes Cloudflare replay the instance from its journal, soevery step that already succeeded returns its recorded result and only the step that died runs
again. That is not a hoped-for mechanism — #546 measured it, when run
82739cb6filed its own deathtwice with an identical journalled
runStartedAtand got further the second time.So the missing piece was never the retry. It was that the driver tore the run down first: the
finallyends the session, releases the single-flight driver claim, closes the board card and posts"Loop stopped". Skipping the teardown while resuming is the load-bearing half — without it the
replay carries on working inside a run every surface has already reported as failed. Guarded by
coding-resume.test.ts, which asserts each terminal writer sits inside the!resumingbranch.AC2 —
autoResumableRoundOfcould not be reused literally, and that is a findingIt gates on a stored
ResumableRound, which onlyrunAgentThinkever attaches. On a Pilot error itreturns
nullby construction — a "reuse" that would compile, pass, and retry nothing. That isprecisely why every Pilot failure records
resumableRound: false;coding-failure.ts:364wasright, and it was right for a reason that also makes the suggested fix a no-op.
What generalises is the predicate, so
isRetryableFailurewas lifted out ofautoResumableRoundOfand both drivers gate on one implementation. There is a test asserting both readers see it, and
asserting the Pilot's carrier is the classification (a DO reset arrives as a plain message with no
structural fields;
classifyCodingFailureis the site that knows).AC3 — retryable is not resumable, and the table is what keeps them apart
CODING_RESUME_POLICYis total over all 11 classes, each with a stated reason. Exactly oneresumes.
infra_transientworkflow_internalorigin mainprovider_stallYour framing is what made the bound decidable: an
infra_transientcaused by our own deploy coststhe owner nothing, so three attempts are affordable where #518 allows two — and the difference is
stated rather than assumed. The counter is durable (
agent_loop_runs.interruptions), because anin-memory one would replay with the journal and reset on the very event it bounds. A run with no
loop-run row is not resumed: an unbounded replay is worse than a terminated run.
The owner is told, on both surfaces: a
coding.run.interruptedtrace event and a chat line namingthe cause and the attempt number. The run is marked
waiting_reason: "platform_interrupt"so areplay in flight reads as waiting, never as stalled — a resume has nothing ticking by design.
Left open
chat, apply, browser and pipeline drivers each have their own failure vocabulary, and a
cross-driver guard needs a shared classification they do not yet have. That is a real piece of
work and I am not claiming it; say the word and I will file it.
landed, so
interruptionsis still 0 on every row and the resume path has unit coverage only.The next deploy that catches a live run is the test; if it fails I will fix it forward.
unknown#546) is untouched — one run still records two deaths when it dies twice.Correctly so: those are two attempts, and this change makes the first of them survivable rather
than merging the records.
fix(runs): a park states when it ends, because the instant was comput…
serge-ivo commentedon Aug 15, 2026
AC5 closed —
1105690. The sweep found a driver that consumed nothing at all.03762fdcorrectly declined to claim this: the guard was stated over every failure CLASS of onedriver, not over every DRIVER, because a cross-driver guard needs a shared classification and there
was none.
The finding: the classification was never coding-specific
Building the shared thing turned up what made it easy.
classifyCodingFailurereads a providertransport failure, a runner that went away, a Cloudflare per-invocation ceiling and an isolate
reset — every one a fact about the PLATFORM, and every one able to kill an apply, a browser
task, a pipeline or a chat loop identically. Only its first consumer was the Pilot. The name
recorded the consumer instead of the subject.
So
codingResumePlan→driverResumePlanandCODING_RESUME_POLICY→DRIVER_RESUME_POLICY. Notcosmetics: a decision function named after one driver is exactly how the next driver grows a second
copy of the rule instead of calling this one, which is the defect this issue is about.
classifyCodingFailureandCodingFailureClasskeep their names deliberately — a shared vocabularywhose name is a wart rather than a coupling, and renaming churns ~60 references for no property.
agent-loop.tshad no consumer at allFive durable drivers, enumerated from
workflows/on disk. Four consumed the verdict in some form.agent-loop.tsconsumed nothing — its catch turned every death intostop = { reason: "failed" },including our own deploy resetting the isolate, while its four peers all re-threw and resumed. At the
cadence this issue measured (seven API deploys in 48 minutes) that is a permanently closed run for a
cause that is ours.
Nobody had checked, because nobody had asked the question as a SET — the same reason
coding-session.tssat at zerologEventcalls against 6/6/7/3 in its peers untilworkflow-trace.test.tsasked.It now reaches the same decision function the Pilot does, and it is bounded where the other
three are not, because it has an
agent_loop_runsrow andcountInterruptioncan hold a durablecount. The replay is cheap for the Pilot's reason:
act,decide,reserveandsettleare allstep.do, so a replay returns each journalled result without re-executing or re-charging.Skipping the budget settle while resuming is the load-bearing half, and the reason is money. The
finally's settle and the loop's ownsettle-${iteration}are DIFFERENT step ids, so settling onthe way out and then replaying into an un-journalled
settle-${iteration}charges the shared treepool twice for one iteration — worse than the leak the surrounding comment already calls out.
Left alone, the replay settles it once through the step that was always meant to.
The guard, with its denominator (ADR 0002)
workflows/driver-failure.test.ts, 13 arms:workflows/on disk — 7 source files, 5 drivers — and anon-driver has to be NAMED with its reason, reusing the same two exemptions
workflow-trace.test.tsuses so two guards over one set cannot disagree about what the set is;credit a driver with a consumer it does not reach;
inside the resume branch, it precedes the terminal
failed, the settle is gated, and only thatbranch sets
resuming. [bug] #442 stores a resumable round the user cannot reach — the gate is byte-equal text, the owner speaks, and the console clears the composer before sending #518's standard: a call whose verdict falls through tofailedwouldsatisfy a registry and change nothing.
Verified red before green: deleting agent-loop's branch fails the guard on agent-loop
specifically. It also caught one of my own registry entries saying
UNBOUNDEDwithout saying why.What this does NOT claim, deliberately
That every driver resumes.
job-apply,browser-taskandpipeline-runconsume the verdictthrough the narrower
isTransientInfraErrorand rethrow UNBOUNDED, because none has a durablerun row to count against — the first two carry a task id, and
pipeline_runshas nointerruptionscolumn. The guard records that asymmetry with a stated reason per driver rather than smoothing it
over. Bounding those three is a migration and a separate decision; say the word and I will file
it.
Still not observed in production
interruptionsis 0 across all 89 runs andwaiting_reasonnull on all 89 — no deploy hasinterrupted an in-flight run of either driver since
03762fd, so both resume paths still have unitcoverage only. That is unchanged from
03762fd's own caveat and I am not claiming better. The nextdeploy that catches a live run is the test.
CI green on
1105690; API deploy31880412727succeeded.fix(runs): a park states WHICH kind of deadline it has, so the handof…
fix(runs): a run the platform is about to replay stops being filed as…
fix(runs): bound a park, enforce a cancel, retire a displaced run (#790)