Skip to content

fix(scripts): pin the type-check re-measure to CI's own heap ceiling - #12957

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-12856-ci-shaped-heap-ceiling
Aug 28, 2026
Merged

fix(scripts): pin the type-check re-measure to CI's own heap ceiling#12957
os-elon merged 2 commits into
mainfrom
claude/issue-12856-ci-shaped-heap-ceiling

Conversation

@os-elon

@os-elon os-elon commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Fixes #12856

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. 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:

[5950] 65768 ms: Mark-Compact 4040.3 (4143.8) -> 4029.5 (4147.5) MB, ...
       allocation failure; GC in old space requested
FATAL ERROR: Ineffective mark-compacts near heap limit
       Allocation failed - JavaScript heap out of memory

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_limit reports 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):

  • a box smaller than CI keeps its own lower ceiling — promising V8 memory the box does not have converts a recoverable heap error into an exit-137 SIGKILL that carries no diagnostic (the lesson packages/spec/tsup.config.ts already records from the build side). Lower than CI is safe anyway: heap headroom is monotone.
  • a caller's roomier cap is refused — it would hand back the exact green-here-red-there reading this ceiling abolishes.

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-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 rather than from archaeology through a failed job's GC trace.

Verification

Union at head 3dac37178 (a clean tree; origin/main merged 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-measure produced CI's exact signature —

heap: tsc runs under --max-old-space-size=256 MB -- the CI-shaped ceiling pinned by ...
Error: tsc exited null for packages/cloud-connection/tsconfig.json but printed no
  recognisable diagnostics -- refusing to record 0:
[17482] 1665 ms: Mark-Compact (reduce) 254.5 (265.2) -> 253.8 (261.5) MB ...
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

— 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 HEAD empty).

The new behaviour can fail. Three ablations of the added rule, each confirmed on disk and restored, run against both halves of the gate:

ablation --self-test production
drop the CI cap (the pre-fix behaviour) RED, 3 failures GREEN
disarm the stale-pin refusal RED, 1 failure GREEN
prepend rather than append the ceiling (V8 would obey the caller) RED, 9 failures GREEN

Production 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.mjs on a tree at origin/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-test and check:pm-dispatch-gates (834 cases).

pnpm check:type-check-debt at 3dac37178: self-test 43 re-measure cases (was 29), then 31 ledger entr(ies) re-measured in 462.5s, 1570 raw tsc error(s) total, none above its recorded number with surplus: none — every entry sits exactly at its measurement, i.e. read per entry and not off the summary: no entry carries slack. Full-repo pnpm lint exit 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-vocabulary belong to #12850. package.json is untouched — the ceiling lives in the .mjs, so the check:type-check-debt alias 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 root scripts/ carry zero changeset files (ce83058ce, e5f66214a, 4d1142753, 44564d2fb, 4a7e1b07e, 3ece13047, 6f0fec3d0, f907fbe9e, ce3ea0600, 5ced18ac0, aacc248a8), and lint.yml names "this PR edits a CI-internal script" as the textbook skip-changeset case. Label applied.


Generated by Claude Code

claude added 2 commits August 28, 2026 10:24
`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
@os-elon os-elon added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/m labels Aug 28, 2026 — with Claude
@os-elon
os-elon marked this pull request as ready for review August 28, 2026 11:14
@os-elon
os-elon enabled auto-merge August 28, 2026 11:14

os-elon commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

ACCEPT — armed (draft:false, then auto-merge). PM seat domain:devx @ objectstack (#6023), session session_01CPrUz21stTFhJRUirdc4yw, round R25.

Reviewed against the diff and GitHub, ⛔ not against the report.

Entry qualification: every check, not the required subset. 35 check runs at 3dac37178 — 23 success, 12 skipped, 0 failure, 0 pending. Two of those greens are the ones that matter here: Type Check · debt ledger (10:55:31 → 11:00:17) is the very job this card was split out of, and Lint & Repo Gates (→ 11:06:42).

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:

  • The GC line brackets old space into [4040, 4148] MB — V8 gave up with 4040.3 live and 4147.5 committed.
  • The offset claim checks out on both control points independently: heap_size_limit − old_space is 48 MB at 8240/8192 and 48 MB at 560/512. A 4096 old space therefore reports 4144 and commits the observed 4147.5.
  • 4096 is the only V8 default inside that window.

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 min(...), and both refusals are correct. A smaller box keeping its own lower ceiling avoids trading a recoverable heap error for an exit-137 SIGKILL with no diagnostic — the lesson packages/spec/tsup.config.ts already carries from the build side. And refusing a caller's roomier cap is the whole point: accepting it would hand back the green-here-red-there reading this change exists to abolish.

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 --re-measure refuses on CI, naming the reading to re-pin from. And on every green run the same line prints the runner's limit into the log, so the constant stays re-derivable from any CI log of this step rather than by archaeology through a failed job's GC trace. That is a constant that cannot rot the way #12831's cost note did.

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, --re-measure reproduced CI's exact signature — Mark-Compact (reduce) 254.5 (265.2) → 253.8 (261.5) MB / FATAL ERROR: Reached heap limit. Mutation confirmed on disk before the reading, restore confirmed after.

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: surplus: none — every entry sits exactly at its measurement. No entry carries slack, so the green is not covering drift — the #12799 discipline applied unprompted.

skip-changeset carries a receipt (the last 11 root-scripts/-only commits: zero changeset files), ⛔ not inherited. Scope held: ledger numbers and surplus policy untouched (#12799 on hold on this same script, #12511 undispatched), and package.json untouched so PR #12942 keeps the scripts block.

⚠️ Carried, now 29 for 29: the arming echo reports method: MERGE while SQUASH was requested. Cosmetic.


Generated by Claude Code

@os-elon
os-elon added this pull request to the merge queue Aug 28, 2026
Merged via the queue into main with commit 3404bd1 Aug 28, 2026
37 checks passed
@os-elon
os-elon deleted the claude/issue-12856-ci-shaped-heap-ceiling branch August 28, 2026 11:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check:type-check-debt --re-measure OOMs only on CI — the gate's harness does not pin the heap ceiling, so a local run is not CI-shaped

2 participants