diff --git a/.claude/skills/pm-dispatch/references/dispatch-runbook.md b/.claude/skills/pm-dispatch/references/dispatch-runbook.md index 784c1294f33..98290e1632c 100644 --- a/.claude/skills/pm-dispatch/references/dispatch-runbook.md +++ b/.claude/skills/pm-dispatch/references/dispatch-runbook.md @@ -82,7 +82,7 @@ - 裁决执行中新生的问题另立卡并回链已裁卡,⛔ 不给已裁卡重挂 `needs-user-decision`。 - 机械两旗 report-only,归半状态巡查;接线是另一张卡的事,⛔ 不随协议文本改脚本。 - 旗一:open 卡标题带决策标记或线程含裁决记录,却仍挂 `needs-user-decision`。 -- 旗二:`pm:blocked` 卡的 `Blocked-by:` 目标已关闭。 +- 旗二:`pm:blocked` 卡的 `Blocked-by:` 目标已关单(PR 则已合并;关而未合并不算)。 - 交付了一半的阻塞是判断不是 grep,归四件之③,⛔ 不进机械旗。 - 发布窗口判据:已上膛 = 开着的 head `changeset-release/main` PR,`is:pr is:open head:changeset-release`。 - 点亮对照 `git ls-remote origin changeset-release/main` 必中;⛔ title 永不是判据。 diff --git a/scripts/ci/scheduled-full-run.mjs b/scripts/ci/scheduled-full-run.mjs index 7238b0a799f..47bf195f7c6 100644 --- a/scripts/ci/scheduled-full-run.mjs +++ b/scripts/ci/scheduled-full-run.mjs @@ -621,11 +621,14 @@ export async function judgeCardBlock({ issue, ownerRepo, listComments, getIssue, // A PR answers in the issues API's PR shape; a closed one with no // `merged_at` was abandoned (a missing `merged_at` reads the same: held). const unmerged = state === 'closed' && got?.pull_request != null && !got.pull_request.merged_at; + // A closed PR with a `merged_at` released by its MERGE: the note names it so. + const merged = state === 'closed' && Boolean(got?.pull_request?.merged_at); resolutions.push({ ...target, state, closedAt: got?.closed_at ?? null, ...(unmerged ? { unmerged: true } : {}), + ...(merged ? { merged: true } : {}), detail: state === 'unresolved' ? `answered state '${got?.state}'` : null, }); } catch (error) { @@ -645,6 +648,7 @@ const targetReading = (r) => { if (r.state === 'closed' && r.local !== true) { return `${targetName(r)} closed upstream (not an unlock criterion for this consumer)`; } + if (r.state === 'closed' && r.merged === true) return `${targetName(r)} merged`; return `${targetName(r)} ${r.state}`; }; @@ -662,13 +666,18 @@ function summariseBlock(block) { // as a body-borne `Blocked-by:` line by the very reader it reports on (the // self-test runs the reader over every note). export function renderBlockNote(block) { + // Only reachable at `expired` or `assigned`, where `staleBlockVerdict` has + // excluded every unmerged row: a closed PR here MERGED, and is named so. const closed = (block.resolutions ?? []) - .map((r) => `${targetName(r)}${r.closedAt ? ` (closed ${r.closedAt})` : ' (closed)'}`) + .map((r) => { + const word = r.merged === true ? 'merged' : 'closed'; + return `${targetName(r)}${r.closedAt ? ` (${word} ${r.closedAt})` : ` (${word})`}`; + }) .join(', '); if (block.verdict === 'expired') { return [ `## State: \`${BLOCKED_LABEL}\` → \`${QUEUE_LABEL}\`, moved by this refresh`, - `This card carried \`${BLOCKED_LABEL}\`, and every \`Blocked-by:\` target it names is closed: ${closed}.`, + `This card carried \`${BLOCKED_LABEL}\`, and every \`Blocked-by:\` target it names is closed (a PR: merged): ${closed}.`, 'A red run that arrives after the last named blocker closed is new work, so this refresh moved the card', `back to \`${QUEUE_LABEL}\` (one additive label write, one remove by name). Priority, lane and every other`, "label are untouched: grading stays the seat's. If this red is still the failure the block was about,", @@ -677,8 +686,8 @@ export function renderBlockNote(block) { } if (block.verdict === 'assigned') { return [ - `## State: \`${BLOCKED_LABEL}\`, and every blocker it names is closed`, - `Every \`Blocked-by:\` target this card names is closed (${closed}), so this red is new work -- but the`, + `## State: \`${BLOCKED_LABEL}\`, and every blocker it names is closed (a PR: merged)`, + `Every \`Blocked-by:\` target this card names is closed or merged (${closed}), so this red is new work -- but the`, `card is assigned (${block.assignees.map((a) => `\`${a}\``).join(', ')}), and moving it to \`${QUEUE_LABEL}\` is`, "the holder's release act (clear the assignee and post the `Release:` line, in one write), never this", "refresh's. No label was changed.", @@ -1975,6 +1984,13 @@ async function selfTest() { if (outcome.block !== 'expired' || b.names() !== sorted([...CARD_LABELS, QUEUE_LABEL])) { throw new Error(`merged PR: expected the card released, got '${outcome.block}' holding ${b.names()}`); } + // The note and the log name the PR by its MERGE, never as a bare close (#28033). + if (!b.card.body.includes('#24305 (merged 2026-10-16T13:20:00Z)') || b.card.body.includes('#24305 (closed')) { + throw new Error(`merged PR: the note does not name the merge:\n${b.card.body}`); + } + if (!outcome.blockSummary.includes('#24305 merged')) { + throw new Error(`merged PR: the summary does not name the merge (${outcome.blockSummary})`); + } } const abandoned = blockBoard({ cardBody: `${OLD_BODY}\nBlocked-by: #24305`, states: { [PR]: 'closed' }, pulls: { [PR]: null } }); diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 9bc7476e2e6..8cd41490085 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -2433,10 +2433,15 @@ export function proseBlockerClause(candidates, heldBack = 0) { ' They all still read OPEN, so the block itself looks live — what is missing is only the ' + 'line that would let a machine see it when they close.'; } else { + // Each count is named only when it is non-zero: abandoned beside unresolved + // has no open row, and `0 still read OPEN` would say the opposite of held. + const counts = [ + open.length > 0 ? `${open.length} still read${open.length === 1 ? 's' : ''} OPEN` : null, + abandoned.length > 0 ? `${abandoned.length} closed UNMERGED (which withholds)` : null, + unresolved.length > 0 ? `${unresolved.length} could not be resolved from this install` : null, + ].filter(Boolean); verdict = - ` None of them has released — ${open.length} still read${open.length === 1 ? 's' : ''} OPEN` + - (abandoned.length > 0 ? `, ${abandoned.length} closed UNMERGED (which withholds)` : '') + - (unresolved.length > 0 ? `, ${unresolved.length} could not be resolved from this install` : '') + + ` None of them has released — ${counts.join(', ')}` + ' — so the block itself looks live, and what is missing is only the line that would ' + 'let a machine see it when a target merges or closes.'; } @@ -7656,12 +7661,14 @@ export function h28StaleBodyBlockerLine(issue, resolutions, commentBodies, owner if (spent.length === 0 || live.length === 0) return null; return ( - `\`pm:blocked\` whose BODY names ${spent.length} CLOSED \`Blocked-by:\` target(s) ` + - `(${namedTargets(spent)}) while a COMMENT names ${live.length} that ${live.length === 1 ? 'is' : 'are'} ` + + `\`pm:blocked\` whose BODY names ${spent.length} \`Blocked-by:\` target(s) no longer OPEN ` + + `(${namedTargets(spent)} — an issue reads closed, a PR reads closed by its MERGE, and a PR that ` + + 'closed UNMERGED is named so: it released nothing, but the body line is spent either way) ' + + `while a COMMENT names ${live.length} that ${live.length === 1 ? 'is' : 'are'} ` + `still OPEN (${namedTargets(live)}) and appear(s) nowhere in the body — so the body line is ` + 'STALE: it states a wait that is over, and the wait that is actually running is parked in the ' + 'channel the body is supposed to be the canonical home for. This is the written half of a ' + - 'RE-PARK: a seat found the body\'s upstream closed, carded the real prerequisite, and wrote ' + + 'RE-PARK: a seat found the body\'s upstream closed or merged, carded the real prerequisite, and wrote ' + 'the new blocker into a comment (the cheaper, safer write) without spending the body line. ' + '⚠️ Read what that costs before the migration: until the liveness read was ungated this card ' + 'resolved ONLY the closed body target and was published as a card whose every blocker had ' + @@ -25097,6 +25104,9 @@ async function selfTest() { t('H4 prose PR: …and says what to do with it: re-point, not release', proseRow(['nothing'], [proseFromRow(27296, abandonedPrRow)]).includes('releases nothing'), true); t('H4 prose PR: an UNMERGED close beside an OPEN target is neither an expiry nor all-open', proseRow(['nothing'], [proseFromRow(27296, abandonedPrRow), cand(17438, 'open')]).includes('1 still reads OPEN, 1 closed UNMERGED (which withholds)'), true); t('H4 prose PR: …and the mixed count never counts the unmerged close as expired', proseRow(['nothing'], [proseFromRow(27296, abandonedPrRow), cand(17438, 'open')]).includes('ALREADY CLOSED'), false); + const abandonedBesideUnresolved = proseRow(['nothing'], [proseFromRow(27296, abandonedPrRow), { ...blockerTargetKey({ repo: 'objectstack-ai/objectui', number: 4356 }, REPO4), state: 'unresolved', closedAt: null, detail: 'HTTP 404' }]); + t('H4 prose PR: an UNMERGED close beside an UNRESOLVED target names only the counts it has', abandonedBesideUnresolved.includes('None of them has released — 1 closed UNMERGED (which withholds), 1 could not be resolved from this install —'), true); + t('H4 prose PR: …and never says `0 still read OPEN`', abandonedBesideUnresolved.includes('0 still read'), false); t('H4 prose PR: a MERGED PR beside an UNMERGED one counts only the merge as expired', proseRow(['nothing'], [proseFromRow(27888, mergedPrRow), proseFromRow(27296, abandonedPrRow)]).includes('1 of 2 is ALREADY CLOSED'), true); t('H4 prose: an UNRESOLVED cross-repo candidate claims nothing about the block', proseRow(['nothing'], [{ ...blockerTargetKey({ repo: 'objectstack-ai/objectui', number: 4356 }, REPO4), state: 'unresolved', closedAt: null, detail: 'HTTP 404' }]).includes('says NOTHING about whether'), true); t('H4 prose: …and renders the cross-repo address, not a bare number', proseRow(['nothing'], [{ ...blockerTargetKey({ repo: 'objectstack-ai/objectui', number: 4356 }, REPO4), state: 'unresolved', closedAt: null, detail: 'HTTP 404' }]).includes('`objectstack-ai/objectui#4356` UNRESOLVED'), true); @@ -30765,8 +30775,16 @@ async function selfTest() { [REPARK_COMMENT], 'objectstack-ai/objectstack', ) ?? ''); - t('H28: two stale body targets are counted', manyStale.includes('names 2 CLOSED'), true); + t('H28: two stale body targets are counted', manyStale.includes('names 2 `Blocked-by:` target(s) no longer OPEN'), true); t('H28: …and the live one is still named', manyStale.includes('`#11501`'), true); + // A body target that is a PR closed UNMERGED (#28033): the body line is spent + // either way (the wait it states is over), so the row still fires -- but the + // target is named with its unmerged mark, never as a bare close. + const abandonedStale = String(h28StaleBodyBlockerLine(reparked, [target(9255, 'closed', { closedAt: '2026-08-19T11:28:26Z', unmerged: true }), target(11501, 'open')], [REPARK_COMMENT], 'objectstack-ai/objectstack') ?? ''); + t('H28: an UNMERGED PR as the spent body target still fires the row', abandonedStale.includes('the body line is '), true); + t('H28: …naming it with its unmerged mark, as `namedTargets` renders it', abandonedStale.includes('`#9255` (closed UNMERGED 2026-08-19T11:28:26Z)'), true); + t('H28: …and never calls it a CLOSED target', abandonedStale.includes('names 1 CLOSED'), false); + t('H28: …while saying it released nothing', abandonedStale.includes('it released nothing'), true); // -- H32 — a HELD seat idle over a non-empty lane queue (#11706) ------------ //