Skip to content

fix(pm): the dispatch-gates gate kill branch exits 3, so its code says what its text says - #18271

Merged
claude[bot] merged 2 commits into
mainfrom
claude/issue-18267-kill-branch-exits-not-measured
Sep 15, 2026
Merged

claude[bot] merged 2 commits into
mainfrom
claude/issue-18267-kill-branch-exits-not-measured

Conversation

@claude

@claude claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #18267

scripts/pm/check-dispatch-gates.mjs's 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, everything else is a run. 2 is 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.mjs does 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 reads timeout's own 124:

PROBE_A_WRAPPER_EXIT=124
--- grep for the signal branch text ---
0

Signalling the wrapper's CHILD does reach it. Spawn the wrapper, read its child pid from the process table, kill -TERM that pid, and wait on the wrapper:

SENT SIGTERM to child 14627
PROBE_B_WRAPPER_EXIT=2
✗ check:pm-dispatch-gates: scripts/pm/dispatch-gates.mjs --self-test was killed by SIGTERM after 4.0s — NOTHING was measured.
  Every case decided before the kill is in the output above and every case after it is unjudged, so this run grades neither the tool nor your diff. ⛔ Do not record it as a run.
  Remedy — detach it and read the log, the invocation this file's header prescribes for a capped container:
      nohup pnpm check:pm-dispatch-gates > pm-dispatch-gates.log 2>&1 &
  then tail that log until it stops growing. CI runs this step with no such cap.

Recorded honestly as pnpm check:pm-dispatch-gates :: exit 2 and reconciled through --ran, against the 31 families a card touching this file derives:

### exit 2  ->  --ran exit 0
Run reconciliation — 31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN.

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:

### exit 3  ->  --ran exit 0
Run reconciliation — 31 derived, 30 run, 1 NOT-MEASURED, 0 UNRUN.
    - pnpm check:pm-dispatch-gates   [recorded exit 3 on line 27 — PREREQUISITE NOT MET]

### exit 143  ->  --ran exit 1
Run reconciliation — 31 derived, 30 run, 0 NOT-MEASURED, 1 UNRUN.
    - pnpm check:pm-dispatch-gates   [recorded exit 143 on line 27 — killed by SIGTERM, so no verdict was reached; declare it as `NOT-MEASURED (command) :: (reason)` beside the code to count it NOT-MEASURED]

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_MET carries all of that in the code itself.

What changed

  • The result.signal branch exits EXIT_PREREQUISITE_NOT_MET (3) and names the code in its own remedy text, so the two carriers cannot drift apart silently again.
  • A child that RAN keeps result.status — untouched.
  • result.error keeps 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). 3 would 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.
  • A new header section carries the four-row exit contract and the argument above.
  • ⛔ Nothing else: no timeout wrapper, no skip, no weakening. The detached-form header stands.

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 an OS_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, and package.json holds the only invocation CI runs. The right boundary matters too — check-self-test-wired matches --self-test with one, so the longer flag is not credited as an invocation of the self-test.
  • Every substituted run announces itself on stderr (SUBSTITUTED CHILD), and the self-test asserts that it does, so the loudness is live rather than promised.
  • The killed stub kills itself. Measured both ways: signalling from outside needs the grandchild's pid, so it races the spawn and reads the process table; timeout -s TERM does 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, so spawnSync reports signal: '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 runReconciliation on a one-family derivation, so 3 means NOT MEASURED here rather than merely being three. parseRunRecord/runReconciliation are 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.

✓ CONTROL: the killed stub really died by SIGNAL, so the branch under test is the one that ran
✓ ⭐ a child killed by a signal exits 3 — the code this branch's own text asks for
✓ …and the text that asks for it is still printed, so the two carriers cannot drift apart silently
✓ a child that RAN and failed keeps its own status
✓ a child that RAN and passed keeps its own status
✓ ⛔ and a substituted child announces itself on every one of those runs — a stub run is never quiet
✓ ⭐ …and a record of that exit reconciles as NOT-MEASURED, which is the whole contract
✓ …derived from the CODE, not claimed by the runner — so a killed battery cannot be recorded as a run by hand
✓ a red run reconciles as a RUN, and the reconciliation holds
✓ a green run reconciles as a RUN too
✓ ⛔ REGRESSION PIN: the code this branch used to exit still reconciles as a RUN — which is why it moved

The last one is the live one: delete the fix and that case reddens, because 2 still 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-cap and check:pm-governed-prose all spell it that way — so check:pm-dispatch-gates now does too. That wires the self-test, and a wired script must leave check-self-test-wired's SELF_TEST_RUN_OTHERWISE ledger ("A row whose script gets wired FAILS. The list only ever shrinks"), so its row is deleted. node scripts/check-self-test-wired.mjs now 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:

SENT SIGTERM to child 16286
PROBE_B_WRAPPER_EXIT=3
✗ check:pm-dispatch-gates: scripts/pm/dispatch-gates.mjs --self-test was killed by SIGTERM after 4.0s — NOTHING was measured.
  Every case decided before the kill is in the output above and every case after it is unjudged, so this run grades neither the tool nor your diff. ⛔ Do not record it as a run.
  This exits 3, the repo-wide NOT MEASURED code, so a record line `(command) :: exit 3` reconciles as NOT-MEASURED rather than as a run that failed.

Before: wrapper exit 2, reconciled inside the run total. After: wrapper exit 3, reconciled NOT-MEASURED with source: '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:

=== HEAD (first draft) ===   node scripts/pm/dispatch-gates.mjs --commands .github/workflows/board-snapshot.yml | grep -c pm-dispatch-gates
0
=== origin/main (before) ===
1

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/objectstack with 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 head fed9c9e2.

Run reconciliation — 38 derived, 34 run, 4 NOT-MEASURED, 0 UNRUN.
  NOT-MEASURED · DERIVED (4) — your record carries exit 3 for these, the number a gate refusing its own prerequisite exits with.
    - pnpm check:dts-closure   [recorded exit 3 on line 24 — PREREQUISITE NOT MET]
    - pnpm check:dual-build-cjs-loads   [recorded exit 3 on line 25 — PREREQUISITE NOT MET]
    - pnpm check:lean-entry-closure   [recorded exit 3 on line 27 — PREREQUISITE NOT MET]
    - pnpm check:sourcemap-no-sources-content   [recorded exit 3 on line 35 — PREREQUISITE NOT MET]
✓ dispatch-gates --ran: 38 derived famil(ies) accounted for — 34 run, 4 NOT-MEASURED (4 DERIVED from a recorded exit 3).

Those four are derived by change KIND from the one-line edit to the private root manifest's scripts block, and all four read built dist/ 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-gates is owed beyond the derivation because this diff edits the wrapper, and it was run the way its own header prescribes — detached, then blocked on with tail --pid, the exit code captured through a wrapper rather than read off a pipe:

✓ dispatch-gates self-test: 1730 cases pass.
check:pm-dispatch-gates: the battery took 450.2s on this box.

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 is private: true, and no published package's files[] ships scripts/. 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 TERM on this wrapper measures nothing about its signal branch. The shell reads timeout's 124 and the branch never prints. Not a defect in anything — timeout signals 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.error conflates 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.
  • A one-line edit to the private root manifest's scripts block 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 root package.json — which is most of them — so the cost is a known four-line NOT-MEASURED block, not a surprise.

Generated by Claude Code

…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>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 15, 2026
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Sep 15, 2026
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: fed9c9e28dda731469e7a2f094954efb374eecd7

Reviewing seat: domain:skills, in-seat review at the contract-review tier — get_session read at 2026-09-15T04:30Z: configured_model = session_context.model = last_served_model, the constant's tier, no fallback notice this round. Card #18267, claim 5675153078 (file surface revised 5675444991), report 5675425337; the PR declares Clause-②: no and the card's claim declares the same.

① Derived judgments

  • Accept set / public surface: none moves — scripts/pm/check-dispatch-gates.mjs (+244/−5), package.json (+1/−1: the check:pm-dispatch-gates script line gains --self-test && before the bare run, the convention four sibling check:pm-* gates use) and scripts/check-self-test-wired.mjs (−6: the wrapper's row leaves the SELF_TEST_RUN_OTHERWISE ledger because its self-test is now wired) against merge-base e592b4b6; no origin/main commit since that base touches any of the three; repo-root tooling, nothing published; skip-changeset on the PR (read back with dependencies, size/m — the labeler's reading of a package.json touch, not a dependency change).
  • The probe the card named was executed FIRST and both halves read: timeout -s TERM on the wrapper never reaches the signal branch (the wrapper has no SIGTERM handler and dies with its child, the shell reads timeout's 124) — the seat's suggested form, falsified; killing the child by pid does, and before the change the wrapper exited 2 and --ran accounted the kill as a RUN inside the run total (exit 0, nothing to notice); after, exit 3 reconciles as NOT-MEASURED, source 'exit-code'. Controls on the same record: exit 3 → NOT-MEASURED; exit 143 → UNRUN unless a claim with a reason sits beside it, which is why the fix is not 143. result.error keeps 2 with the argument written at the branch (a missing tool is a finding about the tree, a box that cannot fork is not; 3 would assert the second over both) — the seat accepts that reading. The production spawn expression is byte-for-byte the one that was there (the dev's first draft collapsed both spawns into one resolve(ROOT, child) call and the tool's derivation lost the RUN edge — 5 of 1730 red, a workflows-only derivation went 1 → 0 for this gate — caught by the battery and fixed before the report; the trap is written at the spawn site).
  • The wrapper's first self-test: --self-test drives three generated stub children through --self-test-child PATH (a flag, not an env var, for the reason argued in the docblock: an inherited variable could substitute a child without appearing in any invocation), each substituted run prints a SUBSTITUTED CHILD banner asserted live, and each exit is pushed through the imported runReconciliation on a one-family derivation — 11 cases: killed → 3 → NOT-MEASURED (source exit-code); red → 1 → RUN; green → 0 → RUN; the regression pin that exit 2 still reconciles as a RUN.
  • Seat-measured on the PR head in a detached worktree at fed9c9e: node scripts/pm/check-dispatch-gates.mjs --self-test → 11 cases pass, exit 0; the full battery THROUGH THE NEW WRAPPER in the foreground on this container → 「dispatch-gates self-test: 1730 cases pass」, exit 0, 445.1 s by the wrapper's own line, zero SUBSTITUTED CHILD banners (a production run stays quiet); node scripts/check-self-test-wired.mjs → 211 self-tests wired, exit 0; eslint on both scripts exit 0; the three diffs read whole.
  • Gates 38 / 34 / 4 / 0 with exits captured by redirect — the four NOT-MEASURED are dts-closure, dual-build-cjs-loads, lean-entry-closure, sourcemap-no-sources-content, derived by change kind from the one-line package.json edit and refusing with exit 3 on an unbuilt worktree (they read built dist/ this diff contains none of; CI runs them built) — an honest DERIVED NOT-MEASURED, not a narrowing; plus pnpm check:pm-dispatch-gates detached (1730, 450.2 s) and repo-wide pnpm lint (82 s) beyond the derivation.

② Semver level

Not applicable — nothing published moves; skip-changeset measured by the fast track (scripts/** + the private root manifest's scripts block).

③ Boundary flags

open_questions: none. Two declared deviations, each answered: (1) none from the ruling — the ruling's own suggested probe form was measured and does not reach the branch; the deterministic self-kill stub (P3 invited the measurement) is the form landed; accepted. (2) four derived families NOT-MEASURED on an unbuilt worktree, accounted from the recorded code; accepted — the seat's checklist reads them as the tool's own DERIVED zero and CI's to run. The file surface was widened by the dispatch's own instruction (register the self-test the sibling way) and the seat revised the claim's surface on the card before this record. Out of scope, recorded: timeout -s TERM on this wrapper measures nothing about its signal branch and fails by looking like a result — now written in the file's own docblock; result.error's two conflated causes — no card without a measurement to choose on; a root package.json scripts-block edit derives four build-artefact gates (over-inclusive in the safe direction) — a known four-line NOT-MEASURED block for any PR touching that file.

Implemented-by: claude/issue-18267-kill-branch-exits-not-measured
Reviewed-by: session_01HZfg2AwVX191qCizp88gQr

VERDICT: PASS


Generated by Claude Code

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Landing provenance — skills seat, session session_01HZfg2AwVX191qCizp88gQr, 2026-09-15T06:12Z. In-seat landing (path face NOT governed: scripts/pm/check-dispatch-gates.mjs + the package.json script line + scripts/check-self-test-wired.mjs): review of record 5675514576 (## Contract review PASS) names head fed9c9e28dda731469e7a2f094954efb374eecd7; ACCEPT 5675514784 on #18267; check-clause2-carriers --pair 18271 exit 0 re-read at 2026-09-15T06:11Z with the C6-RECORD note naming that record; on that head Lint & Repo Gates and TypeScript Type Check both success and every other check is success or skipped (38 runs: 31 success, 7 skipped, 0 running, 0 failed); mergeable_state clean. Ready through POST …/ccr/ready_for_review (200, read back draft: false), auto-merge armed through PUT …/ccr/auto_merge SQUASH (200, enabled, method squash); timeline reads ready_for_review 2026-09-15T06:11Z and added_to_merge_queue 2026-09-15T06:11Z; the queue ref had not yet appeared at 2026-09-15T06:12Z (the added_to_merge_queue event is the reading; the ref is re-read at the landing confirmation), behind PRs #18270, #18242 and #18244. MERGED is confirmed later by two readings (the queue ref gone + git log origin/main carrying (#18271) with one parent), then the card's residue is stripped. After landing, CI's check:pm-dispatch-gates step runs the wrapper's 11-case self-test before the battery.


Generated by Claude Code

Merged via the queue into main with commit 842fa04 Sep 15, 2026
40 checks passed
@claude
claude Bot deleted the claude/issue-18267-kill-branch-exits-not-measured branch September 15, 2026 06:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant