Skip to content

fix(smoke): split staging gate advisory-vs-fatal + O(1) snapshot sweep - #218

Merged
unforced merged 1 commit into
mainfrom
ag-fix-staging-gate-advisory
Jul 26, 2026
Merged

fix(smoke): split staging gate advisory-vs-fatal + O(1) snapshot sweep#218
unforced merged 1 commit into
mainfrom
ag-fix-staging-gate-advisory

Conversation

@unforced

Copy link
Copy Markdown
Contributor

Why

deploy-staging.yml (auto-runs on every push to main) has been red for 9 days — 25+ consecutive runs, the identical single failure each time:

SMOKE FAILED — 163 pass, 1 fail
FAIL  snapshots: live restore round-trip threw (non-fatal — sections continue) — TimeoutError

Two independent defects compounded:

  1. A slow section hard-blocked prod. fail() increments a counter that process.exit(failures === 0 ? 0 : 1) reads. The §17 catch called fail(), 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 to main don't deploy. Three finished changes sat undeployed.
  2. The timeout was a fleet-size timebomb. The §17 snapshot sweep snapshotted every staging vault to R2 (O(fleet)); the fleet grew 167 → 260 as smoke debris accumulated and crossed the client timeout. Because the sweep runs first, the restore assertion behind it went dark for 9 days.

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:

  • FATAL — gates the deploy (exit 1). The checked thing is broken: a wrong answer is the failure. Contract shapes, status codes, auth refusals, scope boundaries, error types, note counts. Every assert() in the smoke is fatal. Ambiguous → fatal (safe default).
  • ADVISORY — loud, but never gates (exit still 0). We couldn't verify it right now: a client-side timeout or network-unreachable while driving live third-party / fleet infra (Workers AI, R2, a cold DO, a fleet sweep). A timeout can't tell slow-but-healthy from stuck-broken, so it's never proof of breakage — only proof we didn't get to check. 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 catch routes through liveCatch() — timeout/unreachable → advisory, everything else → fatal. The contract checks inside each section are assert()s, and assert() never throws (it records a fatal fail() 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 shared try/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:

  1. Advisories never gateexitCode is a function of fail alone.
  2. Advisories can't hide a fatalfail > 0 exits 1 and reads FAILED no matter how many advisories rode along (tested at 0/1/5/99).
  3. Advisories are never silent — any advisory shows in the headline and on its own re-surfaced block; a run carrying advisories never reads as clean green. 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-run now takes an optional ?vault=<name> that scopes runSnapshotSweep to 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 sets onlyVault, 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 new smoke-report invariant tests)
  • workers/identity bun run typecheck · full identity vitest under workerd — 1050 tests across 38 files, incl. the new onlyVault sweep-scope + ?vault= trigger tests (run with singleWorker to 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.ts unwired; the CI gate keys off the smoke's exit code; no throw inside any wrapped section; account-MCP create-refusal is an assert(); arrivalVault is a live, section-required variable.

Reasoned, NOT executed: scripts/smoke-staging.ts was 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

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
@unforced
unforced merged commit 7c6b305 into main Jul 26, 2026
3 checks passed
@unforced
unforced deleted the ag-fix-staging-gate-advisory branch July 26, 2026 22:58
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>
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