Uh oh!
There was an error while loading. Please reload this page.
feat(scripts): check-stall-guard-budget — the guard's kill budget must clear its job's timeout-minutes by a stall window - #12847
Conversation
os-zhuang
commented
Aug 28, 2026
PM 复核:ACCEPT — #11916 / |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11916
run-with-stall-guard.mjsexists so a stalled job says it stalled instead of sittingin_progressuntil the job timeout. That guarantee holds only while the guard's own kill budget lands before its job'stimeout-minutes— and nothing read that relationship. Measured onf907fbe9e:git grep -l 'timeout-minutes' scripts/returns exactly one file,run-with-stall-guard.mjsitself, and both of its hits are prose in the header explaining this very relationship. So it was documented at the one place that could not enforce it. (The card said "no script reads it at all"; that is true of the code and false ofgrep -l, which returns 1.)The regression is invisible on green runs: the guard is observable only when it fires, so a budget that can no longer fire in time scores identically to one that works.
What landed
scripts/check-stall-guard-budget.mjswalks.github/workflows/**, finds every step invoking the guard, resolves the effective cap, and reds when it is not comfortably below the bindingtimeout-minutes. Wired aspnpm check:stall-guard-budgetand run inlint.ymlnext to the guard's own self-test.Measured on this branch: 28 workflow files, 51 jobs, 534 steps, 7 guard-wrapped steps, 0 violations — ci.yml x4 and coverage-nightly x1 at W=10 C=20 T=30 (slack 10, exactly one window), rerun-safety-nightly x2 at W=15 C=30 T=120 (slack 90).
The defaults are read from the guard, never copied
The window default and
DEFAULT_CAP_MULTIPLEare extracted fromrun-with-stall-guard.mjs(comments masked, because its header quotes both in prose). A second copy of2and10in the gate would be two numbers that must agree with nobody holding them to it — the very shape this gate closes, one level up. Rename either declaration and the gate refuses instead of judging against a stale default; a self-test case drives that.The criterion, and where its constant comes from
With W = window, C = effective cap, T = binding
timeout-minutes, p = prep before the step, s = how far into the step the output froze — the guard's clock starts at the last output (silentMs = Date.now() - lastOutputAt), so the verdict lands atp + s + C.C < T. No constant at all: atC >= Tthe guard cannot deliver a verdict in any scenario, not evenp = s = 0(a step that never emits a first line — a shape the guard's own self-test pins). A guaranteed silent no-op.T - C >= W.T - Cis the entire room forp + s. Requiring at least one full stall window of it says the guard must still be able to speak for a freeze that happens after the step has done some work — the mid-suite freeze it exists for.The unit of tier 2 is the step's own declared window, not a fraction someone picked. A taste-chosen ratio becomes a constant nobody can explain, and the point of this card is to make the invariant explainable. All three one-line breakages the card named are caught, and tier 1 alone would miss the first two:
timeout-minutes30 → 25--stall-minutes10 → 12What the measurement said, including a premise that did not survive
The card reasoned the real budget is
timeout-minutesminus checkout/install/build, "a fair chunk of a 30-minute job". That half is false, measured on GitHub's own runner timestamps for CI run 33135187774 (headf907fbe9e, the sixtestshards) — job start to guarded-step start, then the step:Prep is 33s–2m03s, at most 7% of the budget. The term that consumes it is the healthy run, up to 14m35s of the 30 — and that is exactly what no static gate can read. So the gate does not model it and encodes no number derived from it; it refuses the structural class instead.
The same measurement bounds the gate's promise, and the bound is in its header: on the ci.yml family
T - Cis 10 minutes while the worst shard spent 15m10s before it would have frozen, so a freeze at the very end of a healthy shard, on the deferred path only, still loses to the job timeout. The undeferred path covers it with 4.8m spare. That is a property of the checked-in values, not of this gate, and it is filed separately as #12846 — not addressed here.The gate says how to fix a red, and how not to
Per the card's ⛔, in the failure message itself: lower the guard budget (
--stall-minutes, or an explicit--stall-cap-minutes) or raise the job'stimeout-minutes. Deleting the guard from the step is not a remedy — it removes the only instrument that can report a stall and converts a red gate into precisely the unlabeledin_progress-until-timeout outcome the guard abolishes.Non-vacuity
"Every guard-wrapped step has headroom" is true of a tree with no guard-wrapped steps, and equally true of a broken selector that finds none — the success criterion and the total-failure criterion would be word for word identical. So
run()refuses (exit 2, never 0) on a missing workflow directory, no workflow file, no jobs, no guard invocation at all, unreadable guard defaults, or a--stall-minutes/timeout-minutesthat is not a positive number. The verdict line prints the population, and the self-test pins the sweep's hit count above zero on the fixture whose green it also asserts.Verification
node scripts/check-stall-guard-budget.mjs --self-test→ 40 assertions over real fixture trees on disk, driving the realrun(): both violation tiers red, the comment/self-test/post---selector negatives, the guard defaults proven read (a fixture declaring multiple 3 turns the same workflow red, and the fixture's 99-in-a-comment is masked), every refusal path, and the real repository green and non-vacuous.7683f3a1f:ci.yml'stestjobtimeout-minutes: 30 → 25— anchor counts moved both ways (30x5→x4,25x0→x1) and the blob changed (c4d019d34→cfaed7e7d) before any reading was taken. Gate went exit 1 namingci.yml:573on the headroom reason. Restore proved by state: blob back toc4d019d34= the HEAD blob,git diff HEADempty; gate green again. No build ordist/is involved — the gate reads workflow text.7683f3a1f: the 27 familiesnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackderives for this diff, plus the two convention-triggered ones (bare-root-worklist --self-test,check:pm-dispatch-gates) andcheck:nul-bytes— all green. Repo-widepnpm lintalso green (59s under the shared verify lock), so no narrowing was needed.check:type-check-debtis NOT MEASURED: its--re-measurehalf refuses on an unbuilt closure (56 workspace dependencies ... have no built type entry point on disk) — a refusal to measure, not a finding. Its declaration half (check:type-check-coverage, same script) is green, and the diff cannot move the ledger: no.tsfile, notsconfig, no packagetypecheckscript, and the roottsconfig.jsonsets noallowJs, so a.mjsfile is in no tsc program. CI runs it after its build step regardless.Bare-root verdict
Recorded per the #12074 obligation, measured in both directions rather than assumed:
node scripts/pm/bare-root-worklist.mjs --self-testis byte-identical before and after this change — 46 live rows, 39 recorded verdicts, none stale, missing or contradicted. The new gate's population constants (WORKFLOW_DIR = '.github/workflows',GUARD_SCRIPT = 'scripts/run-with-stall-guard.mjs') both carry a separator, soextractWatchHintsalready sees them — confirmed by the derivation naming this card viagate source '.github/workflows'andgate script. No ledger row is owed and none was added; the map stays shrink-only and untouched.Landing site: a new script, not an extension of
check:agent-test-spellingThe card floated extending that gate, since it already parses these command lines. Measured against its source: it is a text scan with no job or step model at all — it walks source files for bare
--separators and has no notion of a job, a step, ortimeout-minutes. Hosting this invariant there means building a workflow job/step model inside a gate whose header explicitly refuses to grow a second matching rule. A new script reusesshellCommands/commandWordsfromcheck-shard-attestation.mjs(the existing import edgecheck-required-contextsalready uses) and costs onelint.ymlstep — cheaper and clearer.No changeset
skip-changeset: rootscripts/,package.jsonscripts wiring and.github/workflows/only — nothing published changes.Generated by Claude Code