perf(pm): discover each tree once per process — the dispatch-gates self-test now reaches a verdict inside the foreground cap - #18255
Conversation
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>
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 #18201
The profile, which is what the card asked for first
pnpm check:pm-dispatch-gateswas measured end to end onb3b43b6e, detached, with everycase 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:
spawnSynccalls from the batterydispatch-gatesCLI children, 198git, 2bash)--tierprobes at ~0.1 s)discoverFamilies()Then a V8 CPU profile of that single 21.2 s derivation, which is where the root cause is:
The same whole-tree discovery runs twice per derivation, over a tree that cannot have changed
between them — once from
derive(), once fromgateFamilyFiles()a whole call chain away underchangeKindGates(). 13.9 s of the 21.2 s, 66 %. And the self-test's live cases call the samefunction 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 andthe 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.
discoverFamiliesis memoised on the tree OBJECT. A caller that means a different treehands a different object and gets its own pass;
null— the deliberate no-tree probe — is notan 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
mkdtempdirectory under the system temp root.check-dispatch-gates.mjs: the kill branch now spends its one chance on the remedy ratherthan 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
b3b43b6e)0f901d56)--self-testwall clock466 s = 7m46s, inside the cap. The 13.2 s derivation is the half every dispatch pays, not only
this gate: every
os-devrunningdispatch-gates.mjs --commandson its own card gets the same38 % 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:
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 markedcontinue-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:
derivehands down and the defaultgateFamilyFilesasks for are ONE pass, not twoByte-identity: the output did not move
Five invocations (
plain,--commands,--json,--residue, and ascripts/pmcard),stdout and stderr, run against the unmodified
b3b43b6echeckout and against this branch: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 TERMrestoring, and the restore proven bygit hash-objectmatching the HEAD blob withgit diff HEADempty.Ablation A — the memo never hits (
const hit = discoveryMemo.get(tree)becomesconst hit = undefined):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/pmpaths: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:
lint.yml's own note for this step records a CI median of 597 s over ten merge-group runs(from ci(lint): Lint & Repo Gates is the merge-group critical path after #16453; run each gate family only on the paths it reads (PM dispatch-gates self-test 518 s first) #16496). That reading predates this change and is a runner reading, not a container one —
it will move, and this PR deliberately does not edit that note, which refuses to carry a figure
anyone has not re-measured on a real run of the step.
deadHintSweepis the next-largest single stage in a derivation (~5.7 s of the remaining13.2 s). It runs once per derivation, so there is nothing redundant to collapse; making it
cheaper would be an optimisation of what it computes, which is a different card and a different
risk.
🤖 Generated with Claude Code
https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
Generated by Claude Code