fix(smoke): split staging gate advisory-vs-fatal + O(1) snapshot sweep - #218
Merged
Conversation
deploy-staging.yml has been red for 9 days (25+ runs, identical failure):
the §17 snapshot SWEEP timed out because it snapshotted EVERY staging vault
to R2 and that fleet grew 167→260 (O(fleet)); the section's catch called
fail(), and fail() gates the exit code — so a slow-infra timeout hard-blocked
prod deploys, and (because the sweep runs first) the restore assertion behind
it went dark for 9 days. Three finished changes sat undeployed.
Two halves:
(a) Exit semantics — advisory vs fatal, as a CATEGORY rule (scripts/smoke-report.ts,
a pure, unit-tested module):
- FATAL gates (exit 1): the thing is BROKEN — a wrong ANSWER. Every assert()
in the smoke is fatal. Safe default: ambiguous stays fatal.
- ADVISORY is loud but never gates: we COULDN'T VERIFY it right now — a
client-side timeout / network-unreachable while driving live third-party
or fleet infra. isUnverifiable() is the ONLY downgrade path; any other
throw (TypeError, bad-shape parse) stays fatal.
The section catches route through liveCatch(): timeout/unreachable → advisory,
everything else → fatal. Contract checks are assert()s (which never throw), so
a wrong answer — e.g. account-MCP failing to REFUSE a vault creation — stays
fatal regardless of the wrapper. Three invariants are pinned in
test-bun/smoke-report.test.ts: advisories never gate, can NEVER hide a fatal
(fail>0 exits 1 even with advisories piled on), and are never silent.
(b) The slowness — scope the sweep to the run's OWN vault. /__test/snapshot-run
takes ?vault=<name>; smoke §17 passes its fresh arrival vault, so the sweep is
O(1) not O(fleet), removing the fleet-size dependency entirely. The nightly
cron is untouched (never sets onlyVault → full-fleet sweep). The timeout was
NOT raised (that was the last attempt, and why this became a 9-day outage).
Once the sweep is fast the restore assertion runs for the first time in 9 days;
if it fails on staging that is a real finding to report, not this change's bug.
Verified locally: root typecheck, root bun test (177, incl. smoke-report 10),
identity typecheck, identity vitest (1050 across 38 files, incl. snapshot scope
+ trigger tests). Did NOT run smoke-staging.ts (creates live debris) — live
behavior is reasoned from code.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
This was referenced Jul 26, 2026
This was referenced Jul 26, 2026
unforced
added a commit
that referenced
this pull request
Jul 27, 2026
…24) (#225) deploy-staging.yml went red again after #218 — this time FOUR sections earlier. §14, the usage rollup (POST /__test/usage-run), fell off the same O(fleet) cliff the snapshot sweep hit: it enumerated EVERY staging vault, and with the fleet at ~268 (nothing reclaims smoke debris — cloud#224 item 1) the rollup outgrew the runtime's fetch timeout. §14 had NEITHER an explicit AbortSignal NOR a liveCatch, so the throw escaped to main().catch() as a bare, section-less DOMException (empty stack) — pinning it down meant diffing two CI logs to see which section died. Three parts (cloud#224 items 2 + 3): 1. Scope the rollup the way #218 scoped the sweep. /__test/usage-run now takes ?vault=<name>; smoke §14 passes its own fresh arrival vault, so the rollup is O(1), not O(fleet). The param is read AFTER the exposeDevLinks 404 guard (prod gate fully precedes it), the SQL is parameterized (WHERE name = ? LIMIT 1 vs the unchanged ORDER BY name LIMIT ?), and the no-param path is byte-for-byte the old behavior — so the nightly USAGE_CRON and every ops.ts caller are untouched. 2. Make §14 fail legibly. The trigger now carries an explicit, generous timeout and routes through liveCatch: a genuine stall is ADVISORY (couldn't-verify, does not gate), any other throw stays FATAL, and either way it now carries a section label instead of an anonymous crash. 3. Apply cloud#221's own lesson — don't couple a slow setup call to the assertions behind it. The rollup TRIGGER and the console-render ASSERTIONS now live in SEPARATE blocks: an unverified rollup skips the dependent surface checks with a named advisory rather than blinding them. This deliberately does NOT reproduce the shared-try coupling I flagged on §17. The timeout was NOT raised anywhere; scoping is what removes the fleet-size dependency (same fix shape as #218). Does not touch §17, does not reclaim staging data (that is #224 item 1, owned separately). Verified locally in an isolated worktree (app/hub/vault siblings symlinked): root typecheck exit 0; root `bun test` 177 pass / 0 fail (the single env-only failure is spa-build-source.test.ts cloning ../parachute-app, absent in the worktree — 13/0 once the sibling is linked, and the SPA-build path is untouched by this diff); identity vitest 1053 pass / 0 fail across 38 files (1050 + 3 new scope tests), run under singleWorker because the shared box's other-actor workerd exhaust ephemeral ports under the default parallel pool (environmental, same class as #218's run). Did NOT run smoke-staging.ts (creates live debris) — live behavior is reasoned from code. Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
deploy-staging.yml(auto-runs on every push tomain) has been red for 9 days — 25+ consecutive runs, the identical single failure each time:Two independent defects compounded:
fail()increments a counter thatprocess.exit(failures === 0 ? 0 : 1)reads. The §17 catch calledfail(), so "non-fatal — sections continue" only meant the exception didn't abort the rest of the run — it was still fatal to the exit code. One timeout = red gate = merges tomaindon't deploy. Three finished changes sat undeployed.The rule this encodes — advisory vs fatal, as a category, not a list
Lives in
scripts/smoke-report.ts(pure, side-effect-free, unit-tested). The next person classifies a new section by this rule without asking:assert()in the smoke is fatal. Ambiguous → fatal (safe default).isUnverifiable()is the only downgrade path; any other throw (TypeError, bad-shape parse) stays fatal.Why this holds per-section without a hand-maintained list: each live section's
catchroutes throughliveCatch()— timeout/unreachable → advisory, everything else → fatal. The contract checks inside each section areassert()s, andassert()never throws (it records a fatalfail()and continues). So a wrong answer — e.g. account-MCP failing to refuse a vault creation (the case flagged as a genuine contract break) — stays fatal regardless of the sharedtry/catch. Only a couldn't-complete becomes advisory. A section that times out before reaching an assertion simply leaves it unverified (correctly advisory); one that fails an assertion then times out records the fatal first, and invariant 2 keeps it gating.Three invariants, pinned in
test-bun/smoke-report.test.ts:exitCodeis a function offailalone.fail > 0exits 1 and reads FAILED no matter how many advisories rode along (tested at 0/1/5/99).SMOKE PASSED — 163 pass, 0 fail, 2 advisory (UNVERIFIED — see below).Section classification (all via the same rule): §17 snapshots, §18 voice, §18b semantic, §18c tickets, §16/19 mock-E2E, §account-mcp, tier-change — for all of them a wrong answer is fatal (
assert()), and only a live-infra timeout is advisory. That's the category rule applied uniformly, not a per-section allowlist.The slowness — scoped, not bumped
/__test/snapshot-runnow takes an optional?vault=<name>that scopesrunSnapshotSweepto a single vault. Smoke §17 passes its own fresh arrival vault (which the section already depends on for the restore round-trip), so the sweep is O(1), removing the fleet-size dependency entirely. The nightly cron is untouched — it never setsonlyVault, so it still sweeps the whole fleet. The 120s timeout was deliberately NOT raised — raising it is what the last attempt did and why this became a 9-day outage.scripts/staging-sweep.ts(the debris cleaner) is still wired into no workflow, so the fleet still grows — but with the sweep scoped, fleet size no longer gates. Wiring that cleaner is a recommended follow-up, not part of this gate fix (it mutates the fleet and deserves its own review).Expect this on the first post-merge staging run
Once the sweep is fast, the restore assertion runs for the first time in 9 days. It may fail — if it does, that is a real finding to report, not a defect in this change.
Verified vs reasoned
Verified (ran locally, all green):
bun run typecheck(root) ·bun run test(root — 177 tests incl. the 10 newsmoke-reportinvariant tests)workers/identitybun run typecheck· full identity vitest under workerd — 1050 tests across 38 files, incl. the newonlyVaultsweep-scope +?vault=trigger tests (run withsingleWorkerto sidestep a sibling worktree saturating workerd machine-wide; the multi-worker crashes were 100% environmental — zero test-logic failures; the affected file also passes standalone).Verified (read against ground truth): rc bump 125→126;
staging-sweep.tsunwired; the CI gate keys off the smoke's exit code; nothrowinside any wrapped section; account-MCP create-refusal is anassert();arrivalVaultis a live, section-required variable.Reasoned, NOT executed:
scripts/smoke-staging.tswas not run (it creates real debris against live staging, per instruction). The live behavior — sweep now O(1), restore finally exercised — is reasoned from the code, not observed.Scope
Identity worker + smoke scripts only; no
workers/vault, no@openparachute/core. rc.125 → rc.126.🤖 Generated with Claude Code
https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB