fix(scripts): pin the type-check re-measure to CI's own heap ceiling - #12957
Conversation
`check:type-check-debt --re-measure` ran tsc under V8's default old-space size, which V8 derives from the physical memory of the box the process starts on. So the gate measured a different world on every machine while only CI's verdict counts: three devs ran it on the agent container against a tree CI had already OOM'd on, and all three read green. A local pass was never a claim about CI, and nothing said so. The ceiling now lives in the gate's own harness, so every caller is CI-shaped without having to remember a flag. The number is read off CI, not off this container: run 33136681083, job `Type Check · debt ledger`, where V8 gave up at `Mark-Compact 4040.3 (4143.8) -> 4029.5 (4147.5) MB` -- bracketing that runner's old space into [4040, 4148] MB, a window whose only V8 default is 4096. The rule is a minimum, never a raise: min(CI's ceiling, this process's own limit, a caller's tighter NODE_OPTIONS cap). A box smaller than CI keeps its own lower ceiling, because promising V8 memory the box lacks converts a recoverable heap error into an exit-137 SIGKILL that carries no diagnostic (the lesson `packages/spec/tsup.config.ts` records from the build side). A caller's ROOMIER cap is refused: it would hand back the exact green-here-red-there reading this ceiling abolishes. The other direction is the one nothing else can catch, so the runner measures it on itself: if CI's own default ever drops BELOW the pinned constant, the pin has stopped describing CI, every local run is silently roomier than CI again, and `--re-measure` refuses on CI naming the reading to re-pin from. On every green run the same line prints the runner's own limit into the log, so the constant stays re-derivable from any CI log of this step instead of from archaeology through a failed job's GC trace. 14 new `--self-test` cases pin all of it, including the two controls that make the family able to fail at all: the roomier-caller refusal, and the same small-box reading OFF ci, which is an ordinary laptop and not a stale pin. Part of #12856 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw
…-shaped-heap-ceiling
|
ACCEPT — armed ( Reviewed against the diff and GitHub, ⛔ not against the report. Entry qualification: every check, not the required subset. 35 check runs at The load-bearing number was re-derived here rather than read off the report, because everything else rests on 4096 being CI's actual ceiling:
And the direction argument is the right one: "If 4096 is wrong it is wrong downward, which is the only safe direction here." A pin above CI's ceiling would be worse than none, so an error that can only go low is an error this card can absorb. The rule is ⭐ The best thing in this PR is the direction nothing else could catch. If CI's own default ever drops below the pinned constant, the pin silently stops describing CI and every local run is roomier again — so the runner measures it on itself and The pin was proven to reach tsc, not assumed. The self-test can only pin the env string, so the mechanism was driven end to end through the gate's own spawn path: at a temporary 256 MB, Three ablations, all self-test RED / production GREEN (drop the cap → 3 failures; disarm the stale-pin refusal → 1; prepend rather than append so V8 obeys the caller → 9). Production green under all three is not a weakness — it is this defect class's signature, and it is why the self-test is the only instrument that can see it. Two controls sit inside the new cases: the roomier-caller refusal, and a small-box reading off CI, which must stay green because an ordinary laptop is not a stale pin. Read per entry, not off the summary:
Generated by Claude Code |
Fixes #12856
check:type-check-debt --re-measureran tsc under V8's default old-space size, which V8 derives from the physical memory of the box the process starts on. So the gate measured a different world on every machine, while only CI's verdict counts. Three devs ran it on the agent container against a tree CI had already OOM'd on, and all three read green. That asymmetry is the defect, and nothing in the gate said so.The ceiling now lives in the gate's own harness, applied to every tsc the gate spawns, so every caller is CI-shaped without having to remember a flag.
The number comes from CI, not from this container
The card's binding constraint is that the value must be CI's, and that a pin above CI's ceiling is worse than none. Read off the runner itself — run 33136681083, job
Type Check · debt ledger, at 6d097a6, Node v22.23.2, the run this card was split out of:V8 gave up with 4040.3 MB live and 4147.5 MB committed, which brackets that runner's old-space limit into [4040, 4148] MB. 4096 is the only V8 default that lands in the window, and both sides agree on the offset:
getHeapStatistics().heap_size_limitreports the old space plus a fixed ~48 MB of other spaces (measured: 8240 reported for 8192, 560 for 512), so a 4096 old space reports 4144 and commits the 4147.5 above.If 4096 is wrong it is wrong downward, which is the only safe direction here: the number's whole job is to be no higher than CI's.
The rule is a minimum, never a raise
min(CI's ceiling, this process's own limit, a caller's tighter NODE_OPTIONS cap):packages/spec/tsup.config.tsalready records from the build side). Lower than CI is safe anyway: heap headroom is monotone.The opposite direction is the one nothing else can catch, so the runner measures it on itself: if CI's own default ever drops below the pinned constant, the pin has stopped describing CI, every local run is silently roomier again, and
--re-measurerefuses on CI naming the reading to re-pin from. On every green run the same line prints the runner's own limit into the log, so the constant stays re-derivable from any CI log of this step rather than from archaeology through a failed job's GC trace.Verification
Union at head
3dac37178(a clean tree;origin/mainmerged in this branch).The pin reaches tsc — proven, not assumed. The self-test can only pin the env string, so the mechanism was measured end to end through the gate's own spawn path: with the constant temporarily at 256,
--re-measureproduced CI's exact signature —— tsc dying at 254.5 MB under a 256 MB pin. The mutation was confirmed on disk before the reading (anchor gone, injected text present, blob differs from HEAD) and the restore confirmed after (
git diff HEADempty).The new behaviour can fail. Three ablations of the added rule, each confirmed on disk and restored, run against both halves of the gate:
--self-testProduction is green under every one of them — this rule's defect class is invisible to a clean tree, which is why the 14 new cases exist and why the two controls are in them (the roomier-caller refusal, and the same small-box reading off CI, which is an ordinary laptop and not a stale pin).
Gates, re-derived from the actual diff with
node scripts/pm/dispatch-gates.mjson a tree atorigin/main(no stale-tree warning), all exit 0 with the gate's own verdict line read:check:agent-test-spelling·check:bash32-floor·check:cli-command-ids·check:cross-package-test-inputs·check:entry-guard·check:parse-guard·check:pnpm-filter-targets·check:watch-hint-literal·check:type-check-coverage·check:nul-bytes·scripts/check-ci-filter-parity.mjs·scripts/check-cross-package-test-inputs.mjs·scripts/check-self-test-wired.mjs· and the two convention-triggered gates the derivation adds for a gate-script edit:scripts/pm/bare-root-worklist.mjs --self-testandcheck:pm-dispatch-gates(834 cases).pnpm check:type-check-debtat3dac37178: self-test 43 re-measure cases (was 29), then31 ledger entr(ies) re-measured in 462.5s, 1570 raw tsc error(s) total, none above its recorded numberwithsurplus: none — every entry sits exactly at its measurement, i.e. read per entry and not off the summary: no entry carries slack. Full-repopnpm lintexit 0 (69s).Not in scope
The ledger numbers and the surplus policy are untouched (#12799 is on hold on this same script, #12511 is undispatched). Path derivation and
check:dispatcher-error-vocabularybelong to #12850.package.jsonis untouched — the ceiling lives in the.mjs, so thecheck:type-check-debtalias needed no change and PR #12942 keeps the scripts block.No changeset
Root
scripts/tooling; nothing published changes. Receipt: the last 11 commits touching only rootscripts/carry zero changeset files (ce83058ce,e5f66214a,4d1142753,44564d2fb,4a7e1b07e,3ece13047,6f0fec3d0,f907fbe9e,ce3ea0600,5ced18ac0,aacc248a8), andlint.ymlnames "this PR edits a CI-internal script" as the textbookskip-changesetcase. Label applied.Generated by Claude Code