diff --git a/.claude/skills/pm-dispatch/references/dispatch-runbook.md b/.claude/skills/pm-dispatch/references/dispatch-runbook.md index 8b74be4cbaa..784c1294f33 100644 --- a/.claude/skills/pm-dispatch/references/dispatch-runbook.md +++ b/.claude/skills/pm-dispatch/references/dispatch-runbook.md @@ -63,7 +63,7 @@ - 小时层三读法之一,sweep 析取①–⑤ = `list_issues` + `since`。 - 标签变更会刷新 `updated_at`,半标注照样入窗。 -- 之二,解锁扫描反演 = 逐仓 `state=CLOSED` + `since` 与 `Blocked-by:` 反向索引求交,命中走双查。 +- 之二,解锁扫描 = 逐张 `pm:blocked` 正向读正文目标:issue 全已关、PR 全已合并才命中走双查。 - 之三,健康指标 = 逐标签 `perPage: 1` 只读 `totalCount`。 - 裸卡数例外:标签缺席无计数可读,取本轮析取①的枚举,⛔ 不另发查询。 - 每日层(当日首 fire)= 全量对账 + 归集本就日频的职责。 diff --git a/.claude/skills/pm-dispatch/references/lanes/devx.md b/.claude/skills/pm-dispatch/references/lanes/devx.md index c794867f9f1..72c93547b88 100644 --- a/.claude/skills/pm-dispatch/references/lanes/devx.md +++ b/.claude/skills/pm-dispatch/references/lanes/devx.md @@ -23,7 +23,7 @@ ## 席内判断 -- 卡片正文冻结在立卡那一刻,而本仓刻意把裁定、更正与 `Blocked-by:` 放在评论里。 +- 卡片正文冻结在立卡那一刻,而本仓刻意把裁定与更正放在评论里(`Blocked-by:` 归正文)。 - ⇒ 超过一天的卡,正文是页面上最不当前的东西;先读晚于正文的评论再判可派性。 - 量具在共享 checkout 上会静默说谎:读内容用 `git show origin/main:` 带路径。 - 跑脚本必须在自己的 worktree;复核 diff 用具名 ref,⛔ 不用 `FETCH_HEAD`。 diff --git a/.claude/skills/pm-dispatch/references/platform-readings.md b/.claude/skills/pm-dispatch/references/platform-readings.md index cd8dffbdbf7..da66b45cb0a 100644 --- a/.claude/skills/pm-dispatch/references/platform-readings.md +++ b/.claude/skills/pm-dispatch/references/platform-readings.md @@ -247,9 +247,9 @@ - 该丢弃亦及标题:自首个 tag 形跨度截到尾,是切不是洞 ⇒ 凭标题的总体计数只是下界。 - 写侧剥除是真实存储损耗,⛔ 两类不并成一条,写后回读必做;实体归属与 `&` 类未实测。 - 评论里独立成行的标记同被读路径吃掉留空行 ⇒ 缺失不证写侧剥除,先读 payload 原体。 -- `Blocked-by:` 行归 BODY,是单通道反向索引:追加按解码后写回执行。 -- 历史上寄放在评论里的行按同程序增量回填,⛔ 不搞批量突击。 -- 解锁扫描只 grep body,⛔ 不加常设评论读;扫描走 `list_issues` 加 `issue_read` 直读。 +- `Blocked-by:` 行写入归 BODY,唯 full-run 卡写评论(其正文每红重写);追加按解码后写回执行。 +- 评论里的旧行按同程序增量回填,⛔ 不搞批量突击;半状态巡查与 full-run 刷新读双通道。 +- 解锁扫描 `list_issues` 列卡、`issue_read` 直读目标,⛔ 不加常设评论读;PR 判合并读 `merged_at`。 - 动手前按空的行内代码跨度判 MCP 读可用否;命中即改从原始体取,⛔ 不拿有损读数回写。 - `list_issue_types` 对本集成 403 而 `issue_write type:` 正常,是读权限缺口。 - ⇒ 直接写已知好值(`Bug`/`Feature`/`Task`),写侧报错才是真信号;列表 403 不等于类型不可用。 diff --git a/.claude/skills/pm-dispatch/references/state-machine.md b/.claude/skills/pm-dispatch/references/state-machine.md index 9494cac8caa..93c014fd013 100644 --- a/.claude/skills/pm-dispatch/references/state-machine.md +++ b/.claude/skills/pm-dispatch/references/state-machine.md @@ -21,14 +21,14 @@ ## `pm:blocked` - 正典标记 = 行首 `Blocked-by: #N`(跨仓 `owner/repo#N`)、写时无装饰;读者兼收列表/粗体/反引号。 -- 句中提及刻意拒收;等 PR 合并或等发布且 pin 覆盖的卡同理,指一张在该刻关闭的卡。 +- 句中提及刻意拒收;等 PR 合并写该 PR 号:合并才放,关而未合并 ⛔ 不放;等发布见下行。 - ⛔ 不扩词表;等发布的目标 = pin-bump 杂事卡,`Part of` 型 PR 的跟踪卡 ⛔ 不可用作目标。 - 工已完而被门禁缺陷卡住者同用本态,⛔ 不设新标签、不重派;`Blocked-by:` 指向门禁卡。 - 正文行 `Unlock-action: re-check PR #M`(重查落地)或 `re-check #N when label <标签> `。 - 只认此二值,别的拼写静默回落重派,散文另起行;停放的 PR 正文须点名那张门禁卡。 - 上游已关 ⇒ 先重新推导是否有新阻塞并改写该行,⛔ 不反射式放行。 - `scheduled-full-run-card.yml` 经 `scripts/ci/scheduled-full-run.mjs` 刷新小时红卡时亦放回,规则更窄: -- 双通道目标全为本仓已关卡、无 `Unlock-action:` 行、无 assignee 才放;跨仓已关不放。 +- 双通道目标全为本仓已关卡(PR 已合并)、无 `Unlock-action:` 行与 assignee 才放;跨仓已关不放。 - 正文是正典家:新 blocker 停在评论、正文留已关旧目标 ⇒ 巡查报 stale 并要求迁回正文。 ## `pm:awaiting-maintainer` 与 `pm:retriage` diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index f5c5c23fd58..8c1ace85218 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -404,7 +404,10 @@ * * ## H19 — the question that ENDS a block * - * H19 an open `pm:blocked` card whose `Blocked-by:` target has CLOSED — the + * H19 an open `pm:blocked` card whose `Blocked-by:` target has CLOSED — for a + * PR target, MERGED: the issues API answers an abandoned PR `closed` too, + * and a PR that never merged landed nothing, so it founds no row and + * withholds a full discharge (#27925) — the * block has outlived its blocker. Two items already read that line and * NEITHER expires a block: H4 asks whether the line EXISTS, H14 asks the * REVERSE index (does anything target THIS card). Nothing asked whether @@ -5093,6 +5096,44 @@ export function needsBlockerLivenessComments(issue) { return needsBlockerLiveness(issue); } +/** + * One `GET /repos///issues/` answer as the resolution H19 judges — + * exported so the reading is driven offline in the self-test rather than only + * inside the sweep's resolver, which needs the network. + * + * ## A PR target is released by its MERGE, never by its close (#27925) + * + * A card may park on a PR number (`state-machine.md`, `pm:blocked`), and the + * issues API answers a PR number in its PR shape: `state` reads `closed` + * whether the PR merged or was abandoned, and only `pull_request.merged_at` + * tells the two apart (measured on this board: a merged PR and a closed- + * unmerged one both answer `closed`, `merged_at` set on the first and `null` on + * the second; an issue carries no `pull_request` key). Reading `state` alone + * published a block on an ABANDONED PR as outlived — an unlock candidate whose + * fix never landed anywhere. So a closed PR with no `merged_at` keeps + * `state: 'closed'` (every other reader of these rows asks what the API said) + * and carries `unmerged: true`, the same field `scheduled-full-run.mjs`'s + * refresh judges, and `h19BlockOutlivedBlocker` lets it found nothing. A + * missing `merged_at` on a PR reads as unmerged: the one direction that cannot + * release a card. + * + * @param {{ key: string, repo: string, number: number, local: boolean }} target + * @param {{ state?: string, closed_at?: string|null, labels?: any[], + * pull_request?: { merged_at?: string|null } }} row + */ +export function blockerResolutionFromRow(target, row) { + const state = row?.state === 'closed' ? 'closed' : 'open'; + const unmerged = state === 'closed' && row?.pull_request != null && !row.pull_request.merged_at; + return { + ...target, + state, + closedAt: row?.closed_at ?? null, + detail: null, + labels: labelNames(row ?? {}), + ...(unmerged ? { unmerged: true } : {}), + }; +} + /** * How many targets a row names before it counts the rest — the same render * budget `BLOCKING_DEPENDENT_LIST_CAP` keeps, for the same reason (the @@ -5117,7 +5158,10 @@ function namedTargets(rows) { const named = shown .map((r) => { const ref = `\`${r.local ? `#${r.number}` : r.key}\``; - if (r.state === 'closed') return `${ref}${r.closedAt ? ` (closed ${r.closedAt})` : ' (closed)'}`; + if (r.state === 'closed') { + const word = r.unmerged === true ? 'closed UNMERGED' : 'closed'; + return `${ref}${r.closedAt ? ` (${word} ${r.closedAt})` : ` (${word})`}`; + } if (r.state === 'unresolved') { const why = r.repoReadable === false @@ -5354,13 +5398,19 @@ export function h19BlockOutlivedBlocker(issue, resolutions, ownerRepo = OWNER_RE // and that evidence is never discarded by carrier (#11747's re-park parks the // LIVE blocker in a comment while the body still names the spent one): it is // counted whichever channel carries it and can only ever WITHHOLD a row. - const closed = rows.filter((r) => r.state === 'closed' && carries(r)); + // + // A PR target that closed UNMERGED (#27925) never landed its fix, so its close + // is not the event the line waits for: it founds nothing and, carried by the + // judged set, withholds a full discharge. Outside the judged set it is the + // same spent archive line as any other superseded close. + const closed = rows.filter((r) => r.state === 'closed' && r.unmerged !== true && carries(r)); + const unmerged = rows.filter((r) => r.state === 'closed' && r.unmerged === true && carries(r)); const superseded = rows.filter((r) => r.state === 'closed' && !carries(r)); const unresolved = rows.filter((r) => r.state === 'unresolved' && carries(r)); const supersededUnreadable = rows.filter((r) => r.state === 'unresolved' && !carries(r)); const open = rows.filter((r) => r.state === 'open'); if (closed.length === 0 && unresolved.length === 0) return null; - const judgedCount = closed.length + open.length + unresolved.length; + const judgedCount = closed.length + open.length + unresolved.length + unmerged.length; const carrier = judgedFrom === 'body' ? 'judged against the BODY\'s `Blocked-by:` set, the authoritative carrier whenever the body ' + @@ -5409,6 +5459,14 @@ export function h19BlockOutlivedBlocker(issue, resolutions, ownerRepo = OWNER_RE '`pm:queue` still carrying the assignee of the seat that parked it is dispatchable to the queue ' + 'view and taken to the claim rule at the same time (H24), which is the state the unlock scan was ' + 'measured leaving behind — ⚠️ agent identity only, a HUMAN assignment is ⛔ never cleared by an agent.'; + // Named on whichever row fires; it never founds one (#27925). + const unmergedNote = + unmerged.length === 0 + ? '' + : ` ${unmerged.length} judged target(s) are PRs that closed UNMERGED (${namedTargets(unmerged)}): the ` + + 'issues API answers a PR `closed` whether it merged or was abandoned, and only `merged_at` tells ' + + 'them apart. A PR that never merged landed nothing, so its close is ⛔ never an unlock — counted in ' + + 'the denominator, never as closed.'; if (closed.length > 0) { const rest = @@ -5416,6 +5474,10 @@ export function h19BlockOutlivedBlocker(issue, resolutions, ownerRepo = OWNER_RE ? ` ${open.length} target(s) are still open (${namedTargets(open)}), so this is a PARTIAL ` + 'discharge and the card may still be legitimately blocked — the row reports it, it does not ' + 'decide it.' + : unmerged.length > 0 + ? ' No target this row JUDGED is still open, but a PR that closed unmerged withholds, so this is ' + + 'a PARTIAL discharge and the card may still be legitimately blocked — the row reports it, it ' + + 'does not decide it.' : supersededUnreadable.length > 0 ? ` No target this row JUDGED is still open, but ${supersededUnreadable.length} target(s) ` + `the comment archive names (${namedTargets(supersededUnreadable)}) could not be read at ` + @@ -5436,6 +5498,7 @@ export function h19BlockOutlivedBlocker(issue, resolutions, ownerRepo = OWNER_RE 'row anywhere: one measured card sat ~4.5h past its blocker\'s close and was found only by a human ' + 'walking the graph, another was released only by a manual triage pass.' + rest + + unmergedNote + alsoUnresolved + supersededNote + supersededUnreadableNote + @@ -5466,6 +5529,7 @@ export function h19BlockOutlivedBlocker(issue, resolutions, ownerRepo = OWNER_RE (open.length > 0 ? ` The card's other ${open.length} target(s) did resolve, and are still open.` : '') + + unmergedNote + supersededNote + supersededUnreadableNote + release @@ -23900,14 +23964,8 @@ async function sweepInto(findings, seen, seenPrs, seenMerged, seenUnscoped, seen resolved = { ...target, state: 'open', closedAt: null, detail: null, labels: labelNames(localOpen) }; } else { try { - const row = await rest(`/repos/${target.repo}/issues/${target.number}`); - resolved = { - ...target, - state: row.state === 'closed' ? 'closed' : 'open', - closedAt: row.closed_at ?? null, - detail: null, - labels: labelNames(row), - }; + // A PR number answers `closed` merged or not; the projection carries `merged_at`. + resolved = blockerResolutionFromRow(target, await rest(`/repos/${target.repo}/issues/${target.number}`)); } catch (err) { // Per-target, never fatal — and deliberately NOT the rethrow H16/H17 // and the comment fallback make on a total shortfall. Those passes @@ -24480,6 +24538,8 @@ export const SELF_TEST_BATTERIES = Object.freeze({ // discharge). A negative with no live control beside it is how a repair // becomes a silencer, so the controls are inside the same floored battery. 'H19 judged-set founding': 34, + // Registered with the PR-target merge reading (#27925), 18 cases, pin just under the count: a WITHHOLD with its controls inside. + 'H19 PR target merge reading': 16, // Registered with the second-spelling repair (#18547): 45 cases, the pin just // under the count on the same grounds as its two neighbours. What this // battery floors is a WIDENING — the row stopped reporting a Chinese @@ -27058,6 +27118,46 @@ async function selfTest() { 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); t('summary: the report-only contract still ends the sentence', summaryLine(btCounts(12, 12), 1).endsWith('not a gate verdict.'), true); + // -- H19: a PR target is released by its MERGE, never by its close (#27925) -- + // + // A card may park on a PR number, and the issues API answers one `closed` + // whether it merged or was abandoned. The rows below are the shapes measured + // on this board 2026-10-30 (`GET /issues/N`): #27888 merged, #27296 closed + // unmerged, #27846 an issue (no `pull_request` key). They go through the + // sweep's own projection, so the reading AND the judgement are pinned: merged + // -> outlived, closed unmerged -> held, open -> held. + const BATTERY_PR_MERGE = 'H19 PR target merge reading'; + const merged27888 = { state: 'closed', closed_at: '2026-10-30T03:29:36Z', labels: [], pull_request: { merged_at: '2026-10-30T03:29:36Z' } }; + const abandoned27296 = { state: 'closed', closed_at: '2026-10-28T18:30:39Z', labels: [], pull_request: { merged_at: null } }; + const issue27846 = { state: 'closed', closed_at: '2026-10-30T05:23:54Z', labels: [] }; + const openPr19 = { state: 'open', closed_at: null, labels: [], pull_request: { merged_at: null } }; + const resolvedFrom = (number, row) => blockerResolutionFromRow(target(number, 'unread'), row); + 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,'); + 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'); + 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); + b(BATTERY_PR_MERGE, 'H19 PR: an open PR reads open, unmarked', [resolvedFrom(1, openPr19).state, resolvedFrom(1, openPr19).unmerged].join(), 'open,'); + 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,'); + 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'); + const prCard = blockedCard(27925, 'Blocked-by: #27888'); + const mergedRow = h19row(prCard, [resolvedFrom(27888, merged27888)], REPO_OS); + b(BATTERY_PR_MERGE, 'H19 PR: MERGED -> outlived: the row fires', mergedRow.includes('outlived its blocker'), true); + b(BATTERY_PR_MERGE, 'H19 PR: …as a full discharge', mergedRow.includes('Every target it names is closed'), true); + b(BATTERY_PR_MERGE, 'H19 PR: CLOSED UNMERGED -> held: no row', h19BlockOutlivedBlocker(blockedCard(27925, 'Blocked-by: #27296'), [resolvedFrom(27296, abandoned27296)], REPO_OS), null); + b(BATTERY_PR_MERGE, 'H19 PR: OPEN -> held: no row', h19BlockOutlivedBlocker(blockedCard(27925, 'Blocked-by: #2'), [resolvedFrom(2, openPr19)], REPO_OS), null); + // An abandoned PR beside a closed issue withholds the discharge and is named. + const mixedPr19 = h19row(blockedCard(27925, 'Blocked-by: #27846, #27296'), [resolvedFrom(27846, issue27846), resolvedFrom(27296, abandoned27296)], REPO_OS); + 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); + b(BATTERY_PR_MERGE, 'H19 PR: …as a PARTIAL discharge', mixedPr19.includes('PARTIAL'), true); + b(BATTERY_PR_MERGE, 'H19 PR: …⛔ never claiming every target is closed', mixedPr19.includes('Every target it names is closed'), false); + 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); + 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); + // The UNJUDGED row names it too, and the archive keeps its old power only. + const unjudgedPr = h19row(blockedCard(1, 'Blocked-by: #2, #27296'), [target(2, 'unresolved', { detail: 'HTTP 404' }), resolvedFrom(27296, abandoned27296)], REPO_OS); + 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'); + const archivePr = h19row(blockedCard(1, 'Blocked-by: #2'), [target(2, 'closed'), resolvedFrom(27296, abandoned27296)], REPO_OS); + 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'); + 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); + // -- H20: a `pm:dispatched` card nobody is working (#10312) ---------------- // // The specimen is #8878, 2026-08-20: a well-formed claim comment at ~14:05Z,