Skip to content

perf(pm): discover each tree once per process — the dispatch-gates self-test now reaches a verdict inside the foreground cap - #18255

Merged
claude[bot] merged 2 commits into
mainfrom
claude/issue-18201-dispatch-gates-selftest-under-the-cap
Sep 15, 2026
Merged

claude[bot] merged 2 commits into
mainfrom
claude/issue-18201-dispatch-gates-selftest-under-the-cap

Conversation

@claude

@claude claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #18201

The profile, which is what the card asked for first

pnpm check:pm-dispatch-gates was measured end to end on b3b43b6e, detached, with every
case line timestamped. It does not merely run past the container's ~10-minute foreground cap —
it completes in 760 s (12m40s), so the card's "9m0s then exit 124" was the kill, never the
battery's real length. 1723 cases, exit 0.

Where the 760 s went, measured three ways on the same tree:

reading value
total wall clock, 1723 cases 760 s
spawnSync calls from the battery 253 (46 dispatch-gates CLI children, 198 git, 2 bash)
time inside those children 500.7 s = 66.2 %
of which: 24 children doing a FULL derivation ~20.7 s each = 496 s (the other 22 are argv/--tier probes at ~0.1 s)
parent's own in-process discoverFamilies() 157.1 s = 20.8 %
one plain derivation, timed alone 21.2 s

Then a V8 CPU profile of that single 21.2 s derivation, which is where the root cause is:

discoverFamilies — 2 profile node(s)
  incl=6.86s  <- derive:12935
  incl=7.06s  <- gateFamilyFiles:9455 <- matches:9918 <- changeKindGates:9972

The same whole-tree discovery runs twice per derivation, over a tree that cannot have changed
between them — once from derive(), once from gateFamilyFiles() a whole call chain away under
changeKindGates(). 13.9 s of the 21.2 s, 66 %. And the self-test's live cases call the same
function a further twenty-odd times in one process, each one a fresh pass re-reading every workflow
and re-masking every gate source. No hot spot, no quadratic: just the identical derivation, run N
times.

The fix: one discovery pass per tree, per process

Exactly the shape — and the argument — the source maskers in this same file already landed:
"the same derivation run once instead of a dozen times, which is the only kind of speed-up this
tool may take."

  • repoCorpus() lists the checkout once per process and hands every caller the SAME listing and
    the SAME tree bundle. derive() now takes both halves from it, which also strengthens the
    "one read, N answers" invariant its own comment states — the pair is now the same two objects,
    not two equal copies.
  • discoverFamilies is memoised on the tree OBJECT. A caller that means a different tree
    hands a different object and gets its own pass; null — the deliberate no-tree probe — is not
    an object and is never memoised. The answer is a pure function of the tree plus the checkout on
    disk, and nothing in this process writes to the checkout: the CLI is one-shot and every write the
    self-test makes goes to a mkdtemp directory under the system temp root.
  • check-dispatch-gates.mjs: the kill branch now spends its one chance on the remedy rather
    than on naming the signal, and every run prints what the battery cost on that box — a reading
    that belongs to the run that took it, which is the one shape that cannot rot. No figure is frozen
    in the header; the header's convention is kept.

Before / after

before (b3b43b6e) after (0f901d56)
--self-test wall clock 760 s 466 s (−38.7 %)
cases 1723 pass, exit 0 1730 pass, exit 0
one plain derivation 21.2 s 13.2 s (−38 %)
longest single gap between case lines 42.9 s 27.2 s

466 s = 7m46s, inside the cap. The 13.2 s derivation is the half every dispatch pays, not only
this gate: every os-dev running dispatch-gates.mjs --commands on its own card gets the same
38 % back.

⛔ No gate weakened, and nobody else has to make the assertions

Every one of the 1723 cases is still made by this same battery, in this same CI step. Zero cases
removed
— verified by diffing the case NAMES of both runs, not their counts:

before cases: 1724   after cases: 1731   (the extra line is the verdict line)
REMOVED (3):
  - the tier constant's VALUE is spelled in exactly ONE site ... the definition at
    scripts/pm/dispatch-gates.mjs:10689 ...          <- same case, line number moved by the insert
  - ⭐ 49 case(s) really handed this harness a diagnostic reading ...   <- same case, 49 -> 53
  - dispatch-gates self-test: 1723 cases pass.                          <- the verdict line itself
ADDED (10): the 7 new cases below, plus the updated spellings of those same 3

All three "removed" names carry a live reading inside the name; all three are present with the
reading updated. Nothing was shrunk, skipped, timeout-wrapped or marked continue-on-error.

Seven cases were ADDED, because the memo's whole symptom is work that does not happen, and
absent work is invisible to every other case here — each of them asks what discovery ANSWERS, and
the answer is identical either way:

  • the default tree is discovered ONCE per process — two further calls compute no pass
  • and those calls hand back the SAME object, so an entry ablated and restored is one entry
  • the corpus behind it is listed once too — one bundle, so the sweep and the discovery cannot describe different revisions
  • and that listing is the tracked corpus itself, not a trimmed copy of it
  • the tree derive hands down and the default gateFamilyFiles asks for are ONE pass, not two
  • a tree object that is not the corpus's is never served its answer — same content, its own pass
  • the no-tree probe is never memoised — it is not an object, and it still answers

Byte-identity: the output did not move

Five invocations (plain, --commands, --json, --residue, and a scripts/pm card),
stdout and stderr, run against the unmodified b3b43b6e checkout and against this branch:

byte-identical after path normalisation: 10 stream(s); differing: 0
225585 total bytes compared

The only raw difference was the checkout directory in the provenance banner, which is what that
banner prints by design.

Reverse verification

Direction predicted before running: red. Committed first, mutated on disk with the blob hash
proving the bytes moved, trap ... EXIT INT TERM restoring, and the restore proven by
git hash-object matching the HEAD blob with git diff HEAD empty.

Ablation A — the memo never hits (const hit = discoveryMemo.get(tree) becomes
const hit = undefined):

on-disk proof: anchor lines 1 -> 0 ; injected lines 0 -> 1 ;
               blob 52d58eea... -> 1f8b91d1...
✗ the default tree is discovered ONCE per process — two further calls compute no pass
✗ and those calls hand back the SAME object, so an entry ablated and restored is one entry
✗ the tree `derive` hands down and the default `gateFamilyFiles` asks for are ONE pass, not two
✗ dispatch-gates self-test: 3 of 1730 case(s) failed.        SELFTEST_EXIT=1
wall clock back to 807 s
restored: hash matches HEAD blob and git diff HEAD is empty

The other four new cases stay green under A, correctly: a memo that never hits breaks the collapse,
not the tree-keying.

What this PR deliberately does NOT do

The card and the dispatch both floated classifying the family as CI-only so a dev "is not owed it
locally". Measured first, and then not taken. The derivation reaches a card by gate-script
identity
— the change kind "adds or edits a GATE SCRIPT (a file some discovered check family
runs)"
— which is wider than the dispatch's hypothesis of scripts/pm paths:

scripts/pm/check-clause2-carriers.mjs   -> pnpm check:pm-dispatch-gates IS in the 35 commands
packages/spec/src/data/filter.zod.ts    -> NOT in the 73 commands
scripts/pm/dispatch-gates.mjs           -> IS in the 31 commands

So it is not in every roster, and the cards it does reach are cards editing a gate script — exactly
the population that should read this verdict. With the battery now inside the cap, taking it off
their roster would remove a reading they can finally get, which is a weakening dressed as a
scoping fix. The reconciler is untouched: exit 3 still reads NOT MEASURED and a 124/143 kill is
still refused as a run.

Acceptance notes

Observed, not filed, not fixed here:


🤖 Generated with Claude Code

https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr


Generated by Claude Code

The self-test battery could not reach a verdict inside an agent
container's foreground cap. A CPU profile of one derivation found
discoverFamilies() running twice over the identical tree -- once from
derive(), once from gateFamilyFiles() under changeKindGates -- and the
self-test driving it a further twenty-odd times in one process, every
pass re-reading every workflow and re-masking every gate source for
bytes that could not have changed in between.

Collapse them: one repoCorpus() listing per process, and discoverFamilies
memoised on the tree OBJECT so a caller meaning a different tree still
gets its own pass. Same derivation, run once instead of N times -- the
tool's output is byte-identical.

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
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c50a79ca5da042caf100c56273465759cf38e75d

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 #18201, claim 5674096667, report 5674798302; the PR declares Clause-②: no and the card's claim declares the same.

① Derived judgments

  • Accept set / public surface: none moves — scripts/pm/dispatch-gates.mjs (+162/−7) and scripts/pm/check-dispatch-gates.mjs (+50/−1) against merge-base cb648cb1; no origin/main commit since that base touches either file; both are repo-root PM tooling with no package files[]; skip-changeset is the right form and is on the PR (read back with size/m). The head is the branch's merge of origin/main (c50a79ca), the same head the dev's 32-family union and --ran reconciliation ran on.
  • The card asked for a profile first and the report delivers one: the battery COMPLETES (760 s detached on b3b43b6e), so the 「9 min then exit 124」 was the cap, not the length; the V8 profile found discoverFamilies() run twice per derivation over the identical tree (from derive and from gateFamilyFiles under changeKindGates) plus twenty-odd times more in the self-test. The fix is the third shape neither PM candidate named and the only kind of speed-up this tool may take: repoCorpus() lists the checkout once per process and discoverFamilies is memoised on the TREE OBJECT (a WeakMap; null never memoised; a different tree object gets its own pass), with discoveryPassCount() exported so the collapse is OBSERVED rather than inferred. No case removed, no assertion weakened, no timeout, no skip, no continue-on-error; the result.signal branch keeps exit 2 and now prints the remedy (detach) instead of only the signal; the wrapper prints its own wall clock at run time rather than freezing a figure.
  • Seat-measured on the PR head in a detached worktree at c50a79ca: node scripts/pm/check-dispatch-gates.mjs run in the FOREGROUND on this container → 「dispatch-gates self-test: 1730 cases pass」, exit 0, 434.8 s by the wrapper's own line (the dev's 466 s solo reading holds; the card's whole point — a verdict inside the cap — is reproduced by the seat; a first attempt died at 1278 cases on git check-ignore … beyond a symbolic link because the seat's worktree had a symlinked node_modules, the seat's environment and not the PR's, fixed by a real directory). --commands --repo objectstack-ai/objectstack with three identical explicit paths on the head and on origin/main: 14302 bytes each, diff empty after normalising the checkout path and the commit line (11 s vs 20 s). eslint on both files exit 0. The self-test's seven new cases read as the diff shows: the default tree discovered once, the same object handed back, the corpus listed once, the derive/gateFamilyFiles seam one pass, a twin tree with identical content its own pass, the null probe never memoised.
  • Ablation reported red both ways (A: the memo lookup blinded → 3 of 1730 red, wall clock back to 807 s; B: a one-slot memo ignoring the tree → 3 of 1730 red, one of them a pre-existing fixture-tree case) with the blob-hash mutation proof and the restore proved by git diff HEAD empty — accepted as reported; the direction is the predicted one. P3 held (the --ran reconciler untouched; 124/143 still refused as a run).
  • Gates 32 / 32 / 0 / 0 with exits captured by redirect, plus pnpm check:pm-dispatch-gates detached (461 s) beyond the derivation.

② Semver level

Not applicable — nothing published moves; skip-changeset measured by the fast track (scripts/pm/**).

③ Boundary flags

open_questions: none. Seven declared deviations, each answered: (1) the PR body cites 0f901d56 for the timing reading while the union ran on the merge head c50a79ca — both are true readings of their own commits and the body was not PATCHed (outside the budget); accepted. (2) two of the seven new cases (the repoCorpus identity pair) are not driven red by either ablation — neither ablation touches repoCorpus; accepted, noted. (3) the wrapper run overlapped an ablation for ~2 min; the solo reading is the one cited; accepted. (4) P1 refined by measurement: the family reaches a card by gate-script identity, not by every scripts/pm/** roster; accepted. (5) P2(b)'s warm-process half falsified (≈10 %), the collapse is the memo; accepted. (6) P2(a) measured and deliberately NOT taken — with the battery inside the cap, moving the family to CI-only would remove a verdict from the cards that edit a gate script; the seat agrees. (7) P3 held. Out of scope, recorded here and carried by the seat: (a) a cap-killed pnpm check:pm-dispatch-gates exits 2 from the wrapper's signal branch, so a dev recording :: exit 2 is accounted by --ran as a RUN with a failing code rather than NOT MEASURED (exit 3) — a class (a) candidate the dev named and did not execute; the seat files it as a card after this lands; (b) lint.yml's note carries a 597 s CI median for this step from before this change — a runner reading; this PR's own Lint & Repo Gates run is the first real reading of the new cost and the seat reads it at landing; (c) deadHintSweep (~5.7 s of the remaining 13.2 s) runs once per derivation, nothing redundant left, no card.

Implemented-by: claude/issue-18201-dispatch-gates-selftest-under-the-cap
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-15T04:55Z. In-seat landing (path face NOT governed: scripts/pm/dispatch-gates.mjs + scripts/pm/check-dispatch-gates.mjs only): review of record 5674898704 (## Contract review PASS) names head c50a79ca5da042caf100c56273465759cf38e75d; ACCEPT 5674898927 on #18201; check-clause2-carriers --pair 18255 exit 0 re-read at 2026-09-15T04:54Z with the C6-RECORD note naming that record; on that head Lint & Repo Gates (concluded 2026-09-15T04:53Z — the first real CI reading of the step with the collapsed discovery) and TypeScript Type Check (concluded 2026-09-15T04:37Z) both success and every other check is success or skipped (31 runs: 23 success, 8 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-15T04:54Z and added_to_merge_queue 2026-09-15T04:54Z; queue ref gh-readonly-queue/main/pr-18255-* present on origin at 2026-09-15T04:54Z. MERGED is confirmed later by two readings (the queue ref gone + git log origin/main carrying (#18255)), then the card's residue is stripped.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

1 participant