fix(pm): the dispatch-gates gate kill branch exits 3, so its code says what its text says - #18271
Conversation
…e says what its text says The `result.signal` branch printed "⛔ Do not record it as a run" and then exited 2. `dispatch-gates.mjs --ran` classifies a recorded exit FROM THE CODE: 3 is the repo-wide NOT MEASURED code, 124 and the 128 + signum floor are kills, and everything else is a run. 2 is none of those, so a cap-killed battery recorded honestly as `:: exit 2` reconciled inside the `run` total — a family counted as measured on a run that measured nothing. The branch now exits EXIT_PREREQUISITE_NOT_MET and names the code in its own remedy text. A child that RAN keeps `result.status`; the spawn-failure branch keeps 2, because "the tool is gone" and "the box could not fork" reach it wearing the same error and only the second is a not-measured reading. Pinned by this file's first self-test: three stub children (one that signals its own pid, one red, one green) driven through a `--self-test-child` flag, asserting the wrapper's exit for each AND how `runReconciliation` reads a record of it — including a regression pin that 2 still reconciles as a run. Wired the sibling way (`--self-test && <bare>`), so its row leaves check-self-test-wired's SELF_TEST_RUN_OTHERWISE ledger. Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr Co-authored-by: Claude <noreply@anthropic.com>
… RUN edge The first draft routed both the real and the substituted child through one `resolve(ROOT, child)` call. The tool's derivation follows that spawn as a RUN edge to inherit TOOL's watch hints — the workflow tree among them — and its run scan refuses a rebound program component, so the edge vanished: five cases of the tool's own 1730-case battery red, and a workflows-only surface stopped deriving `check:pm-dispatch-gates` at all (measured 1 -> 0 on .github/workflows/board-snapshot.yml). The substituted child now gets its own call and the production one is the expression that was there before. Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewing seat: ① Derived judgments
② Semver levelNot applicable — nothing published moves; ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Landing provenance — skills seat, session Generated by Claude Code |
Fixes #18267
scripts/pm/check-dispatch-gates.mjs'sresult.signalbranch printed 「⛔ Do not record it as a run」 and then exited 2.dispatch-gates.mjs --ranclassifies a recorded exit from the code:3is the repo-wide NOT MEASURED code,124and the128 + signumfloor are kills, everything else is a run.2is none of those. The two carriers disagreed about one reading, and the code won.The probe, driven — the reading BEFORE any change
The card named this probe and nobody had executed it. It was the first act of this branch. Both forms the dispatch suggested were measured, and one of them does not work:
timeout -s TERM 5 node scripts/pm/check-dispatch-gates.mjsdoes NOT reach the branch. The wrapper registers no SIGTERM listener, so it takes the default disposition and dies with its child; nothing prints, and the shell readstimeout's own 124:Signalling the wrapper's CHILD does reach it. Spawn the wrapper, read its child pid from the process table,
kill -TERMthat pid, andwaiton the wrapper:Recorded honestly as
pnpm check:pm-dispatch-gates :: exit 2and reconciled through--ran, against the 31 families a card touching this file derives:The reconciler accounted the kill as a RUN. Exit 0, nothing to notice, a family counted as measured on a run that measured nothing. The two controls, same record, same derivation:
That third reading is why the fix is not
143: a bare kill code is read as UNRUN unless the runner also writes a NOT-MEASURED claim with a stated reason beside it. That default is right for a runner relaying a kill it did not diagnose; it is wrong here, where the branch has already identified the kill, printed the reason and named the remedy before it exits.EXIT_PREREQUISITE_NOT_METcarries all of that in the code itself.What changed
result.signalbranch exitsEXIT_PREREQUISITE_NOT_MET(3) and names the code in its own remedy text, so the two carriers cannot drift apart silently again.result.status— untouched.result.errorkeeps 2, deliberately, and the reason is now at the branch: a spawn that never started has two causes this file cannot tell apart — the tool is gone from the tree (a finding about the tree) and the box could not fork (not a finding at all).3would assert the second reading over both. Its current reading costs a rerun rather than a false green, which is the safe direction, so nothing here justified moving it without a measurement that separates the two.The self-test, and the shape it needed
The wrapper had none. It could not have one without a way to stand the 435-second battery down, because the branch under test only fires when that battery is killed — so the smallest code change that lets a stub in is in scope, and it is a flag:
--self-test-child (path)substitutes the child. ⛔ Not anOS_TEST_*env var: an environment variable is inherited from whatever shell started the runner, so it can arrive without appearing in any invocation anyone reads, and the one failure this affordance could introduce is a green gate over a child that is not the tool. A flag is spelled at the call site, andpackage.jsonholds the only invocation CI runs. The right boundary matters too —check-self-test-wiredmatches--self-testwith one, so the longer flag is not credited as an invocation of the self-test.SUBSTITUTED CHILD), and the self-test asserts that it does, so the loudness is live rather than promised.timeout -s TERMdoes not reach the branch at all (above). A stub that signals its own pid has no race and no lookup — node with no SIGTERM listener takes the default disposition, sospawnSyncreportssignal: 'SIGTERM',status: null, which is the exact shape a foreground-cap kill produces. That is the form pinned.Eleven cases, sub-second, three directions plus the half that is not about this file — each exit is pushed through
runReconciliationon a one-family derivation, so3means NOT MEASURED here rather than merely being three.parseRunRecord/runReconciliationare imported (43 ms) rather than spawned: what is under test is how a recorded code classifies, not how the derivation finds the family, and a spawned derivation would add a workflow-tree walk per case to every CI run of this gate.The last one is the live one: delete the fix and that case reddens, because
2still reconciles as a run.Wiring. The repo's convention for a
check:*script that ships a self-test is(script) --self-test && (script)—check:pm-skill-ratchet,check:pm-skill-id-lint,check:pm-label-desc-capandcheck:pm-governed-proseall spell it that way — socheck:pm-dispatch-gatesnow does too. That wires the self-test, and a wired script must leavecheck-self-test-wired'sSELF_TEST_RUN_OTHERWISEledger ("A row whose script gets wired FAILS. The list only ever shrinks"), so its row is deleted.node scripts/check-self-test-wired.mjsnow reports 211 scripts, 208 through the flag, 3 through a recorded route (was 4).Reverse verification
The ablation is the probe itself, re-driven against the changed tree, same script, same method:
Before: wrapper exit 2, reconciled inside the
runtotal. After: wrapper exit 3, reconciled NOT-MEASURED withsource: 'exit-code'— derived from the code, so a runner cannot fail to declare it. A child that ran red still reconciles as a run (pinned above). No permanent test file was left behind; the pin lives in the self-test.A regression the tool's own battery caught, and the trap behind it
Worth recording, because it reads as reasonable code. The first draft routed both the real and the substituted child through one
spawnSync(process.execPath, [resolve(ROOT, child), '--self-test']). The tool's derivation follows that spawn as a RUN edge — it is how this gate inherits the tool's watch hints, the workflow tree among them — and its run scan refuses a rebound program component on purpose. So the edge vanished silently:Five of the tool's 1730 cases went red on it, and a workflows-only card stopped deriving this gate at all. The substituted child now gets its own call and the production one is byte-for-byte the expression that was there before; the trap is written at the spawn site. Re-measured after the fix:
1.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackwith no paths (derived from git off the merge base), every family run in the foreground, each exit captured by redirect before any pipe, then reconciled. Union re-run at the final headfed9c9e2.Those four are derived by change KIND from the one-line edit to the private root manifest's
scriptsblock, and all four read builtdist/output this diff contains none of; they refuse on an unbuilt worktree. CI runs them on a built tree. It is the reading this card is about, arriving by itself.pnpm check:pm-dispatch-gatesis owed beyond the derivation because this diff edits the wrapper, and it was run the way its own header prescribes — detached, then blocked on withtail --pid, the exit code captured through a wrapper rather than read off a pipe:Wall clock 450.2 s on a contended box, exit 0, with the wrapper's own eleven cases running in front of it under the new wiring.
Repo-wide
pnpm lint(eslint . --no-inline-config) run in full, not narrowed: exit 0 in 82 s.skip-changeset: the root manifest isprivate: true, and no published package'sfiles[]shipsscripts/. Nothing this diff touches publishes.Acceptance notes
Noted, not filed — none of these is a reproducible defect, a contract violation, or a metadata-authoring trap:
timeout -s TERMon this wrapper measures nothing about its signal branch. The shell readstimeout's 124 and the branch never prints. Not a defect in anything —timeoutsignals its direct child by design — but it is the obvious way to try this probe and it fails by looking like a result. Successor: the next author of a probe against this branch, who now finds the measurement in the file's own self-test docblock and in the header.result.errorconflates two causes under exit 2. A missing tool is a finding about the tree; a box that cannot fork is not. Whichever code that branch carries is wrong for one of them, and its current one errs toward a rerun rather than a false green. Left alone deliberately, with the argument now written at the branch for whoever measures the split. Successor: none today — no card is queued against this branch, and it is recorded here rather than filed because filing it would ask someone to choose without a measurement to choose on.scriptsblock derives four build-artefact gates. Over-inclusive in the safe direction, and they announce themselves with exit 3 rather than a false green, so nothing is hidden. Successor: any PR editing rootpackage.json— which is most of them — so the cost is a known four-line NOT-MEASURED block, not a surprise.Generated by Claude Code