Skip to content

fix(runs): a lost checkout under a path named "timeout" was never retried - #139

Open
nplusonedev wants to merge 7 commits into
mainfrom
fix/workspace-missing-classification
Open

fix(runs): a lost checkout under a path named "timeout" was never retried#139
nplusonedev wants to merge 7 commits into
mainfrom
fix/workspace-missing-classification

Conversation

@nplusonedev

Copy link
Copy Markdown
Contributor

Three things PR #126 had that main does not. #126 itself is superseded — see below.

Why this exists

#126 proposed an execInWorkspace primitive to rebuild a checkout a replaced container threw away. While it sat open, #127, #128, #130 and #134 landed a different recovery: ensureWorkspace, a test -d <dir>/.git probe that re-clones on a miss, called inside the retryable step by offload-test (both paths), check and oxlint.

That recovery is wider than #126's — #126 only ever touched offload-test — and the primitive is redundant against it. But three things in #126 are still missing from main, and one of them is a live defect.

1. A lost checkout under a path containing "timeout" was never retried

isWorkingDirFailure raised a bare Error whose message embeds the missing cwd. The enclosing catch then tested /timed?\s*out|timeout/i against that same message to spot an SDK timeout. So a checkout at, say, /workspace/request-timeout classified as ExecTimeout — a class RETRY_ON excludes deliberately, because a command that outran its ceiling will outrun it again. The step died unretried and ensureWorkspace never got its second chance.

The failure is now built at the throw site as ExecFailed, and the catch opens with if (cause instanceof ExecFailed) return cause;.

Mutation-checked rather than asserted: remove that early return and the new test reports expected 'ExecTimeout' to be 'ExecFailed'. The test drives makeSandboxCloudflareLive, not a fake.

2. CheckoutFailed joins RETRY_ON

ensureWorkspace re-clones inside the retryable step, and sandbox.gitClone fails CheckoutFailed, which was in none of the three lists. A clone that flaked mid-recovery ended the step one call short of the repair it was invoked to perform.

The cost, stated plainly. CheckoutFailed is a catch-all over the whole clone body, so deterministic failures ride it too — a bad sha, a repo that is gone, and on the substrate backend the recipe-pin mismatch at sandbox-facade.ts:274-287. On the isolated-stages path there is no earlier checkout step (offload-test.ts:653), so the stage's clone is the run's first and those deterministic failures do reach the retry.

For the failures named above that is four fast attempts plus 5s/10s/20s of backoff — under a minute — times the stages running at once, each minting its own installation token on the container backend. A clone that hangs rather than fails is bounded by CLONE_TIMEOUT_SEC = 600 per attempt, so roughly 40 minutes worst case. That last figure is read from the code and assumes Cloudflare applies the per-step timeout per attempt; it is not a measured bound.

ContainerLaunchFailed and ContainerBusy stay out. ExecTimeout stays out, unchanged.

3. ADR-0001 rule 5

The ADR claimed the container filesystem is "shared state across durable steps" kept alive by sleepAfter. It is not, and apps/dispatcher/src/sandbox.ts said the same thing in the file the ADR cites — so following the citation landed a reader on the retired claim. Both are corrected, with Cloudflare's own wording on ephemeral disk.

Rule 5 carries the caveat that matters: 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 reading a tree an earlier step mutated. Applied to self-heal-pr's verify it would hand back a clean checkout and pass on unmodified code — an infra red turned into a wrong green. Those need FileRef.

Deployment matrix

container backend (SUBSTRATE_BACKEND=off, deployed) substrate backend (=on)
item 1 fixes a live defect not reachable — the facade has no isWorkingDirFailure fold and never forwards cwd
item 2 as described above gains a retry on pool-admission refusals, pays a retried pin mismatch
probe cost one test -d per retryable step one fenced exec per retryable step; execUnderGrant rebuilds inside the fence anyway

wrangler.jsonc:90 is "off", so no deployed configuration exercises the substrate column today.

Also in here

A failed credential scrub now logs as well as appending to the error. Because CheckoutFailed became retryable, an attempt that fails there followed by one that succeeds would have carried that notice out of the run's final result into per-attempt history alone. The residue itself was never the risk — rm -rf ${targetDir} runs ahead of every clone, so a green run always ends scrubbed. The disclosure path was.

ensureWorkspace's docblock now states that the step's retryOn must list CheckoutFailed. The next run to adopt the primitive reads the primitive, not the three run files that already know.

Known follow-ups, deliberately not in this PR

  • Deterministic clone failures (bad sha, gone repo, pin mismatch) are retried where they cannot succeed. Classifying them terminal inside git.clone is the real fix.
  • Step headroom is a flat +120s while ensureWorkspace can run a clone plus install inside the same step. Pre-existing, predates this branch; two independent lenses flagged it.
  • RETRY_ON and its rationale are duplicated across three runs, edited in lockstep twice now.
  • Nothing holds the ADR's bare-workspace() list true; a test asserting the workspace() vs ensureWorkspace() split would.
  • ADR-0001 is still proposed while rule 5 governs merged behaviour. A governance call, not one to make inside this PR.

Verification

pnpm lint 0, pnpm typecheck 0, pnpm test 173 files / 2247 passed, 1 skipped. Test count is +2 over main: the six existing retryOn assertions only ever read a literal array out of step metadata, and the inline fake never retries, so two cases now drive rethrowForRetryPolicy itself — CheckoutFailed passes through under the runs' policy and returns NonRetryableError under one that omits it.

Closes #126.

`ensureWorkspace` re-clones a checkout the container threw away, from inside
the retryable step. Two things could stop it running, both left over from
before that recovery existed.

Classification order. The missing-working-directory throw embeds `cwd` and 200
chars of stderr in its message, and the `catch` below matched
`/timed?\s*out|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, since a command that outran its ceiling will
outrun it again. The step then died unretried and the rebuild never ran. The
throw now carries a symbol marker and the `catch` reads it ahead of the regex.
Mutation-checked: drop the marker branch and the new test reports
`expected 'ExecTimeout' to be 'ExecFailed'`.

`CheckoutFailed` in `RETRY_ON`. The rebuild's own clone runs in exactly the
weather that made the rebuild necessary. `sandbox.gitClone` fails
`CheckoutFailed`, which was in none of the three lists, so a transient clone
failure ended the step non-retryably one call short of the recovery it was
invoked to perform. Added to `offload-test`, `check` and `oxlint`, alongside
the `StepFailed` they already carry.

`ContainerLaunchFailed` and `ContainerBusy` reach the same place from
`ensureWorkspace`'s `acquire` and are deliberately left out: `acquire` already
waits to the layer's ceiling before raising `ContainerBusy`, so retrying here
stacks a second wait on top of admission control instead of recovering
anything. That coupling wants its own change.

ADR-0001 said the opposite of the platform. It claimed the container filesystem
is "shared state across durable steps" kept alive by `sleepAfter`. It now
records that all container disk is ephemeral, and carries a fifth rule with the
qualifier that matters: a re-clone restores the tree the spec describes, so it
is right for a suite or a build and wrong for a step reading a tree an earlier
step mutated. Applied to `self-heal-pr`'s verify it would pass on unmodified
code — an infra red turned into a wrong green. Those need `FileRef`.

pnpm lint, typecheck clean; test 173 files / 2245 passed, 1 skipped.
Classification moved to the throw site. `isWorkingDirFailure` now raises
`ExecFailed` directly instead of a symbol-marked `Error` the `catch` re-reads,
so the `catch` needs only `if (cause instanceof ExecFailed) return cause`. That
drops the hand-rolled discriminant, drops the duplicated `ExecFailed`
construction, and stops the new declarations from sitting between the long
`isWorkingDirFailure` doc block and the function it documents. Mutation still
holds: delete the early return and the test reports
`expected 'ExecTimeout' to be 'ExecFailed'`.

Why retrying `CheckoutFailed` is safe, written down. It is a catch-all over the
whole clone body and also wraps deterministic failures — a bad sha, a repo that
is gone, the facade's pinned-recipe mismatch at `sandbox-facade.ts:277-287`.
What keeps those off these steps is the sequence, not the tag: the only clone
reachable from an exec step is `ensureWorkspace` re-cloning the same repo and
sha the `checkout` step already cloned once, so a deterministic failure ends the
run earlier. `check` and `oxlint` get the same note plus the
`ContainerLaunchFailed` / `ContainerBusy` omission that only `offload-test`
carried.

Docs the change had left behind: the `RETRY_ON` header still said "both of
which"; `runs/README.md` still described the retried set as `ExecFailed` alone
and implied message-based classification; the ADR index row still summarised
four rules; and rule 5 landed with no Consequences and no revisit trigger, so
the probe cost, the widened retry set, the ten runs still on bare `workspace()`,
and the `FileRef` dependency are all recorded now.

lint, typecheck clean; test 173 files / 2245 passed, 1 skipped.
The sequence argument was wrong. It said the only clone reachable from an exec
step is a re-clone of a repo and sha `checkout` already cloned, so deterministic
clone failures could never reach the retry. That holds on the shared path and
not on the isolated one: `offload-test.ts:653` skips the `checkout` step
entirely when stages are isolated, and `:794-796` then performs the stage's
FIRST clone inside the retryable step. So a bad sha or a gone repo does get
retried there. The comment now says that plainly and owns the cost instead of
arguing it away.

"acquire already waits to the layer's ceiling" was wrong on both backends. That
came from the port's doc comment, not from either implementation. The container
backend's `acquire` is `Effect.succeed({ id: sandboxId })`
(`sandbox-cf.ts:464`) and cannot raise `ContainerBusy` at all; the facade's
refusal path is fail-fast and says so (`apps/substrate/src/facade.ts:163-165`).
The paragraph is deleted rather than repaired — the conclusion may stand, but
not for the reason given, and a wrong reason in a comment is worse than none.

Comment volume, per AGENTS.md. Dropped the test's four-line restatement of its
own name, the duplicated `CheckoutFailed` paragraphs in `check` and `oxlint`,
and six lines in `sandbox-cf.ts` that the one-line `catch` note and the named
test already carry. Also deleted the stale "The throw is classified by the
`catch` below", which the change had just made false.

`retryOn` had no behavioural test — the six assertions read a literal array out
of step metadata, and the inline fake never retries. Two cases now drive
`rethrowForRetryPolicy` itself: `CheckoutFailed` passes through under the runs'
policy, and comes back `NonRetryableError` under one that omits it.

ADR: added `vitest-shard` and `self-heal-pr` to the bare-`workspace()` list, and
scoped rule 5's empty-disk claim to the container backend, since the substrate
restores the tree inside the exec fence.

lint, typecheck clean; test 173 files / 2247 passed, 1 skipped.
Its opening line called every retried class the platform, twenty lines above an
admission that a bad sha and a gone repo ride `CheckoutFailed` into the same
list. A bad sha is neither. Dropped that half; "never a verdict" is the half
that is true and the half that matters.

The cost line said "bounded by the backoff", which understates it: the bound is
four clone attempts plus backoff, multiplied by the stages running at once on
the isolated path.
…g each other

A failed credential scrub now logs as well as appending to the error. Three
review lenses landed on the same gap independently: that notice reached the
operator only because `CheckoutFailed` ended the run, and this branch makes it
retryable, so an attempt that fails here followed by one that succeeds carries
the notice out of the final result and into per-attempt history alone. The
residue itself was never the issue — `rm -rf ${targetDir}` runs ahead of every
clone, so a later attempt clears it and a green run always ends scrubbed. The
disclosure path was the issue.

`apps/dispatcher/src/sandbox.ts` still said `sleepAfter` "buys durability across
normal inter-step gaps", which is the exact framing ADR-0001 rule 5 retires —
and the ADR cites that file, so following the citation landed a reader on the
retired claim.

`ensureWorkspace`'s own docblock said to call it inside the retryable step and
never mentioned that the step's `retryOn` has to list `CheckoutFailed`. The next
run to adopt the primitive reads the primitive, not the three run files that
already know, and would reproduce the bug this branch fixes.

Rule 5's substrate paragraph now names what that deploy gains and pays rather
than implying a no-op: one fenced exec per retryable step, a retry on pool
admission refusals, and a retried recipe-pin mismatch, since
`sandbox-facade.ts:274-287` folds both into `CheckoutFailed`. The `RETRY_ON`
note picks up the pin mismatch and the per-attempt token mint.

Also dropped two comments the policy forbids — one restating the line under it,
one narrating where the code used to live — and rewrapped a ragged paragraph.

lint, typecheck clean; test 173 files / 2247 passed, 1 skipped.
The sentence sat after one about the substrate backend, where `gitClone` sends
no clone and no token is minted at all. Only the container path calls
`resolveCloneToken` per attempt.

@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 — 💬 Comment

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

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

1. 💡 Suggestion — Avoid direct console.warn in runtime code

📍 packages/runtime-cf/src/sandbox-cf.ts:568-570

The credential-scrub failure path writes directly to the global console, unlike the surrounding structured error handling. This can produce noisy, uncorrelated production logs and is difficult for callers/tests to control. Route the warning through the runtime's existing logger or injectable diagnostics mechanism, preserving the redaction guarantees.

2. ⚠️ Warning — Clarify the effect of sleepAfter

📍 apps/dispatcher/src/sandbox.ts:57-62

The comment says 'sleepAfter = "10m"' “narrows the idle window,” but 'sleepAfter' is the idle grace period before the container sleeps; a 10-minute value defines or extends that period rather than narrowing it. Rephrase this as reducing the maximum time a container remains active, without implying that the setting makes reclamation less likely or the disk durable.

📋 View full logs & reviewed diff ↗

The rewritten comment said a LONGER idle window "narrows the idle window",
which contradicts itself inside one sentence. The PR-review bot read it right:
sleepAfter is the idle grace period before the container sleeps, and 10m
extends it. The sentence now says what the setting does — extends how long an
idle container stays awake — and keeps the claim that matters: it guarantees
nothing, because container disk is ephemeral on every path.
@nplusonedev

Copy link
Copy Markdown
Contributor Author

Validated both findings; one fixed, one refuted with evidence.

Finding 2 (sleepAfter wording) — correct, fixed in 7f01295. The sentence said a longer idle window "narrows the idle window", which contradicts itself. Verified against @cloudflare/containers@0.3.7: renewActivityTimeout() resets the deadline on every request path and isActivityExpired() compares against it, so sleepAfter is an idle grace period that activity restarts — a bigger value extends it. The comment now says that, and keeps the claim that matters: it is not a durability mechanism, because container disk is ephemeral on every path (ADR-0001 rule 5). The ADR's own line already carried the correct qualifier and needed no change.

Finding 1 (console.warn) — refuted. The flagged call sits inside an Effect.tryPromise({ try: async () => … }) callback — plain Promise context, where Effect.logWarning is not yieldable. This layer is constructed from bindings only and has no injectable logger; the same file uses Effect.logError where the surrounding code is an Effect pipeline (exposePort), and artifact-r2.ts:164 is the existing precedent for console.warn in exactly this position in a peer live layer. Routing it through a logger would mean restructuring the clone body out of tryPromise — a real change with its own blast radius, for a warn that carries no captured bytes and cannot throw.

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.

1 participant