propflowai#21973 said the mutation-control step died because runControls builds memory across a sequential pass, and labelled that inference, not confirmed. It is now measured, and it is wrong. Here is what the numbers say, and the one instrument that would have answered this in a single reading.
2026-10-23 · dock mutation-selector-oom, row runcontrols-memory-bounded · read-only lane: no code changed, no PR opened on scripts/check-mutation-control.ts (a sibling lane holds that file) · REFUTED
Memory does not build across the pass. A pass of 781–786 controls completes cleanly in one process on the same runner shape — fourteen times the 56 that died, five times the wall time — and the exact 56-control pass that died passed on a re-run of the same commit on the same shape twenty minutes later. The kill is a one-off on a box with zero swap, not a ceiling the loop walks into.
From the issue: “runControls (scripts/check-mutation-control.ts:241) runs them sequentially; memory appears to build across 56 double vitest runs (inference, not confirmed).” If that were the mechanism, two things would follow: a bigger selection would die sooner, and the same selection would die every time.
Sampling the last 150 ci.yml pull-request runs and reading the mutation step out of each CI Checks job log (141 logs; 113 ran the step): nine runs selected every control and finished.
| Job | Selection | Verdict printed | Step duration | Runner |
|---|---|---|---|---|
| 120133510119 | mode=all selected=781 | CONTROLS VERIFIED — 781 | 29m 11s | r8gd.xlarge · 4 cores · 31,486 MB · swap 0 |
| 120139551553 | mode=all selected=781 | CONTROLS VERIFIED — 781 | 36m 10s | r7g.xlarge · 4 cores · 31,486 MB · swap 0 |
| 120142441146 | mode=all selected=781 | CONTROLS VERIFIED — 781 | 28m 55s | r8gd.xlarge · 4 cores · 31,486 MB · swap 0 |
| 120145959644 | mode=all selected=781 | CONTROLS VERIFIED — 781 | 39m 35s | r7g.xlarge · 4 cores · 31,486 MB · swap 0 |
| 120170956630 | mode=all selected=784 | CONTROLS VERIFIED — 784 | 40m 44s | r8gd.xlarge · 4 cores · 31,486 MB · swap 0 |
| 120174359606 | mode=all selected=781 | CONTROLS VERIFIED — 781 | 31m 22s | r8g.xlarge · 4 cores · 31,486 MB · swap 0 |
| 120179990806 | mode=all selected=781 | CONTROLS VERIFIED — 781 | 29m 52s | r8gd.xlarge · 4 cores · 31,486 MB · swap 0 |
| 120198567291 | mode=all selected=784 | CONTROLS VERIFIED — 784 | 28m 14s | r8g.xlarge · 4 cores · 31,486 MB · swap 0 |
| 120202461535 | mode=all selected=784 | CONTROLS VERIFIED — 784 | 28m 08s | r8g.xlarge · 4 cores · 31,486 MB · swap 0 |
Every one of those is the same single npx tsx process, the same for … of loop, the same accumulating lines[], on the same 31,486 MB / zero-swap machine class as the job that died — and each ran four to five times longer than the 7m 28s pass that did. Diff-mode passes of 138 and 139 controls complete on the same shape too.
Positive control on the sweep: the same scanner, run over the dead job’s own log (120198320103), returns sel=('diff','56') exit=137. A zero therefore means “no other 137”, not “the scanner did not look”. Across the 113 sampled jobs that ran the step there is no second exit 137.
Run 39887146455 on propflowai#21970 has two attempts. Both landed on r8gd.xlarge, 4 cores, 31,486 MB, Swap: 0.
| Attempt 1 (job 120198320103) | Attempt 2 (job 120204148798) | |
|---|---|---|
| Selection | mode=diff selected=56 | mode=diff selected=56 |
| Step window | 02:03:21 → 02:10:50 (7m 29s) | 02:22:26 → 02:29:54 (7m 28s) |
| Outcome | exit 137 | CONTROLS VERIFIED — 56 |
| Instance | r8gd.xlarge, 31,486 MB, swap 0 | r8gd.xlarge, 31,486 MB, swap 0 |
| Memory free at job start | 30,323 MB available | 30,011 MB available |
Identical input, identical machine class, one second apart in duration, opposite outcome. A deterministic build-up cannot produce that. It also means attempt 1 was killed at the very end of its pass, within about two seconds of where attempt 2 finished, not “mid-way”.
Measured locally by replicating only the import half of loadControls — importing all 759 scripts/mutation-controls/*.control.ts in filename order, sampling process.memoryUsage(), executing no run() and spawning no vitest:
| Modules imported | RSS | Heap used | External | Elapsed |
|---|---|---|---|---|
| 0 (process start) | 207 MB | 85 MB | 4 MB | 0.0 s |
| 100 | 412 MB | 162 MB | 39 MB | 5.6 s |
| 350 | 523 MB | 256 MB | 45 MB | 19.2 s |
| 600 | 724 MB | 361 MB | 54 MB | 31.2 s |
| 759 | 731 MB | 386 MB | 57 MB | 35.8 s |
759 + the controlSources map (20 MB of text) | 755 MB | 408 MB | 56 MB | 36.0 s |
/usr/bin/time -l peak maximum resident set size: 838,975,488 bytes (839 MB), process exit code 0, 37.97 s real. Two things follow. The checker’s own footprint is under a gigabyte on a 31 GB box, so the parent is not what fills the machine. And the growth that does exist is in the load phase, which is unconditional: all 759 modules and all 759 control-plus-guard source texts are held whatever the selection, so narrowing the selection does not move this number at all.
execFileSync('npx', ['vitest','run',GUARD], …) with no maxBuffer, so Node’s 1 MiB default applies and a louder run fails ENOBUFS rather than growing. Worst case retention for a 56-control pass is 56 × 2 arms × 1 MiB ≈ 112 MB. The other 672 controls call the subject in-process and return a short string.killed[] holds MutationControl objects that loaded already holds for the life of the process — it retains nothing new.lines[] holds formatted verdict text. Attempt 2’s full 56-control report is a few tens of kilobytes.So the specific thing #21973 names — accumulation inside runControls — is capped around a hundred megabytes before you measure anything. The measurement agrees with the arithmetic.
guest-card-reply/own-card-echo-treated-as-new-lead.” No. That control’s two log lines are stamped 02:03:51.313Z; the kill is at 02:10:50 — seven minutes later. It is simply the last control that writes to stdout: every vitest-spawning control runs with stdio: ['ignore','pipe','pipe'] and prints nothing. Which control was running when the process died is not recoverable from the log at all, and that is the real gap.run() does a double vitest run.” Only 87 of 759 do. The one named above calls intakeSyndicatedGuestCard directly, in-process, in milliseconds.ci.yml:1708-1714 gates the step on github.event_name == 'pull_request', with a comment saying the queue must not run it, pinned by src/__tests__/mutation-controls-pr-gate.drift.test.ts. There is no train exposure to fix.Exit 137 is SIGKILL. The step runs with NODE_OPTIONS=--max-old-space-size=6144, so a V8 heap exhaustion in the parent would abort at 134 with a message, not die silently at 137. That leaves a kernel OOM kill on a 31,486 MB box with Swap: 0 — no elasticity whatever, so any transient peak over the line is an instant kill rather than a slowdown — or the instance being taken away.
The timing signature says the instance. scripts/ci/checks-reclaim-signature.sh’s own header names two runs AWS recorded as Server.SpotInstanceTermination that showed nothing but a bare 137 on the job. Reading both of their logs next to this one:
| Job | Step that took the 137 | Time into that step | exit 137 at | “runner has received a shutdown signal” | Gap |
|---|---|---|---|---|---|
| 117541318845 (AWS: spot termination) | npm run check:ddb-escapes | 25 s | 00:20:00.024 | 00:20:10.022 | 10.0 s |
| 117543009864 (AWS: spot termination) | npm run check:ddb-escapes | 31 s | 00:29:27.022 | 00:29:37.022 | 10.0 s |
| 120198320103 (propflowai#21970, this one) | npx tsx scripts/check-mutation-control.ts | 7 m 29 s | 02:10:50.112 | 02:11:00.019 | 9.9 s |
Three for three, the same ten seconds, and in the two AWS-confirmed cases the victim was a step twenty-five seconds old — a step with no memory history to accumulate. The repo’s own classifier agrees on the one we care about: run it over this job’s annotations and it answers reclaimed=true · why=exit 137 with no test failure: treated as a reclaim (negative control: a synthetic test-failure annotation correctly returns reclaimed=false). That is why the step kept going afterwards — steps 26 to 28 completed in the ten-second gap, which a slow systemd shutdown allows and an OOM kill also allows, so it separates nothing on its own. The ten seconds do.
What cannot be said, and why. An out-of-memory kill is not excluded — nothing recorded the memory. The mutation-control step is the only long step in that job with no memory reading. Twenty lines earlier in the same ci.yml job, the tsgo step carries a five-second sampler printing avail_mb, rss_mb and swap_free_mb, and it is the reason the tsgo eviction class is understood rather than argued about. Had it been running here, one log line would have ended this question in a reading instead of a day of archaeology.
ci.yml (the step at line 1708). Lift the tsgo step’s sampler onto the mutation-control step in its read-only form — sample and print, no VERDICT=KILL arm — plus a per-control progress line so the log names which control was running when a step dies. This touches no file the mutation-selector work touches, and it is the only change here that buys a fact nobody has — the next 137 on this step gets classified from its own log instead of from three other runs’ timestamps.runControls (scripts/check-mutation-control.ts:241-275), is the fix for a build-up this measurement says is not there — and it would cost a Node start-up plus a 759-module import, about 36 s, on every control. It would also land in the file a parallel lane is editing. Revisit only if the sampler shows a rising trajectory.maxForks for the controls’ vitest children — both one-line, neither inside runControls.gh api reads of 150 run manifests and 141 CI Checks job logs (158 MB downloaded to a scratch dir, 36 s wall at 8-way parallelism). No writes.npx tsx process importing 759 control modules and calling no run(): 37.97 s real, 839 MB peak RSS, exit 0. It does not execute the refused scripts/check-mutation-control.ts and spawns no vitest; it is the import half only, written as a standalone file in the lane’s scratch dir.scripts/ci/checks-reclaim-signature.sh classify on the dead job’s annotations, with a synthetic real-test-failure negative control that correctly returns reclaimed=false.Sources: propflowai#21973; run 39887146455 attempts 1 and 2 (jobs 120198320103, 120204148798); the nine mode=all jobs listed above; .github/workflows/ci.yml; scripts/check-mutation-control.ts; scripts/lib/mutation-control.ts; vitest.config.ts; scripts/ci/checks-reclaim-signature.sh.