Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/pm-dispatch/references/dispatch-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 永不是判据。
Expand Down
24 changes: 20 additions & 4 deletions scripts/ci/scheduled-full-run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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}`;
};

Expand All @@ -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,",
Expand All @@ -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.",
Expand Down Expand Up @@ -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 } });
Expand Down
32 changes: 25 additions & 7 deletions scripts/pm/check-half-states.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
}
Expand Down Expand Up @@ -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 ' +
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) ------------
//
Expand Down
Loading