You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 50e894d
Browse filesBrowse the repository at this point in the historyBrowse 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>
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');
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.
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');
0 commit comments