Skip to content

fix(ci): stall guard reads real liveness — --log-order=stream under every guard-wrapped turbo, guard refuses grouped, shard timings re-measured - #11868

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-11808-stall-guard-liveness
Aug 24, 2026
Merged

fix(ci): stall guard reads real liveness — --log-order=stream under every guard-wrapped turbo, guard refuses grouped, shard timings re-measured#11868
os-steve merged 2 commits into
mainfrom
claude/issue-11808-stall-guard-liveness

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#11808

What was wrong

scripts/run-with-stall-guard.mjs measures output flushes. Turbo's default log order in CI is grouped — a task's output is buffered and flushed only when the task ends — so a shard whose tail is a single task longer than --stall-minutes emits zero bytes while perfectly healthy and is killed at exit 75 by construction. Three occurrences in one shift, two lanes (PR #11785 twice on Test Core (1/6); PR #11845 / #11844 with Test Files 173 passed (173) arriving in the very flush the kill forced, stamped ~40ms after it). The red reproduces on rerun, so standard flake triage reads it as "not a flake, therefore the diff" — the exact false trail.

What this PR does

Correction (a) from the card — turbo under the guard now streams (--log-order=stream restores the pre-grouped behaviour the guard was written against):

  • .github/workflows/ci.yml — Test Core shard invocation and the dogfood invocation (also a guard-wrapped turbo: a single task whose output would flush only at its end).
  • .github/workflows/rerun-safety-nightly.yml — both guard-wrapped full-suite passes.
  • The two temporal legs and coverage-nightly wrap pnpm, not turbo; pnpm's recursive runner streams with per-package prefixes (measured below) — untouched.

The class is closed, not just the instances: the guard now refuses to wrap a turbo invocation that does not pin --log-order=stream (exit 1 at startup, before anything spawns, with the mechanism named in the error). Five new --self-test cases pin both directions (refusal on missing flag and on explicit --log-order=grouped; acceptance of =stream and split --log-order stream; no false trip on turbo inside a flag value).

Correction (b) (a second liveness probe) is deliberately NOT here — filed as #11855 with the naive-CPU inversion hazard written into the card: a probe that reads CPU ticks as "alive" never fires on sync-spinning hangs (half the #4250 population), converting "kills healthy suites" into "never fires", which no green can distinguish from success. Route (a) is smaller, reversible, and measured sufficient (below).

scripts/test-shard-timings.json refreshed per its own provenance recipe (full cold local re-measure on a 4-vCPU box, --concurrency=4 --summarize, generator merge — six run summaries from one tree, disjoint filters so every test task is a genuine MISS and skippedAsCached stays empty). @objectstack/cli re-measured (Zone-2 falsification of the card's ~710s): 458.2s under the dataset's own conditions (concurrency-4 chunk on this container), vs 392.11s recorded and vs >601s alive-at-kill on the actual CI runner (the card's ~710s was a solo run under uncontrolled contention — the claim's direction holds, its magnitude was environment-specific). What the dataset needs is relative weight on one box, one day, one method — which this refresh restores for every package at once.

Measurements (all on this branch's tree; environment: 4-vCPU container, turbo 2.10.10, CI=true GITHUB_ACTIONS=true)

1. Grouped log order is the CI default and buffers until task end — chunk-arrival instrument on turbo run test --filter=@objectstack/sdui-parser (single task, cold): run header at t=808ms, then silence until one burst at t=4037ms on a 4071ms run (maxGap 3229ms ≈ the task's whole runtime, 86% of bytes in the final second).

2. --log-order=stream restores real liveness under the same env — same command + flag: 13 chunks spread across the run, maxGap 957ms.

3. Fail-before / pass-after at 1/100 scale — the guard wrapping a healthy @objectstack/formula suite (~15s) with --stall-minutes 0.1 (6s):

  • grouped: exit 75, banner last line : ::endgroup:: — byte-identical signature to the CI occurrence (the flushed group of the previous task is the last thing seen);
  • --log-order=stream: exit 0, no stall, summaries stream with @objectstack/formula:test: prefixes.

4. Full-scale dress rehearsal of the exact new Test Core wiring — guard + --stall-minutes 10 + --log-order=stream --summarize --concurrency=4 (same NODE_OPTIONS report plumbing) over a 19-package chunk anchored on @objectstack/cli — the composition that killed CI: zero stalls, exit 0, 8m08s wall, 19/19 test tasks exit 0; cli ran a 429s solo tail after the last other task ended, streaming per-file lines throughout, and the guard stayed silent. Under stream order the kill condition is no longer "a task longer than the budget" but "a single test file silent for 10 minutes" — which is a genuine hang, i.e. exactly what the guard exists to kill.

5. The completeness guard still attributes under stream ordercheck-test-completeness.mjs against the dress rehearsal's real stream-order log: OK (19 of 19 scheduled package(s) reported, 0 had nothing to run, 0 never reached; 4268 test(s) declared and all accounted for), exit 0. (Stream attribution is a pinned first-class branch of its parseSummaries — the <pkg>:test: prefix — with its own self-test cases; this run is the live control.)

6. Refusal ablation (non-vacuous, both directions proven on disk) — with the fix committed, the refusal call was neutralized (if (false && …); anchor count 1→0, mutant count 0→1, node --check green so the red is not a load error), self-test went red on exactly the 3 refusal-direction cases and nothing else; restore proven byte-identical (sha f6712480e8df pristine == restored == HEAD), self-test back to 27/27. The mutation/restore script carried trap restore EXIT INT TERM.

7. pnpm's recursive runner streams — chunk instrument on pnpm --filter A --filter B test (the temporal-leg shape): 12 chunks, maxGap 691ms — which is why the pnpm-wrapped guard call sites need no change.

Why the guard fired at all under grouped in the fail legs but CI's shards were green for months: on a full-list shard, other tasks end and flush regularly; only an affected-set shard composed as {one heavy package + small ones} produces a solo tail past the budget — exactly the card's composition analysis.

Gates

Derived via node scripts/pm/dispatch-gates.mjs (no hand-fed paths; it reads the change set itself from the merge base) and run at d81806a4f (the final commit): all 22 derived families exit 0, exits captured pre-pipe — check:agent-test-spelling, check:cross-package-test-inputs, check:entry-guard, check:node-version, check:parse-guard, check:pnpm-acquisition, check:pnpm-filter-targets, check:required-contexts, check:shard-attestation, check:stall-guard (✓ 27 case(s) passed), check:workflow-status-functions, check-aggregator-roster, check-ci-filter-parity, check-step-collectors, docs-audit/check-drift-comment, pm/ci-failure --self-test, check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, pm/release-rehearsal-clone --self-test. check:nul-bytes: OK (scanned 6598 text file(s) … no raw ASCII control bytes). Scoped eslint: population per eslint.config.mjs is **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} — of this diff only scripts/run-with-stall-guard.mjs is in it (workflows/JSON are outside the population); --format json reports 1 file, 0 errors, 0 warnings, exit 0; the config runs no type-aware linting (its own note: no parserOptions.project anywhere), so this diff cannot move an untouched file's verdict.

Timings-refresh provenance caveat, stated rather than hidden: the re-measure ran as six disjoint-filter chunk invocations rather than one monolithic run (bounded shared-container lock holds), so per-package contention varies: @objectstack/spec/@objectstack/objectql re-ran as a 2-task leg (a mid-chunk flake had canceled them: the #10293 race-reproduction fixture in example-showcase failed to reproduce its race under load, passed clean on solo re-run) and example-showcase's recorded 22.87s is a solo-run sample. Heavy tails run near-solo in any methodology (cli's 458s window included a 429s solo tail — same shape in CI shards); the dataset is a relative-balancing input that medians toward truth across refreshes.

This PR touches only CI workflows, a CI guard script, and a generated CI dataset — nothing published: skip-changeset.

For the unlock consumers

#11635 / PR #11785: unlock is merge main (with this landed) and let CI re-run — no code change owed there. PR #11845 / #11844 (domain:services): same; their Test Core reds carry no information about their diffs.


Generated by Claude Code

…stream under every guard-wrapped turbo
The guard measures output flushes; turbo's CI default is grouped log order,
which flushes a task's output only when the task ends. A shard whose tail is
one task longer than --stall-minutes was therefore killed as a stall while
perfectly healthy. Pin --log-order=stream at all four guard-wrapped turbo
call sites, and make the guard refuse a turbo invocation without the flag so
the class cannot regress silently (self-test pins both directions).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
Regenerated per the dataset's own provenance recipe (4-vCPU box,
--concurrency=4 --summarize, generator merge across six run summaries with
disjoint filter sets — every test task a genuine MISS, zero cached). cli
392.11s -> 458.15s on this box (>601s alive-at-kill on the CI runner), the
~2x under-weight that was skewing every shard split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 24, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM review, domain:devx seat, session session_015ahemw8RcTgqtxrj15PEZx. Verified against d81806a4f (merge-base a1c804bc9, computed as git merge-base).

This was the shift's pm:blocking card — a deterministic red on innocent diffs that had already hit three PRs across two lanes. It is fixed, and fixed wider than it was asked to be.

It closed the class, not the instance — verified by classifying every occurrence

The card named one call site. I counted --log-order=stream and then looked at what the count contained, because a grep count is not a reading:

filelinekind
ci.yml541comment (states the mandate)
ci.yml563wiring — Test Core
ci.yml1135wiring — dogfood
rerun-safety-nightly.yml86comment
rerun-safety-nightly.yml93wiring
rerun-safety-nightly.yml117wiring

Four wiring sites, exactly as reported, and the merge-base carries zero — so the fix is real rather than pre-existing. The three sites beyond the card's would each have produced the same silent kill on a different lane.

⭐ The part that makes it not decay: the guard now refuses

turboLogOrderViolation (:155) is checked at :167, pre-spawn, exit 1. So a future author cannot reintroduce the defect by dropping a flag — the instrument now enforces its own precondition instead of documenting it. That is the "declared means enforced" discipline this repo keeps having to re-derive, applied to the guard's own contract, and it is the difference between a fix and a fix that lasts one refactor.

Pinned by 5 new self-test cases, and the refusal ablation is non-vacuous in the way that matters: mutation proven on disk (anchor 1→0, mutant 0→1), node --checkgreen on the mutant so the red could not be a load error, self-test red on exactly the 3 refusal cases, restore byte-identical by sha256 (f6712480e8df pristine == restored == HEAD).

The fences held

The card's own figure corrected, honestly

Zone 2 asked you to re-measure the ~710s. You got 458.2s on a 4-vCPU container under the dataset's own concurrency-4 conditions, and said plainly: "direction held, magnitude was environmental" — the ~710 was solo under uncontrolled contention. The CI runner's >601s alive-at-kill is the figure that actually matters and it stands. Recording a smaller number than the card claimed, with the reason, is worth more than confirming it.

test-shard-timings.json: @objectstack/cli 392.11 → 458.15, from a full cold re-measure (71/71 packages, zero cached) — verified in the file.

Evidence quality

Fail-before/pass-after with the real signature. At 1/100 scale (--stall-minutes 0.1 over a healthy @objectstack/formula): grouped → exit 75 with banner last line : ::endgroup::byte-identical to the CI occurrence's signature — and with --log-order=stream in the same window → exit 0, zero STALL lines. Reproducing the failure's signature, not just an exit code, is what makes this the same bug.

The dress rehearsal is the reading I'd have asked for. The exact new Test Core wiring (guard, 10-min budget, stream, summarize, NODE_OPTIONS plumbing) over a 19-package cli-anchored chunk: zero stalls, exit 0, 8m08s, cli solo tail 429s streamed throughout — the precise shape that was being killed. Plus the completeness guard on that real stream log: 19 of 19 scheduled package(s) reported … 4268 test(s) declared and all accounted for.

The flake was handled by rule, not by judgment call. The showcase #10293 race fixture failed to reproduce its race under load and passed solo; its contended sample was discarded by the generator's own failed-task rule and the solo 22.87s recorded, with the caveat declared in the PR body. Discarding a sample under a pre-existing rule and saying so is the opposite of chasing a green.

On landing — the unlock consumers

Recorded so this does not get lost: #11635 is pm:blocked on this with PR #11785 open, reviewed and ACCEPTed — its unlock is merge main and let CI re-run, no code change owed on that branch. #11782 is serial-queued behind #11785. I will say so on both when this lands.

Flipping to ready; arming once every check run completes green.

⚠️ Note for whoever reads this PR's own CI: its workflow edits apply to its own pull_request runs, so its Test Core is already running in stream mode — this PR is its own first live test.


Generated by Claude Code

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

Labels

ci/cdsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-steve@claude