Skip to content

fix(sandbox): rebuild a checkout the container threw away - #126

Closed
nplusonedev wants to merge 1 commit into
mainfrom
fix/workspace-survives-container-loss
Closed

fix(sandbox): rebuild a checkout the container threw away#126
nplusonedev wants to merge 1 commit into
mainfrom
fix/workspace-survives-container-loss

Conversation

@nplusonedev

@nplusonedev nplusonedev commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

A consumer's offload-test runs have been red for days. The exec step retried
exactly as designed and recovered nothing:

attempt 1  (2m54s)  ExecFailed: HTTP error! status: 500
attempt 2  (1.1s)   ExecFailed: HTTP error! status: 500
attempt 3  (4.7s)   ExecFailed: working directory '<dir>' was missing at exec time
attempt 4  (0.6s)   ExecFailed: working directory '<dir>' was missing at exec time

Read from the Workflows instance record, not from logs.

Attempts 3 and 4 reached a fresh container. That is the bug. The platform
replaced the container, the new one booted with an empty disk, and the checkout
step had already completed — so Workflows never re-ran it. Every attempt after
the replacement was doomed before it started, and no retry budget could change
that.

This is documented platform behaviour, not a platform bug

From the Containers FAQ and
Lifecycle of a Container:

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.

The same pages state there is no guaranteed minimum runtime, that an
out-of-memory instance is restarted, and that host restarts terminate instances
at unpredictable times.

We were treating the container filesystem as state that survives a checkpoint.
It never was.

The fix

workspace() now returns a spec — plain JSON, so it rides the checkpoint —
and the new execInWorkspace primitive re-clones from it inside the step that
finds the tree gone, then runs the command once more.

This mirrors what the substrate already does: ensure() before every exec,
rebuilding from the recipe when the restore misses. The dispatcher path had only
the reclassification backstop and no recovery.

One rebuild, not a loop. A second miss means the container is being replaced
faster than it can be populated, and the step's own retry budget already re-arms
the recovery with exponential backoff between attempts.

The rebuild reaches the replacement, not a corpse

This was the load-bearing question, and it is settled by the mechanism rather
than by hope. workspaceMissing can only be raised after a shell ran on the
new container — isWorkingDirFailure requires a non-zero exit carrying the
shell's own directory-change error on stderr. So at the moment the rebuild is
decided, the sandbox client in hand has just round-tripped to the replacement,
and the clone goes through that same client. The production trace confirms it:
attempts 3 and 4 got answers back from a fresh container through this handle.

Typed signal, classified first

ExecFailed gains an optional workspaceMissing, so the decision is a field
rather than a match against the message the runtime just wrote.

That classification now runs before the timeout regex. The marked throw
embeds cwd, so a repo path containing "timeout" (/workspace/request-timeout)
would otherwise classify as ExecTimeout, skip the rebuild, and — since
ExecTimeout is not in RETRY_ON — kill the step non-retryably. Two tests pin
this; both fail if the ordering is reverted.

RETRY_ON also gains CheckoutFailed: a clone failing mid-recovery happens in
exactly the weather that triggers recovery, and rethrowForRetryPolicy would
otherwise rethrow that transient as NonRetryableError.

Docs, which said the opposite

specs/adr/0001-cloudflare-workflows-scope.md claimed the container filesystem
is "shared state across durable steps" kept alive by sleepAfter. It now
records the platform facts, cites this run as the evidence, and carries a new
rule 3.

Rule 3 is scoped deliberately. A re-clone restores the tree the spec
describes, which is correct for a suite, a lint or a build, and wrong for a step
that reads a tree an earlier step mutated. Applied blindly 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, which is worse than the red it
replaces. Those steps need captured bytes restored, which is the FileRef
chokepoint already open in REWRITE.md, and the rule says so.

Also updated: the dispatcher's sleepAfter comment no longer claims to buy
durability (it narrows the idle window and does nothing when the container dies
mid-exec), runs/README.md, packages/core/README.md, and the REWRITE.md gap
row that already called this out as "the container FS is mistaken for durable
state" — now marked partly landed, since this re-clones rather than restoring
captured bytes.

Scope

Only offload-test moves onto the primitive: it is the failing path and the one
with 35-minute execs.

Same exposure, mechanical follow-ups: check, worker-deploy,
playwright-demo, pr-review, oxlint.

Same exposure, but execInWorkspace is the wrong fix and would produce a
wrong green: self-heal-pr, refresh-fixtures (mutated trees), cdp-acceptance
(detached model). These need the FileRef work.

Not exposed: matrix-fanout and playwright-e2e clone and exec inside one step;
release-notes builds its workspace outside a step, so replay re-runs it.

Verification

pnpm typecheck 0 errors, pnpm lint clean, pnpm test 174 files / 2242
passed, 1 skipped.

Mutation-checked rather than assumed: disabling the workspaceMissing guard
fails 3 of the 7 new primitive tests, and reverting the classification ordering
fails both new runtime tests. They pin behaviour, not the fake.

Honest boundary: this is proven against fakes plus the production trace, not
end to end against a real container replacement. Container boot is not
exercisable under Miniflare, so the live-path claim rests on the shared-client
mechanism described above and on the observed run.

An offload-test run failed with the exec step retrying exactly as designed and
recovering nothing:

    attempt 1  (2m54s)  HTTP error! status: 500
    attempt 2  (1.1s)   HTTP error! status: 500
    attempt 3  (4.7s)   working directory '<dir>' was missing at exec time
    attempt 4  (0.6s)   working directory '<dir>' was missing at exec time

Attempts 3 and 4 reached a fresh container. That is the whole bug: the platform
replaced the container, the new one booted with an empty disk, and the step that
would have cloned into it had already completed, so Workflows never re-ran it.
Every later attempt was doomed before it started.

Cloudflare is explicit that this is normal: "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." There is also no minimum runtime,
an out-of-memory instance is restarted, and a host restart can take one at any
time. We were treating the container filesystem as state that survives a
checkpoint, and it never was.

The fix mirrors what the substrate already does with `ensure()` before every
exec. `workspace()` now returns a `spec` — plain data, so it rides the
checkpoint — and `execInWorkspace` re-clones from it inside the step that finds
the tree gone, then runs the command once more. One rebuild, not a loop: a
second miss means the container is being replaced faster than it can be
populated, and the step's own retry budget already re-arms the recovery with
backoff between attempts.

The rebuild reaches the replacement, not a corpse. `workspaceMissing` can only
be raised after a shell RAN on the new container — `isWorkingDirFailure` needs a
non-zero exit with the shell's own directory-change error on stderr — so the
sandbox client in hand has just round-tripped to it, and the clone goes through
that same client.

`ExecFailed` gains `workspaceMissing` so the decision is a typed field rather
than a match against the message, and that classification now runs BEFORE the
timeout regex: the marked throw embeds `cwd`, so a repo path containing
"timeout" would otherwise be classified `ExecTimeout` and silently disable the
rebuild. `RETRY_ON` gains `CheckoutFailed`, because a clone failing mid-recovery
happens in exactly the weather that triggers recovery, and without it that
transient would be rethrown as non-retryable.

Docs corrected, because they said the opposite. ADR-0001 claimed the container
filesystem is "shared state across durable steps" kept alive by `sleepAfter`;
it now states the platform gives no such guarantee, and carries a new rule 3.
That rule is scoped deliberately: a re-clone restores the tree the SPEC
describes, which is right for a suite or a lint and WRONG for a step reading a
tree an earlier step mutated — re-cloning would hand self-heal-pr's verify a
clean checkout and pass on unmodified code. Those need captured bytes, which is
REWRITE.md's open FileRef chokepoint, and the rule says so rather than inviting
a wrong green. The dispatcher's `sleepAfter` comment no longer claims to buy
durability; runs/README.md and packages/core/README.md follow.

Only offload-test moves onto the primitive here. check, worker-deploy,
playwright-demo, pr-review and oxlint have the same exposure and are
mechanical follow-ups; self-heal-pr, refresh-fixtures and cdp-acceptance read
mutated or detached state and need the FileRef work instead.

@flaredispatch-fractalboxdev flaredispatch-fractalboxdev Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI code review — ✅ Approve

Risk tier: full · 0 critical · 0 warnings · 0 suggestions

Reviewers: security ⚠️ · performance 0 · code-quality 0 · documentation ⚠️ · release-management ⚠️ · compliance ⚠️ · agents-md ⚠️

No findings.

📋 View full logs & reviewed diff ↗

@debuggingfuture

Copy link
Copy Markdown
Member

Ran /pr-merge on this. Stopping before merge — the mechanism here has been superseded, and the rebase can't be resolved without reverting merged work.

Superseded

This branched from #125. Six commits landed on main since, all on this exact failure mode: #127#128#130#133#134. main now recovers via ensureWorkspace (probe test -d <dir>/.git, re-clone if gone), called per-stage inside each retryable step. This PR's execInWorkspace is the reactive form of the same idea, plus a shared up-front step("checkout").

Why the conflict can't be resolved

Four conflicted files, and the conflicts are design-level rather than textual. Taking this PR's side would:

  1. Drop StepFailed from RETRY_ON — a direct revert of fix(runs): survive a platform kill — retry a bare StepFailed, and rebuild the checkout on every path #134, the newest commit on main. It went ["ExecFailed"]["ExecFailed", "StepFailed"] because a platform kill loses its Effect Cause, errorTagOf falls back to "StepFailed", and a policy listing only ExecFailed classified the one purely-platform failure as non-retryable. This PR's ["ExecFailed", "CheckoutFailed"] puts that back.
  2. Reinstate a shared up-front checkout, reverting fix(offload-test): a stage probes for its checkout before it runs #128/fix(runs): every PR run re-establishes its checkout inside the retryable step #130's per-stage isolated workspaces.
  3. Add a second rebuild mechanism beside ensureWorkspace, with no caller for it.

Neither side wrote ["ExecFailed", "StepFailed", "CheckoutFailed"], so resolving the conflict means inventing behaviour neither branch has — not a merge decision.

What this PR got right, and what it's owed

The diagnosis was correct and it found three defects that are still live on main — filed as #136:

  1. The classification-ordering bug is real and unfixed. cwd is /workspace/<repo-name>, the throw interpolates it, and the catch regex /timed?\s*out|timeout/i runs against that message with no workspace-missing check first — so a consumer repo named request-timeout misclassifies a container recycle as ExecTimeout, which isn't in RETRY_ON and dies non-retryably. No test pins it.
  2. CheckoutFailed is still absent from RETRY_ON, so a recovery clone that fails transiently is non-retryable — in exactly the conditions that caused the recycle. (Bounded: it's masked when the Cause is erased and the tag falls back to StepFailed.)
  3. specs/adr/0001-cloudflare-workflows-scope.md still says the container filesystem is "shared state across durable steps" — the belief the whole feat(offload-test): a stage's retry rebuilds the workspace its death destroyed #127fix(runs): survive a platform kill — retry a bare StepFailed, and rebuild the checkout on every path #134 series disproved. The sleepAfter comment repeats it.

The ExecFailed.workspaceMissing field, the two ordering tests, and the ADR rewrite (with its rule 3 scoping caveat about mutated trees and self-heal-pr) are all directly reusable. #136 has the suggested shape: three commits, no new primitive.

Recommend closing this in favour of #136 rather than rebasing it. Happy to open that PR if you want it.

@debuggingfuture

Copy link
Copy Markdown
Member

Correction to my comment above, after both findings were verified against origin/main line by line. #136 is updated.

Finding 2 is unconditional — I hedged it wrongly. I wrote that it is "masked when the Cause is erased and the tag falls back to StepFailed". That path does not exist for this error. errorTagOf (packages/runtime-cf/src/step-runner-cf.ts:139-149) returns "StepFailed" only when cause === undefined; runEffect attaches the live Effect Cause to the thrown Error, and a CheckoutFailed always carries one. So errorTagOf always reads "CheckoutFailed", it is never in RETRY_ON, and the run dies with 0 of its 3 PLATFORM_RETRIES spent. The StepFailed entry never rescues it.

The asymmetry makes it sharper: the initial step("checkout", acquireWorkspace) (runs/offload-test.ts:632) passes no opts, so retryOn === undefined and CF's default retry applies. The first clone is retried; the recovery clone — the one that runs precisely when the container is already unstable — is not.

Finding 1, two details worth having. cwd reaches the thrown message twice: literally, and again inside the appended stderr tail (Failed to change directory to '<cwd>'). And there is a test for the missing-working-directory case — packages/runtime-cf/src/sandbox-cf.test.ts:405-430 — but it uses cwd: "/workspace/repo", with no timeout substring, so it stays green with the bug present.

@nplusonedev

Copy link
Copy Markdown
Contributor Author

Superseded — closing in favour of #139.

While this sat open, #127, #128, #130 and #134 landed a different recovery for the same failure: ensureWorkspace in packages/core/src/primitives/workspace.ts, a test -d <dir>/.git probe that re-clones on a miss, called inside the retryable step. It covers offload-test (both paths), check and oxlint; this PR only ever touched offload-test, so what landed is the wider fix, and execInWorkspace is redundant against it.

Merging this as-is would also regress: it sets RETRY_ON = ["ExecFailed", "CheckoutFailed"], dropping the StepFailed that #134 added after observing a platform kill arrive as a bare StepFailed.

Three things here were genuinely missing from main, and #139 carries all three:

  1. The classification ordering. The missing-workspace throw embeds cwd, and the catch matched /timeout/i against that message first — so a checkout under a path like /workspace/request-timeout classified as ExecTimeout, which RETRY_ON excludes on purpose, and the step died unretried. This was a live defect on main, confirmed by mutation: reverting the fix makes the new test report expected 'ExecTimeout' to be 'ExecFailed'.
  2. CheckoutFailed in RETRY_ON — added to all three runs, keeping StepFailed.
  3. The ADR-0001 rule on ephemeral container disk, including the caveat this PR wrote and main has nowhere: a re-clone is the wrong repair for a step reading a mutated tree, because it turns an infra red into a wrong green.

The workspaceMissing field on ExecFailed is not carried over. Nothing reads it once the classification happens at the throw site, and a declared-but-unwired field reads as wired to the next person.

Thanks — the diagnosis in this PR body is what made the defect findable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants