Skip to content

Commit 50e894d

Browse files
fix(pm): a Blocked-by PR target is released on merge in every unlock path, and H19 holds an abandoned PR (#27983)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DDmpZzGjgrQKQq5kw75Ypsk)_ domain:skills#2 · os-elon-musk · session_016WV4cwiU8WPKdGvRr5uhB6 Fixes #27925 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:卡可以直接停在一个 PR 号上(`Blocked-by: #PR`),现在规则与巡查都按「已合并」放卡。`state-machine.md` 改写 PR 等待的写法和整点全量红卡刷新的放回条件;`dispatch-runbook.md` 的小时解锁扫描改为逐张 `pm:blocked` 卡正向读正文目标(issue 已关、PR 已合并才命中),与分诊现跑的扫描一致;`platform-readings.md` 定下一条通道规则(`Blocked-by:` 写正文,只有整点全量红卡写评论),`lanes/devx.md` 一句随之对齐。半状态巡查 H19 不再把「关闭未合并」的 PR 读成阻塞已过期。 - 为什么改:上一张卡允许停在 PR 号上之后,旧规则仍把 PR 等待指向它修的卡;旧的反向扫描所用的关闭列表列不出 PR(实测 MCP 列表 38 行、0 个 PR);H19 把弃置的 PR 读成「已关」并列为放回候选。卡可能再被困住,或被一个从未落地的 PR 放出。 - 风险与回滚:只动内部 PM 规则文本和 `scripts/pm/check-half-states.mjs`(只读巡查,不写标签),不发布任何包。H19 只多一种「不报过期」,不会多报。回滚即 revert。 - 先看:`scripts/pm/check-half-states.mjs` 的 `blockerResolutionFromRow` 与 `h19BlockOutlivedBlocker`;`state-machine.md` 第 24、31 行;`platform-readings.md` 第 250-252 行。 - 两分钟验证:`node scripts/pm/check-half-states.mjs --self-test`,预期末行报全部用例通过,电池清单里有 `H19 PR target merge reading 18/16`。 - 需要你做的:契约复审(contract review)尚欠,由席位达档复审;落地档位以登记册为准(`node scripts/pm/check-governed-merges.mjs --pr` 本 PR 号)。 ## What changes **H19 reads a PR target's merge, not its close.** The issues API answers a PR number `closed` whether it merged or was abandoned; only `pull_request.merged_at` tells them apart. Measured 2026-10-30 with `GET /issues/N`: #27888 `closed`, `merged_at` set; #27296 `closed`, `merged_at: null`; #27846 (an issue) has no `pull_request` key. `resolveBlockerTarget` kept `state` only, so H19 published a block on an abandoned PR as outlived, an unlock candidate whose fix never landed. - `blockerResolutionFromRow` (new, exported): the resolver's projection of one `/issues/N` answer. A closed PR with no `merged_at` keeps `state: 'closed'` and carries `unmerged: true`, the field `scheduled-full-run.mjs` already judges. A missing `merged_at` on a PR reads as unmerged, the direction that cannot release a card. Issue rows are byte-identical to before. - `h19BlockOutlivedBlocker`: an unmerged target founds no row. Carried by the judged set it withholds a full discharge (PARTIAL), is counted in the denominator, and is named `closed UNMERGED` on whichever row fires. Outside the judged set (archive-only beside a body line) it is the ordinary superseded clause, like any other spent close. - `namedTargets` renders `closed UNMERGED` for such a row; every other row renders as before. **Rule text: one form, one channel.** All four files stay at their line count (ceilings untouched), every changed line is at most 120 bytes. - `state-machine.md:24`: a card waiting on a PR writes the PR number, released on merge, never on a close without merge; the release-wait half now points at `:25`, which names that target. - `state-machine.md:31`: the full-run refresh releases only when every target is a closed local card, a PR one merged. - `dispatch-runbook.md:66`: the hourly unlock scan reads each `pm:blocked` card's body targets forward (issues closed, PRs merged), which is what the triage seat's `unlock` scan does (triage 6515340724). The old reverse join (`state=CLOSED` + `since` against the index) is gone. - `platform-readings.md:250-252`: a `Blocked-by:` line is written in the body, except on the full-run card (its body is rewritten on every red), matching the `triage-duties.md` line PR #27924 added. Old comment-borne lines are still backfilled incrementally; the half-state patrol and the full-run refresh read both channels. The unlock scan lists cards, reads each target directly, adds no standing comment read, and reads a PR's merge from `merged_at`. - `lanes/devx.md:26`: the lane keeps rulings and corrections in comments, `Blocked-by:` in the body (seat go, see Acceptance notes). ## Why After the previous card a card may park on a PR number, but `state-machine.md` still pointed a PR wait at the card the PR fixes, the reverse unlock scan could never see a PR target, and H19 read an abandoned PR as a released block. A card parked on a PR could strand again, or be released by a PR that never landed. Option A of the triage ruling: one body form that every unlock path reads, PR targets released on merged. No new gate. ## Tests `node scripts/pm/check-half-states.mjs --self-test`: new battery `H19 PR target merge reading`, 18 cases, pin 16. The measured API shapes go through the sweep's own projection, so the reading and the judgement are pinned together: - projection: merged PR → `closed`, unmarked; PR closed unmerged → `closed` + `unmerged`; PR with no `merged_at` key → `unmerged`; open PR → `open`; issue → judged by state alone; close time and labels kept. - H19: MERGED → outlived (full discharge); CLOSED UNMERGED → held, no row; OPEN → held, no row. - a closed issue beside an abandoned PR → fires on the issue only, 1 of 2, PARTIAL, never "every target is closed", the PR named `closed UNMERGED`. - the UNJUDGED row names a judged abandoned PR; an archive-only abandoned PR is the superseded clause, not a withhold; an ordinary closed target renders byte-identically. ## Acceptance notes - **Declared deviation, `lanes/devx.md:26`** (reported to the seat first, seat go). Before: 「卡片正文冻结在立卡那一刻,而本仓刻意把裁定、更正与 `Blocked-by:` 放在评论里。」 After: 「卡片正文冻结在立卡那一刻,而本仓刻意把裁定与更正放在评论里(`Blocked-by:` 归正文)。」 - **Declared deviation, `state-machine.md:24`.** The old line covered two waits in one clause (a PR merge, and a release with a pin), both pointing at a card that closes at that moment. The PR half now names the PR itself; the release half reads 「等发布见下行」, since `:25` already names that target (the pin-bump chore card). The qualifier 「且 pin 覆盖」 is carried by that target, not restated, to stay within the line's 120 bytes. - Noted, not filed: `proseBlockerClause` (H4's prose half) still reads a prose-named PR that closed unmerged as 「ALREADY CLOSED」. The resolution now carries `unmerged: true`, so honouring it is one filter. Report-only and outside the H19 ruling. - Noted, not filed: a block whose only target is an abandoned PR is now silent in H19 (held), and no merge will ever come to release it. H26 asks "can it ever close" only of open targets parked in `pm:on-hold` / `needs-user-decision`. Triage ruled no new gate; the seat may route it. - Noted, not filed: SKILL.md's status-model row still says 「#N 关闭时由解锁扫描…放回」. `state-machine.md` carries the merged condition for a PR; SKILL.md is outside this claim and on its own ratchet row. - Noted, not filed: H28 counts a body line naming an abandoned PR as a spent CLOSED target beside a live comment target. Its remedy (rewrite the body to name the live blocker) is still the right one. - Measured: REST `/issues?state=closed&since=2026-10-30T02:30:00Z` returns PRs (78 rows, 40 PRs, #27888, #27882 and #27870 included); MCP `list_issues` with `state: CLOSED` and the same `since` returns 38 rows and no PR. The gap is the MCP listing. Either listing still needs `merged_at` to tell a merge from an abandonment, so the rule names the forward per-card read. - **Branch name.** The card thread's platform-assigned branch `claude/project-thread-3r8sm7` names no issue; claim 6515710355 (amended) is the identity record. ## Verification - Ablation 1 (projection: `unmerged` forced `false`): self-test red, 9 of 5084 cases, all in the new battery (e.g. `H19 PR: CLOSED UNMERGED -> held: no row` got the full "outlived its blocker" row). Restored: blob == HEAD `8c1ace852181`, `git diff HEAD` empty. - Ablation 2 (judge: unmerged allowed to found the row): self-test red, 4 of 5084 cases. Restored the same way. - Both through `scripts/ablation-replace.mjs` (anchor hit 1 → 0, marker 0 → 1), on committed head `1b32a159`. - Gates: head `ffe534a1` (merge base `bacb3e6a0`): `node scripts/pm/dispatch-gates.mjs --commands` derived 56 families (re-derived after the last commit: identical). After `pnpm install --frozen-lockfile --ignore-scripts` and `pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` (for `check:doc-formula-expressions`), each ran with its exit code recorded before any pipe: all 56 exited 0. `--ran`: 56 derived, 56 run, 0 NOT-MEASURED, 0 UNRUN. This includes `check:pm-half-states`, `check:pm-skill-ratchet` (all four rule files at their ceilings: 41, 245, 458, 35/36), `check:pm-skill-id-lint`, and the self-tests of the scripts that import this file (`scheduled-full-run.mjs`, `board-snapshot.mjs`, `check-governed-queue-guard.mjs`). - Not measured locally, cited from CI instead: the repo-wide `pnpm lint` and the type-check lanes. This diff touches no TypeScript. - Register tier: `check-governed-merges.mjs --test` on the five paths answers **Tier S** (`.claude/**` ×4; `scripts/pm/check-half-states.mjs` not on the register), head repo and size not measured by that mode; the seat reads `--pr`. `dispatch-gates --tier`: no path-derived mandate; 138 changed lines (+119 / -19), under 3000. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; ESLint (`--no-inline-config`) on `scripts/pm/check-half-states.mjs` exits 0, and CI's `pnpm lint` is cited from the check-runs. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (publishes nothing: `skip-changeset`) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cwiU8WPKdGvRr5uhB6)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cwiU8WPKdGvRr5uhB6 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a0cc51f commit 50e894d

5 files changed

Lines changed: 119 additions & 19 deletions

File tree

‎.claude/skills/pm-dispatch/references/dispatch-runbook.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363

6464
- 小时层三读法之一,sweep 析取①–⑤ = `list_issues` + `since`。
6565
- 标签变更会刷新 `updated_at`,半标注照样入窗。
66-
- 之二,解锁扫描反演 = 逐仓 `state=CLOSED` + `since` 与 `Blocked-by:` 反向索引求交,命中走双查。
66+
- 之二,解锁扫描 = 逐张 `pm:blocked` 正向读正文目标:issue 全已关、PR 全已合并才命中走双查。
6767
- 之三,健康指标 = 逐标签 `perPage: 1` 只读 `totalCount`。
6868
- 裸卡数例外:标签缺席无计数可读,取本轮析取①的枚举,⛔ 不另发查询。
6969
- 每日层(当日首 fire)= 全量对账 + 归集本就日频的职责。

‎.claude/skills/pm-dispatch/references/lanes/devx.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
## 席内判断
2525

26-
- 卡片正文冻结在立卡那一刻,而本仓刻意把裁定、更正与 `Blocked-by:` 放在评论里。
26+
- 卡片正文冻结在立卡那一刻,而本仓刻意把裁定与更正放在评论里(`Blocked-by:` 归正文)。
2727
- ⇒ 超过一天的卡,正文是页面上最不当前的东西;先读晚于正文的评论再判可派性。
2828
- 量具在共享 checkout 上会静默说谎:读内容用 `git show origin/main:` 带路径。
2929
- 跑脚本必须在自己的 worktree;复核 diff 用具名 ref,⛔ 不用 `FETCH_HEAD`。

‎.claude/skills/pm-dispatch/references/platform-readings.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,9 +247,9 @@
247247
- 该丢弃亦及标题:自首个 tag 形跨度截到尾,是切不是洞 ⇒ 凭标题的总体计数只是下界。
248248
- 写侧剥除是真实存储损耗,⛔ 两类不并成一条,写后回读必做;实体归属与 `&amp;` 类未实测。
249249
- 评论里独立成行的标记同被读路径吃掉留空行 ⇒ 缺失不证写侧剥除,先读 payload 原体。
250-
- `Blocked-by:` 行归 BODY,是单通道反向索引:追加按解码后写回执行。
251-
- 历史上寄放在评论里的行按同程序增量回填,⛔ 不搞批量突击。
252-
- 解锁扫描只 grep body,⛔ 不加常设评论读;扫描走 `list_issues` 加 `issue_read` 直读。
250+
- `Blocked-by:` 行写入归 BODY,唯 full-run 卡写评论(其正文每红重写);追加按解码后写回执行。
251+
- 评论里的旧行按同程序增量回填,⛔ 不搞批量突击;半状态巡查与 full-run 刷新读双通道。
252+
- 解锁扫描 `list_issues` 列卡、`issue_read` 直读目标,⛔ 不加常设评论读;PR 判合并读 `merged_at`。
253253
- 动手前按空的行内代码跨度判 MCP 读可用否;命中即改从原始体取,⛔ 不拿有损读数回写。
254254
- `list_issue_types` 对本集成 403 而 `issue_write type:` 正常,是读权限缺口。
255255
- ⇒ 直接写已知好值(`Bug`/`Feature`/`Task`),写侧报错才是真信号;列表 403 不等于类型不可用。

‎.claude/skills/pm-dispatch/references/state-machine.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,14 @@
2121
## `pm:blocked`
2222

2323
- 正典标记 = 行首 `Blocked-by: #N`(跨仓 `owner/repo#N`)、写时无装饰;读者兼收列表/粗体/反引号。
24-
- 句中提及刻意拒收;等 PR 合并或等发布且 pin 覆盖的卡同理,指一张在该刻关闭的卡。
24+
- 句中提及刻意拒收;等 PR 合并写该 PR 号:合并才放,关而未合并 ⛔ 不放;等发布见下行。
2525
- ⛔ 不扩词表;等发布的目标 = pin-bump 杂事卡,`Part of` 型 PR 的跟踪卡 ⛔ 不可用作目标。
2626
- 工已完而被门禁缺陷卡住者同用本态,⛔ 不设新标签、不重派;`Blocked-by:` 指向门禁卡。
2727
- 正文行 `Unlock-action: re-check PR #M`(重查落地)或 `re-check #N when label <标签> <absent|present>`。
2828
- 只认此二值,别的拼写静默回落重派,散文另起行;停放的 PR 正文须点名那张门禁卡。
2929
- 上游已关 ⇒ 先重新推导是否有新阻塞并改写该行,⛔ 不反射式放行。
3030
- `scheduled-full-run-card.yml` 经 `scripts/ci/scheduled-full-run.mjs` 刷新小时红卡时亦放回,规则更窄:
31-
- 双通道目标全为本仓已关卡、无 `Unlock-action:` 行、无 assignee 才放;跨仓已关不放。
31+
- 双通道目标全为本仓已关卡(PR 已合并)、无 `Unlock-action:` 行与 assignee 才放;跨仓已关不放。
3232
- 正文是正典家:新 blocker 停在评论、正文留已关旧目标 ⇒ 巡查报 stale 并要求迁回正文。
3333

3434
## `pm:awaiting-maintainer` 与 `pm:retriage`

‎scripts/pm/check-half-states.mjs‎

Lines changed: 112 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,10 @@
404404
*
405405
* ## H19 — the question that ENDS a block
406406
*
407-
* H19 an open `pm:blocked` card whose `Blocked-by:` target has CLOSED — the
407+
* H19 an open `pm:blocked` card whose `Blocked-by:` target has CLOSED — for a
408+
* PR target, MERGED: the issues API answers an abandoned PR `closed` too,
409+
* and a PR that never merged landed nothing, so it founds no row and
410+
* withholds a full discharge (#27925) — the
408411
* block has outlived its blocker. Two items already read that line and
409412
* NEITHER expires a block: H4 asks whether the line EXISTS, H14 asks the
410413
* REVERSE index (does anything target THIS card). Nothing asked whether
@@ -5093,6 +5096,44 @@ export function needsBlockerLivenessComments(issue) {
50935096
return needsBlockerLiveness(issue);
50945097
}
50955098

5099+
/**
5100+
* One `GET /repos/<o>/<n>/issues/<N>` answer as the resolution H19 judges —
5101+
* exported so the reading is driven offline in the self-test rather than only
5102+
* inside the sweep's resolver, which needs the network.
5103+
*
5104+
* ## A PR target is released by its MERGE, never by its close (#27925)
5105+
*
5106+
* A card may park on a PR number (`state-machine.md`, `pm:blocked`), and the
5107+
* issues API answers a PR number in its PR shape: `state` reads `closed`
5108+
* whether the PR merged or was abandoned, and only `pull_request.merged_at`
5109+
* tells the two apart (measured on this board: a merged PR and a closed-
5110+
* unmerged one both answer `closed`, `merged_at` set on the first and `null` on
5111+
* the second; an issue carries no `pull_request` key). Reading `state` alone
5112+
* published a block on an ABANDONED PR as outlived — an unlock candidate whose
5113+
* fix never landed anywhere. So a closed PR with no `merged_at` keeps
5114+
* `state: 'closed'` (every other reader of these rows asks what the API said)
5115+
* and carries `unmerged: true`, the same field `scheduled-full-run.mjs`'s
5116+
* refresh judges, and `h19BlockOutlivedBlocker` lets it found nothing. A
5117+
* missing `merged_at` on a PR reads as unmerged: the one direction that cannot
5118+
* release a card.
5119+
*
5120+
* @param {{ key: string, repo: string, number: number, local: boolean }} target
5121+
* @param {{ state?: string, closed_at?: string|null, labels?: any[],
5122+
* pull_request?: { merged_at?: string|null } }} row
5123+
*/
5124+
export function blockerResolutionFromRow(target, row) {
5125+
const state = row?.state === 'closed' ? 'closed' : 'open';
5126+
const unmerged = state === 'closed' && row?.pull_request != null && !row.pull_request.merged_at;
5127+
return {
5128+
...target,
5129+
state,
5130+
closedAt: row?.closed_at ?? null,
5131+
detail: null,
5132+
labels: labelNames(row ?? {}),
5133+
...(unmerged ? { unmerged: true } : {}),
5134+
};
5135+
}
5136+
50965137
/**
50975138
* How many targets a row names before it counts the rest — the same render
50985139
* budget `BLOCKING_DEPENDENT_LIST_CAP` keeps, for the same reason (the
@@ -5117,7 +5158,10 @@ function namedTargets(rows) {
51175158
const named = shown
51185159
.map((r) => {
51195160
const ref = `\`${r.local ? `#${r.number}` : r.key}\``;
5120-
if (r.state === 'closed') return `${ref}${r.closedAt ? ` (closed ${r.closedAt})` : ' (closed)'}`;
5161+
if (r.state === 'closed') {
5162+
const word = r.unmerged === true ? 'closed UNMERGED' : 'closed';
5163+
return `${ref}${r.closedAt ? ` (${word} ${r.closedAt})` : ` (${word})`}`;
5164+
}
51215165
if (r.state === 'unresolved') {
51225166
const why =
51235167
r.repoReadable === false
@@ -5354,13 +5398,19 @@ export function h19BlockOutlivedBlocker(issue, resolutions, ownerRepo = OWNER_RE
53545398
// and that evidence is never discarded by carrier (#11747's re-park parks the
53555399
// LIVE blocker in a comment while the body still names the spent one): it is
53565400
// counted whichever channel carries it and can only ever WITHHOLD a row.
5357-
const closed = rows.filter((r) => r.state === 'closed' && carries(r));
5401+
//
5402+
// A PR target that closed UNMERGED (#27925) never landed its fix, so its close
5403+
// is not the event the line waits for: it founds nothing and, carried by the
5404+
// judged set, withholds a full discharge. Outside the judged set it is the
5405+
// same spent archive line as any other superseded close.
5406+
const closed = rows.filter((r) => r.state === 'closed' && r.unmerged !== true && carries(r));
5407+
const unmerged = rows.filter((r) => r.state === 'closed' && r.unmerged === true && carries(r));
53585408
const superseded = rows.filter((r) => r.state === 'closed' && !carries(r));
53595409
const unresolved = rows.filter((r) => r.state === 'unresolved' && carries(r));
53605410
const supersededUnreadable = rows.filter((r) => r.state === 'unresolved' && !carries(r));
53615411
const open = rows.filter((r) => r.state === 'open');
53625412
if (closed.length === 0 && unresolved.length === 0) return null;
5363-
const judgedCount = closed.length + open.length + unresolved.length;
5413+
const judgedCount = closed.length + open.length + unresolved.length + unmerged.length;
53645414
const carrier =
53655415
judgedFrom === 'body'
53665416
? 'judged against the BODY\'s `Blocked-by:` set, the authoritative carrier whenever the body ' +
@@ -5409,13 +5459,25 @@ export function h19BlockOutlivedBlocker(issue, resolutions, ownerRepo = OWNER_RE
54095459
'`pm:queue` still carrying the assignee of the seat that parked it is dispatchable to the queue ' +
54105460
'view and taken to the claim rule at the same time (H24), which is the state the unlock scan was ' +
54115461
'measured leaving behind — ⚠️ agent identity only, a HUMAN assignment is ⛔ never cleared by an agent.';
5462+
// Named on whichever row fires; it never founds one (#27925).
5463+
const unmergedNote =
5464+
unmerged.length === 0
5465+
? ''
5466+
: ` ${unmerged.length} judged target(s) are PRs that closed UNMERGED (${namedTargets(unmerged)}): the ` +
5467+
'issues API answers a PR `closed` whether it merged or was abandoned, and only `merged_at` tells ' +
5468+
'them apart. A PR that never merged landed nothing, so its close is ⛔ never an unlock — counted in ' +
5469+
'the denominator, never as closed.';
54125470

54135471
if (closed.length > 0) {
54145472
const rest =
54155473
open.length > 0
54165474
? ` ${open.length} target(s) are still open (${namedTargets(open)}), so this is a PARTIAL ` +
54175475
'discharge and the card may still be legitimately blocked — the row reports it, it does not ' +
54185476
'decide it.'
5477+
: unmerged.length > 0
5478+
? ' No target this row JUDGED is still open, but a PR that closed unmerged withholds, so this is ' +
5479+
'a PARTIAL discharge and the card may still be legitimately blocked — the row reports it, it ' +
5480+
'does not decide it.'
54195481
: supersededUnreadable.length > 0
54205482
? ` No target this row JUDGED is still open, but ${supersededUnreadable.length} target(s) ` +
54215483
`the comment archive names (${namedTargets(supersededUnreadable)}) could not be read at ` +
@@ -5436,6 +5498,7 @@ export function h19BlockOutlivedBlocker(issue, resolutions, ownerRepo = OWNER_RE
54365498
'row anywhere: one measured card sat ~4.5h past its blocker\'s close and was found only by a human ' +
54375499
'walking the graph, another was released only by a manual triage pass.' +
54385500
rest +
5501+
unmergedNote +
54395502
alsoUnresolved +
54405503
supersededNote +
54415504
supersededUnreadableNote +
@@ -5466,6 +5529,7 @@ export function h19BlockOutlivedBlocker(issue, resolutions, ownerRepo = OWNER_RE
54665529
(open.length > 0
54675530
? ` The card's other ${open.length} target(s) did resolve, and are still open.`
54685531
: '') +
5532+
unmergedNote +
54695533
supersededNote +
54705534
supersededUnreadableNote +
54715535
release
@@ -23900,14 +23964,8 @@ async function sweepInto(findings, seen, seenPrs, seenMerged, seenUnscoped, seen
2390023964
resolved = { ...target, state: 'open', closedAt: null, detail: null, labels: labelNames(localOpen) };
2390123965
} else {
2390223966
try {
23903-
const row = await rest(`/repos/${target.repo}/issues/${target.number}`);
23904-
resolved = {
23905-
...target,
23906-
state: row.state === 'closed' ? 'closed' : 'open',
23907-
closedAt: row.closed_at ?? null,
23908-
detail: null,
23909-
labels: labelNames(row),
23910-
};
23967+
// A PR number answers `closed` merged or not; the projection carries `merged_at`.
23968+
resolved = blockerResolutionFromRow(target, await rest(`/repos/${target.repo}/issues/${target.number}`));
2391123969
} catch (err) {
2391223970
// Per-target, never fatal — and deliberately NOT the rethrow H16/H17
2391323971
// and the comment fallback make on a total shortfall. Those passes
@@ -24480,6 +24538,8 @@ export const SELF_TEST_BATTERIES = Object.freeze({
2448024538
// discharge). A negative with no live control beside it is how a repair
2448124539
// becomes a silencer, so the controls are inside the same floored battery.
2448224540
'H19 judged-set founding': 34,
24541+
// Registered with the PR-target merge reading (#27925), 18 cases, pin just under the count: a WITHHOLD with its controls inside.
24542+
'H19 PR target merge reading': 16,
2448324543
// Registered with the second-spelling repair (#18547): 45 cases, the pin just
2448424544
// under the count on the same grounds as its two neighbours. What this
2448524545
// battery floors is a WIDENING — the row stopped reporting a Chinese
@@ -27058,6 +27118,46 @@ async function selfTest() {
2705827118
t('summary: …and the H19 clause never prints the string undefined', saidBy('h19Blockers', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0 }, 0)).includes('undefined'), false);
2705927119
t('summary: the report-only contract still ends the sentence', summaryLine(btCounts(12, 12), 1).endsWith('not a gate verdict.'), true);
2706027120

27121+
// -- H19: a PR target is released by its MERGE, never by its close (#27925) --
27122+
//
27123+
// A card may park on a PR number, and the issues API answers one `closed`
27124+
// whether it merged or was abandoned. The rows below are the shapes measured
27125+
// on this board 2026-10-30 (`GET /issues/N`): #27888 merged, #27296 closed
27126+
// unmerged, #27846 an issue (no `pull_request` key). They go through the
27127+
// sweep's own projection, so the reading AND the judgement are pinned: merged
27128+
// -> outlived, closed unmerged -> held, open -> held.
27129+
const BATTERY_PR_MERGE = 'H19 PR target merge reading';
27130+
const merged27888 = { state: 'closed', closed_at: '2026-10-30T03:29:36Z', labels: [], pull_request: { merged_at: '2026-10-30T03:29:36Z' } };
27131+
const abandoned27296 = { state: 'closed', closed_at: '2026-10-28T18:30:39Z', labels: [], pull_request: { merged_at: null } };
27132+
const issue27846 = { state: 'closed', closed_at: '2026-10-30T05:23:54Z', labels: [] };
27133+
const openPr19 = { state: 'open', closed_at: null, labels: [], pull_request: { merged_at: null } };
27134+
const resolvedFrom = (number, row) => blockerResolutionFromRow(target(number, 'unread'), row);
27135+
b(BATTERY_PR_MERGE, 'H19 PR: a merged PR reads closed, and carries no unmerged mark', [resolvedFrom(27888, merged27888).state, resolvedFrom(27888, merged27888).unmerged].join(), 'closed,');
27136+
b(BATTERY_PR_MERGE, 'H19 PR: a PR closed unmerged reads closed AND unmerged', [resolvedFrom(27296, abandoned27296).state, resolvedFrom(27296, abandoned27296).unmerged].join(), 'closed,true');
27137+
b(BATTERY_PR_MERGE, 'H19 PR: a closed PR whose merged_at is absent reads unmerged (the side that cannot release)', resolvedFrom(1, { state: 'closed', labels: [], pull_request: {} }).unmerged, true);
27138+
b(BATTERY_PR_MERGE, 'H19 PR: an open PR reads open, unmarked', [resolvedFrom(1, openPr19).state, resolvedFrom(1, openPr19).unmerged].join(), 'open,');
27139+
b(BATTERY_PR_MERGE, 'H19 PR: an ISSUE (no pull_request key) is judged by state alone, as before', [resolvedFrom(27846, issue27846).state, resolvedFrom(27846, issue27846).unmerged].join(), 'closed,');
27140+
b(BATTERY_PR_MERGE, 'H19 PR: …and the projection keeps the close time and the labels the other rows read', [resolvedFrom(27846, issue27846).closedAt, resolvedFrom(1, { ...openPr19, labels: [{ name: 'pm:on-hold' }] }).labels.join()].join(' '), '2026-10-30T05:23:54Z pm:on-hold');
27141+
const prCard = blockedCard(27925, 'Blocked-by: #27888');
27142+
const mergedRow = h19row(prCard, [resolvedFrom(27888, merged27888)], REPO_OS);
27143+
b(BATTERY_PR_MERGE, 'H19 PR: MERGED -> outlived: the row fires', mergedRow.includes('outlived its blocker'), true);
27144+
b(BATTERY_PR_MERGE, 'H19 PR: …as a full discharge', mergedRow.includes('Every target it names is closed'), true);
27145+
b(BATTERY_PR_MERGE, 'H19 PR: CLOSED UNMERGED -> held: no row', h19BlockOutlivedBlocker(blockedCard(27925, 'Blocked-by: #27296'), [resolvedFrom(27296, abandoned27296)], REPO_OS), null);
27146+
b(BATTERY_PR_MERGE, 'H19 PR: OPEN -> held: no row', h19BlockOutlivedBlocker(blockedCard(27925, 'Blocked-by: #2'), [resolvedFrom(2, openPr19)], REPO_OS), null);
27147+
// An abandoned PR beside a closed issue withholds the discharge and is named.
27148+
const mixedPr19 = h19row(blockedCard(27925, 'Blocked-by: #27846, #27296'), [resolvedFrom(27846, issue27846), resolvedFrom(27296, abandoned27296)], REPO_OS);
27149+
b(BATTERY_PR_MERGE, 'H19 PR: a closed issue beside an abandoned PR still fires on the issue', mixedPr19.includes('1 of 2 `Blocked-by:` target(s)'), true);
27150+
b(BATTERY_PR_MERGE, 'H19 PR: …as a PARTIAL discharge', mixedPr19.includes('PARTIAL'), true);
27151+
b(BATTERY_PR_MERGE, 'H19 PR: …⛔ never claiming every target is closed', mixedPr19.includes('Every target it names is closed'), false);
27152+
b(BATTERY_PR_MERGE, 'H19 PR: …and names the abandoned PR as closed UNMERGED', mixedPr19.includes('`#27296` (closed UNMERGED 2026-10-28T18:30:39Z)'), true);
27153+
b(BATTERY_PR_MERGE, 'H19 PR: …and never as the closed target the row is founded on', mixedPr19.includes('CLOSED (`#27846` (closed 2026-10-30T05:23:54Z))'), true);
27154+
// The UNJUDGED row names it too, and the archive keeps its old power only.
27155+
const unjudgedPr = h19row(blockedCard(1, 'Blocked-by: #2, #27296'), [target(2, 'unresolved', { detail: 'HTTP 404' }), resolvedFrom(27296, abandoned27296)], REPO_OS);
27156+
b(BATTERY_PR_MERGE, 'H19 PR: an UNJUDGED row names a judged abandoned PR', [unjudgedPr.includes('UNJUDGED, not confirmed'), unjudgedPr.includes('closed UNMERGED')].join(), 'true,true');
27157+
const archivePr = h19row(blockedCard(1, 'Blocked-by: #2'), [target(2, 'closed'), resolvedFrom(27296, abandoned27296)], REPO_OS);
27158+
b(BATTERY_PR_MERGE, 'H19 PR control: an archive-only abandoned PR is the superseded clause, not a withhold', [archivePr.includes('Every target it names is closed'), archivePr.includes('reach this card ONLY through the comment archive')].join(), 'true,true');
27159+
b(BATTERY_PR_MERGE, 'H19 PR control: an ordinary closed target renders byte-identically', h19row(blockedCard(1, 'Blocked-by: #2'), [target(2, 'closed', { closedAt: '2026-08-20T07:58:08Z' })], REPO_OS).includes('`#2` (closed 2026-08-20T07:58:08Z)'), true);
27160+
2706127161
// -- H20: a `pm:dispatched` card nobody is working (#10312) ----------------
2706227162
//
2706327163
// The specimen is #8878, 2026-08-20: a well-formed claim comment at ~14:05Z,

0 commit comments

Comments
 (0)