Skip to content

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

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-notes alone: it opens an approval PR, calls step.waitForEvent("release approval", { type: "release-approval", timeout: "72 hours" }) (runs/release-notes.ts:405-409), and declares maxDurationSec: 4 * 24 * 3600 (:312). A human merging or closing the PR resumes it through resolveReleaseApproval → 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.maxDurationSec bounds the run body, not a step. RunWorkflow checkpoints the body’s start in a run-ceiling-start step (apps/dispatcher/src/workflow.ts), and withRunCeiling (packages/core/src/run-ceiling.ts) fails the run RunDurationExceeded when the ceiling elapses from it. Below it, waitForExit is 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 }) raising instance.already_exists is 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-202 uses runDetached + waitForExit rather than one long exec precisely so a Worker eviction mid-agent does not re-spawn the agent and double-spend the model budget. The container’s sleepAfter = "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.

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:

Which instance shape a run takes Flowchart, top to bottom. 9 nodes, 8 edges. event arrives webhook, Action, schedule, child spawn → name the instance from the event's identity name the instance from the event's identity → Instance id already created? Instance id already created? → deduplicated answered as dispatched [yes]; → Named person owes an answer inside a stated window? [no] Named person owes an answer inside a stated window? → hibernating instance step.waitForEvent with a timeout e.g. release approval, 72 h [yes]; → Tracks an external entity humans also edit? [no] Tracks an external entity humans also edit? → fresh instance per event read state, act, write state back to labels / PR state / deploy status [yes]; → one short instance runs to completion in minutes [no] yes no yes no yes no event arriveswebhook, Action, schedule, childspawn name the instancefrom the event's identity Instance id alreadycreated? deduplicatedanswered as dispatched Named person owes ananswerinside a stated window? hibernating instancestep.waitForEvent with a timeoute.g. release approval, 72 h Tracks an external entityhumans also edit? fresh instance per eventread state, act, write state backto labels / PR state / deploy status one short instanceruns to completion in minutes
Which instance shape a run takes
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 ok

Corollary 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.

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.

  • 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-notes stays 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 -d probe per retryable step on the happy path, and a clone plus install when the probe misses. CheckoutFailed joins 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, oxlint and offload-test call ensureWorkspace; cdp-acceptance, matrix-fanout, playwright-e2e, playwright-demo, pr-review, refresh-fixtures, spec-drift-pr, worker-deploy, org-spec-audit, vitest-shard and release-notes still call workspace() bare, as does self-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.
  • 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 maxDurationSec enforcement and changing what rule 2’s corollary has to do by hand.
  • The FileRef capture-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.