ADR-0001 — Cloudflare Workflows scope: one instance per dispatch, hibernation only for bounded human decisions
Status: proposed 2026-08-06
Related: packages/core/src/step.ts · apps/dispatcher/src/workflow.ts · runs/release-notes.ts (the one sanctioned hibernating run) · the issue-triage design that forced the question
Context
Section titled “Context”Every run already executes as one Cloudflare Workflows instance — RunWorkflow is the
only execution shape in the tree. What has never been written down is which of
Workflows’ durability features a run may lean on, and how long an instance may live.
Two patterns exist today and nothing says when to reach for which:
- Short-lived, one per dispatch.
pr-review,check,offload-test,playwright-e2e,worker-deploy: an event or Action arrives, an instance runs to completion in minutes, every durable fact lives outside it (check-runs, PR comments, D1 rows, R2 artifacts). - Long-lived and hibernating.
release-notesalone: it opens an approval PR, callsstep.waitForEvent("release approval", { type: "release-approval", timeout: "72 hours" })(runs/release-notes.ts:405-409), and declaresmaxDurationSec: 4 * 24 * 3600(:312). A human merging or closing the PR resumes it throughresolveReleaseApproval→signalWorkflow.
Designing issue triage forced the choice into the open. An issue’s lifecycle — opened, triaged, fix proposed, reporter confirms or never replies, reopened months later — is exactly the shape that invites one hibernating instance per issue, with the state machine expressed as ordinary control flow. The alternative is a fresh short instance per webhook event, with the issue’s labels as the authoritative state.
Platform facts, verified against Cloudflare docs on 2026-08-06 (Paid plan):
| Steps per instance | 10,000 default, configurable to 25,000 |
| Step duration (wall clock) | unlimited |
| Step compute time | 30 s default, configurable to 5 min |
| Maximum sleep | 365 days |
| Concurrent instances | 50,000 |
| Instance creation rate | 300/s per account |
| Retention of completed instance state | 30 days |
| Maximum step result size | 1 MiB |
| Persisted state per instance | 1 GB |
| Billing | 500,000 steps included/mo then $0.80 per additional 100,000; storage 1 GB included then $0.20/GB-mo; step and storage billing begins 2026-08-10 |
So the platform does not forbid the hibernating design: instances are cheap, waits can run a year, and 50,000 concurrent instances is far more than any repo’s open issues. The constraints that decide it are ours, not Cloudflare’s.
Three repo facts the decision has to account for:
limits.maxDurationSecbounds the run body, not a step.RunWorkflowcheckpoints the body’s start in arun-ceiling-startstep (apps/dispatcher/src/workflow.ts), andwithRunCeiling(packages/core/src/run-ceiling.ts) fails the runRunDurationExceededwhen the ceiling elapses from it. Below it,waitForExitis a bare wait with no timeout (packages/runtime-cf/src/sandbox-cf.ts:518-547) and step wall-clock is unlimited, so a wedged step holds its sandbox until the run ceiling fires.- The instance id is the idempotency key. A duplicate
create({ id })raisinginstance.already_existsis swallowed as a successful dispatch (apps/dispatcher/src/routes/webhook.ts:259-268). Dedup is therefore a property of how we name instances, not of anything inside the run. - Replay re-executes anything not inside a completed step.
self-heal-pr.ts:186-202usesrunDetached+waitForExitrather than one longexecprecisely so a Worker eviction mid-agent does not re-spawn the agent and double-spend the model budget. The container’ssleepAfter = "10m"(apps/dispatcher/src/sandbox.ts:60) narrows the idle window in which a container is reclaimed between steps. It does not make the container filesystem durable, and nothing does — see rule 5.
Decision
Section titled “Decision”Five rules govern how runs use Workflows.
1. One dispatch, one instance, and the instance id is the semantic idempotency key.
Every entry point (webhook, Action, schedule, child spawn) names its instance from the
event’s identity — {run}:{repo}:{sha}, …:i<issue>:c<comment>, and so on. Naming is
the dedup mechanism; runs do not implement their own.
2. A durable step is the unit of replay-safe work. Anything inside a step may run
more than once. A step that spends money or mutates the outside world must be
individually replay-safe — either idempotent against the external system (check for the
existing PR/comment before creating it) or structured so replay observes rather than
repeats (runDetached then waitForExit, never one indivisible long exec). Step
results are capped at 1 MiB: large outputs go to R2 and the step returns a key.
3. Hibernation is reserved for bounded human decisions with a named decider and a
declared timeout. step.waitForEvent and long sleeps are legitimate when a specific
person owes a specific answer inside a stated window — release approval is the shape.
They are not a mechanism for waiting on the world in general.
4. Long-lived entity lifecycles keep their state in the system of record, and get a fresh instance per event. When the thing being tracked is an external entity that humans also act on — a GitHub issue, a PR, a deployment — the authoritative state is that entity’s own status field (labels, PR state, deployment status), and each webhook event starts a short instance that reads state, acts, and writes state back.
5. Container disk is not durable state, so a step re-establishes what it needs. A
checkout does not survive between durable steps by right. Cloudflare states the
mechanism plainly: “All disk is ephemeral. When a Container instance goes to sleep, the
next time it is started, it will have a fresh disk as defined by its container image”
(Containers FAQ). The same pages
give no guaranteed minimum runtime, restart an out-of-memory instance, and terminate
instances on host restarts. A checkout step that already completed is never re-run by
replay, so on the container backend every attempt after a container swap hits an empty
disk. ensureWorkspace (packages/core/src/primitives/workspace.ts) is the repair, and
it belongs INSIDE the retryable step — a rebuild the retry does not re-run is not a
rebuild. On the substrate backend (SUBSTRATE_BACKEND=on, wrangler.jsonc is "off"
today) execUnderGrant rebuilds inside the fence (apps/substrate/src/facade.ts:210-212),
so the probe is redundant there rather than wrong — but not free, and not a no-op: that
deploy still pays one fenced exec per retryable step, gains a retry on pool-admission
refusals, and pays a retried recipe-pin mismatch, because
packages/runtime-cf/src/sandbox-facade.ts:274-287 folds both into CheckoutFailed.
Scoped deliberately. A re-clone restores the tree the spec describes, which is right
for a suite, a lint or a build, and wrong for a step that reads a tree an earlier step
mutated. Applied to self-heal-pr’s verify step it would hand back a clean checkout and
pass on unmodified code, turning an infra failure into a wrong green — worse than the red
it replaces. Those steps need captured bytes restored, which is the FileRef chokepoint
still open in REWRITE.md.
The rules select a run’s instance shape:
Diagram source
flowchart TB accTitle: Which instance shape a run takes ev["**event arrives**<br/>webhook, Action, schedule, child spawn"] --> name["**name the instance**<br/>from the event's identity"] name --> dup{"Instance id already<br/>created?"} dup -->|yes| folded["**deduplicated**<br/>answered as dispatched"] dup -->|no| human{"Named person owes an answer<br/>inside a stated window?"} human -->|yes| hib["**hibernating instance**<br/>`step.waitForEvent` with a timeout<br/>e.g. release approval, 72 h"] human -->|no| entity{"Tracks an external entity<br/>humans also edit?"} entity -->|yes| fresh["**fresh instance per event**<br/>read state, act, write state back<br/>to labels / PR state / deploy status"] entity -->|no| short["**one short instance**<br/>runs to completion in minutes"] class folded muted class hib accent class fresh ok class short okCorollary to rules 3 and 4: wall-clock bounds must be written explicitly (timeoutSec
on exec, Effect.timeoutFail around waits, as waitForPort already does at
sandbox-cf.ts:565-577). maxDurationSec is the backstop for the whole body; a wait
bounded only by it reports RunDurationExceeded instead of naming the wait that hung.
Rationale
Section titled “Rationale”The wait is unbounded and usually infinite. waitForEvent requires a timeout.
Release approval survives a 72-hour bound because a named person owes the answer now.
Reporter confirmation on an issue arrives in an hour, in three weeks, or — for most
issues — never. Any timeout is then either short enough to abandon live work or long
enough to keep an instance per open issue indefinitely. The platform tolerates that; our
ability to reason about it does not.
Most events are not the awaited one. A suspended instance still has to be woken by every comment to decide whether it is the one it was waiting for, and the majority resolve to “keep waiting”. The router gets rebuilt inside the resumed program, which is the code the durable design was supposed to remove.
Re-entry restarts the work anyway. Issue retriage re-runs the whole reproduce→diagnose→verify→fix pipeline when a comment adds new information. Inside a hibernating instance that is either a loop back to the top — an explicit state machine again — or killing the instance and starting another, which is rule 4 with extra bookkeeping.
External state must be authoritative because humans edit it. Maintainers read and change labels by hand; that is the status UI. If a suspended program held the real state, a maintainer removing a label would desync it from what everyone sees and the instance would keep acting on stale beliefs. When the system of record holds the state, a human edit is simply a legal transition and costs nothing to support.
Operational fit. Completed-instance state is retained 30 days while the entities we track live far longer, so an instance is the wrong place to look for history regardless — D1 and the entity’s own thread are. And with step and storage billing starting 2026-08-10, an instance that hibernates for months is a storage line item that buys us nothing the system of record isn’t already storing for free.
The counter-argument this rejects: hibernation gives in-memory continuity between phases,
so an agent session could survive from triage to confirmation. That continuity is worth
little here — the container is destroyed at the finalize boundary on every exit path
regardless (workflow.ts:684-692), so “continuity” would mean re-cloning and re-priming
anyway.
Consequences
Section titled “Consequences”- Entity-driven runs re-read state from the system of record on every event: a couple of API calls per dispatch, traded for statelessness.
- No cross-event in-memory continuity. Anything that must persist between events is written to the entity (labels, comments, branch existence), to D1, or to R2.
- No per-entity mutex. Dedup by instance id, cooldown, and the container lease are dampers, not serialization; last-writer-wins races on a status field are accepted and must be documented per run.
- Observability loses “one instance = one entity” — the dashboard groups executions by entity instead, and an operator tracing an issue reads its thread plus D1 rows, not one instance timeline.
release-notesstays exactly as it is, and is the reference implementation for rule 3. Any new hibernating run cites this ADR and names its decider and timeout.- Step count and step-result size become design constraints runs are expected to respect, not incidental limits discovered in production.
- Rule 5 costs one
test -dprobe per retryable step on the happy path, and a clone plus install when the probe misses.CheckoutFailedjoins the retried set in those steps, because the repair’s own clone must survive the weather that made it necessary. - Rule 5 is not yet repo-wide.
check,oxlintandoffload-testcallensureWorkspace;cdp-acceptance,matrix-fanout,playwright-e2e,playwright-demo,pr-review,refresh-fixtures,spec-drift-pr,worker-deploy,org-spec-audit,vitest-shardandrelease-notesstill callworkspace()bare, as doesself-heal-pr, which is in the mutated-tree class the rule excludes. Each remaining one is either mechanically portable or excluded, and that call is per run.
Revisit triggers
Section titled “Revisit triggers”- Cloudflare ships a durable per-entity primitive (or Workflows gains cheap unbounded waits with queryable state), making one-instance-per-entity both affordable and inspectable.
- Post-2026-08-10 billing data shows per-event instance creation costing more than hibernation for our event volume — the step-count line is the one to watch.
- A run genuinely needs in-memory continuity across events that re-priming cannot reconstruct cheaply.
- We integrate a system of record with no writable status field, leaving nowhere external to keep entity state.
- Workflows introduces an instance-level wall-clock bound, replacing the dispatcher’s
maxDurationSecenforcement and changing what rule 2’s corollary has to do by hand. - The
FileRefcapture-and-restore chokepoint lands, making the mutated-tree case serviceable. Rule 5’s exclusion exists only because a re-clone is the wrong repair there, and restoring captured bytes would let those steps recover too. - Containers gain durable or reattachable disk, which would retire rule 5 rather than extend it.