fix(pm): widen H8's inputs — branch-name delivery, closed-card residue (H22), and open-PR suppression - #11107
Conversation
…y declares none `h8MergedPrStillDispatched` read delivery from the PR body alone — `Part of #N` or a closing keyword bound to `#N`. A merged delivery spelled any third way was invisible: the measured specimen was a merged PR whose body said only `Refs #10757`, leaving that card carrying `pm:dispatched` unreported for ~22h in a sweep that reported six other H8 rows correctly. Every dev branch here is `claude/issue-<n>-<slug>` by protocol and every PR row already carries `head.ref`, so the recovery costs no API call. It is spelled as a FALLBACK rather than a third `||` term, because widening the delivery relation has a cost: a branch cut for card N and later re-scoped would otherwise be counted as delivering N forever, on the authority of a name nobody updated. The body is the channel an author maintains, so a body declaring ANY delivery is authoritative and the branch name is consulted only for bodies declaring none. Both directions are pinned in the self-test per the card's binding caveat: the `Refs #N` specimen shape reports, and a re-scoped branch does not false-fire. Fixes #11036
…tive drop on half-delivered cards
`h8MergedPrStillDispatched`'s entire input was the merged-PR window, so it had
no way to ask the question that decides the answer: is there ALSO an unmerged PR
delivering this card? On a card delivered in halves the first half's merge made
the row fire, and it kept firing on every subsequent sweep until the last half
landed — pointing at the card whose remaining work was most active, and
prescribing a destructive write against it ("drop `pm:dispatched`"). A reader
who followed it de-labelled a card with live work, which then read as
un-dispatched and was liable to be re-dispatched: two agents on one card.
Measured on #9834, whose error-counter half sat open as draft #10226 while its
duration half had merged as #10004.
The open-PR list is already in hand from the H7/H12/H21 pass, so this adds no
API call; delivery is read through the same relation, branch-name fallback
included. Drafts are deliberately not filtered — the specimen was one.
The half-delivered case DOWNGRADES rather than falling silent. Silence would fix
the harm and buy a new one, per the card's binding caveat: it loses the genuine
#8683 case where the last half is later abandoned. The quieter sentence names
both sides, counts them, and says `pm:dispatched` is correct and must not be
dropped. The destructive prescription now fires only when every delivering PR
has merged, and that path is pinned unchanged.
Fixes #10468
Every collector in this sweep is open-only by construction, and for most items
that is right. It is a gap for H8 specifically: H8's subject is a write that has
not happened yet, but the card is usually closed by the same merge that
discharges the PR — often by a `Closes #N` in the same instant. So whether H8
ever fired was decided by a race it normally loses. Once the card closes, no run
looks at it again and the duty is discharged by disappearance.
Measured at filing: 129 of the 500 most recently updated closed cards carried a
live `pm:` label, 118 of them `pm:dispatched` — the signature of exactly the
write H8 was built to catch, unmet at scale because the card closed first.
This is direction A: ONE bounded closed reader (`sort=updated`, two pages, the
same convention and quota decision as the merged-PR window), every other
collector unchanged and still open-only, so the race closes without widening the
sweep. The window is the stated boundary and it is load-bearing — a 2026-08-22
re-measure paged past 500 closed `pm:dispatched` carriers repo-wide, so an
unbounded read would bury every other item under one-time historical residue.
The residue vocabulary is its own constant. It is deliberately NOT H13's
`PM_STATE_LABELS`, which answers a different question ("does this label make the
card visible to a reader?") and diverges in both directions: `finding` and
`needs-user-decision` are fine states to close in, while `pm:blocking` is a
live-work claim H13 omits. Sharing one list would make H22 report every closed
finding card and miss the blocking-cache residue entirely; the divergence is
pinned in both directions.
Fixes #10688
PM 验收 — ACCEPT复核:席位 session_01MsbKEG4LtERSLaDrbehM3e 全 diff 对抗性审毕,三成员(#11036 分支名回退、#10468 open 侧降级、#10688 H22 有界 closed 读者)逐项吻合定级意图。关键设计点核验:回退 precedence(body 声明任何交付即权威)双向 pin;open 侧只软化不自造行、draft 不过滤; 开放问题裁定(席位纪律内,报维护者知悉):深尾 500+ closed 载体采 A(留置)——H22 巡逻近期活性 residue,深尾属一次性回填问题,B(专卡预算化回填)备选留维护者裁量; 落地类:非治理面(check-governed-merges --test 判定在案)——普通队列流程,本席 ready + auto-merge 布防,队列 ref 回读验证后合并;合并后剥离三卡 Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32587651072 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes #11036
Fixes #10688
Fixes #10468
The H8 patrol family — three graded cards, one predicate, one script, one PR. All
three are the same defect shape: H8's input set is too narrow. It ignored the
branch names every PR row already carries, it never revisited a card once it
closed, and it could not consult the open PRs the sweep already holds. One commit
per member.
Verified at
a256ecc9(the final commit — every gate result below was produced onthis tree, after the last commit).
1. Branch-name fallback (#11036)
h8MergedPrStillDispatchedread delivery from the PR body alone —Part of #Nora closing keyword bound to
#N. A merged delivery spelled any third way wasinvisible. The measured specimen: a merged PR whose body carried only a
Refsreference to card #10757, which then sat carrying
pm:dispatchedunreported for~22h in a sweep that reported six other H8 rows correctly.
Every dev branch here is
claude/issue-<n>-<slug>by protocol and every PR rowalready carries
head.ref, so the recovery costs no API call.The card's⚠️ is the load-bearing clause, and it shapes the implementation.
This widens the delivery relation, so the branch name is a fallback, not a
third
||term: a branch cut for card N and later re-scoped would otherwise becounted as delivering N forever, on the authority of a name nobody updated. The
body is the channel an author maintains, so a body declaring any delivery is
authoritative, and the branch name is consulted only for bodies declaring none —
exactly the population the specimen came from.
Both directions are pinned in the self-test, as the card requires:
Refsreference, branch names the cardplus: the re-scoped body's own card still reports, a closing keyword for another
card also suppresses the fallback, closed-unmerged on the card branch is still not
a delivery, a non-protocol branch name delivers nothing, and the anchored reader is
pinned against
CLAIM_BRANCH_SHAPEso one branch convention keeps two readers inagreement.
2. Closed-card residue sweep + backfill (#10688), direction A
Every collector in this sweep is open-only by construction. That is right for most
items and a gap for H8, whose subject is a write that has not happened yet — while
the card is usually closed by the same merge that discharges the PR. So whether H8
ever fired was decided by a race it normally loses: once the card closes, no run
looks at it again and the duty is discharged by disappearance.
H22 adds one bounded closed reader (
sort=updated, two pages — the sameconvention and quota decision as the merged-PR window). Every other collector is
untouched and still open-only, so the race closes without widening the sweep.
The residue vocabulary is its own constant,
PM_RESIDUE_LABELS. It is deliberatelynot H13's
PM_STATE_LABELS, which answers a different question ("does thislabel make the card visible to a reader?") and diverges in both directions:
findingandneeds-user-decisionare perfectly good states to close in, whilepm:blockingis a live-work claim H13 omits. Sharing one list would have made H22report every closed finding card on the board and miss the blocking-cache residue
entirely. The divergence is pinned in both directions.
Re-measurement — the card's population numbers are a window artifact
The card reports "129 closed carriers, 104 remaining". Re-measuring before writing,
as instructed, that figure is bounded by its own 500-most-recently-updated scan,
not the population. A label-scoped query paged past 500 closed
pm:dispatchedcarriers alone and was still returning full pages back to 2026-08-13.
This is why the window cap is load-bearing rather than incidental: an unbounded
closed read would bury every other item under one-time historical residue. H22
therefore reports recent residue — where the paired write is still a live duty
someone remembers — and the deep tail is a backfill question, not a patrol question.
Backfill performed
Scoped to the same bounded window H22 actually reads, so the row's first live run
is quiet and the code and the board agree:
Cleaned by label:
pm:dispatched68 ·pm:queue8 ·pm:blocking3 ·pm:blocked1 (counts overlap — 6 cards carried two). Page 2 was already lightbecause the devx cleanup recorded in the card is visible in it.
500+ repo-wide by the re-measure above. That is far beyond the ~104 this dispatch
was sized against, and clearing it is a decision about historical metadata rather
than about a live duty. Flagged in the report rather than actioned.
3. Open-PR suppression (#10468)
H8's entire input was the merged-PR window, so it could not ask the question that
decides the answer: is there also an unmerged PR delivering this card? On a card
delivered in halves the first half's merge made the row fire, and it kept firing on
every subsequent sweep until the last half landed — pointing at the card whose
remaining work was most active, and prescribing a destructive write against it
("drop
pm:dispatched"). A reader who followed that row de-labelled a card withlive work, which then read as un-dispatched and was liable to be re-dispatched: two
agents on one card, the outcome the claim protocol exists to prevent. Measured on
card #9834, whose error-counter half sat open as a draft while its duration half
had merged.
The open-PR list is already in hand from the H7/H12/H21 pass, so this adds no API
call; delivery is read through the same relation, branch-name fallback included.
Drafts are deliberately not filtered out — the specimen was one.
Implementer's call, with the caveat carried: downgrade, do not silence. Silence
would fix the harm and buy a new one, exactly as the card's caveat says — it loses
the genuine case where the last half is later abandoned, when the merged half
really is delivered and the card really is stale. So the half-delivered case emits
a distinct quieter sentence that names both sides, counts them (
N of M), and sayspm:dispatchedis correct here and must not be dropped. The rationale is in thedocblock, as asked. The destructive prescription now fires only when every
delivering PR has merged, and that path is pinned unchanged.
Verification
Landing class —
node scripts/pm/check-governed-merges.mjs --test, its own verdictline:
Gate families derived with
node scripts/pm/dispatch-gates.mjson the actual changeset (1 path). All 10 matched families run, each quoting its own verdict line:
check:pm-half-states(the patrol's self-test)✓ check-half-states self-test: 838 cases pass.check:cross-package-test-inputsOK: 13 package(s) read outside themselves, all declared…check:entry-guard✓ check:entry-guard: 136 scripts/ file(s) — every entry guard goes through invoked-as.mjscheck:parse-guardcheck:partof-closing-keyword✓ check-partof-closing-keyword self-test: 28 cases pass.check-ci-filter-parity.mjsOK: all 83 declared cross-package glob(s)…check-closing-keyword-parity.mjsOK (3 parsers agree on all 9 keywords…)check-cross-package-test-inputs.mjsOK: 13 package(s) read outside themselves…check-partof-closing-keyword.mjs(per-PR)check-half-states.mjs(live patrol)Self-test grew 793 → 838 cases (+45 across the three members).
Ablation, member 1. The branch fallback was removed from the committed state
(
return falsein its place), confirmed on disk by counting both the injectedmarker (1) and the removed text (0) rather than trusting the editor's exit code.
Direction observed: red,
exit 1— thrown at theRefs-specimen assertion, sothe crash preempts the tallied count rather than producing counted
✗rows. Norebuild leg applies: this script is executed directly from source by its own gate,
with no
dist/resolution in the path. Restore ran under anEXIT INT TERMtrapand was verified clean afterwards (
git status --porcelainempty, real line back,marker gone).
Lint, narrowed and declared. Repo-wide
pnpm lintis CI's run. Narrowed to thechanged file with all three pieces of evidence: the population is read from eslint's
own config (
--print-configresolves for this file), the count is read from--format json(1 file, 0 errors, 0 warnings), andparserOptions.projectisnull— type-aware linting is not enabled here, so a one-file diff cannot move theverdict of any untouched file.
Not run, and why: the live sweep (
node scripts/pm/check-half-states.mjs). Itsown
--probereportsPREREQUISITE NOT MET — the token in the environment is not a valid GitHub credentialandexit 3; the file header already records that the livesweep cannot run inside a PM session container. The H22 reader is therefore covered
by self-test and by the backfill's live read-back above, not by a local live run.
No changeset:
scripts/only, this PR publishes nothing. Carryingskip-changeset.Generated by Claude Code