Repository navigation
Commit f8eaf67
pm(half-states): H67 reads the queue for cards a PR already landed on (#18678)
Fixes #18372
Clause-②: no
`skip-changeset` — the diff is one file under `scripts/pm/**`, which no
released package ships (`files[]` on every published manifest excludes
it), so nothing publishes.
## The defect
On 2026-09-09 a batch state transition moved a bucket of cards back onto
`pm:queue`. The transition set the label correctly and nobody re-read
each card's own closing condition, and part of that bucket had already
been delivered by another PR during the wait — so the queue length every
seat reads (取卡全序, the priority sort, a batch decision) is an upper bound
rather than a to-do count, and a seat can be handed work that is already
done. The filer measured one lane: 63 open unassigned `pm:queue` cards,
38 of them with at least one merged cross-referenced PR and zero open
ones, 25 with no linked PR at all. It hit the shape three times in a row
in one shift and caught all three only by a rule of its own invention —
「派发前先重读关闭条件」 — which is in no repo text. This PR is acceptance ④ and
nothing else: that rule made a repeatable, report-only reading. It
judges none of the 38, and it closes nothing anywhere.
## The row — H67
**Population.** Open, unassigned `pm:queue` cards. `h67SpeaksAbout`
CALLS `h66SpeaksAbout` rather than restating it (`refsOnlyLinksFor`'s
discipline): the two rows read one population from two channels, and a
population that drifted apart would let a card be "in the pool, unowned"
for one row and not the other with nothing saying so.
**Tell.** The card's own timeline carries at least one MERGED
cross-referenced PR (`source.issue.pull_request.merged_at` parses) and
no OPEN one — the filer's own instrument, taken as-is rather than
re-derived, because the card's readings are stated against it. A
closed-never-merged PR is a third class: it delivered nothing and it is
not an open PR, so it neither fires nor clears, and the row names it.
**Discriminators, per row, at ZERO extra requests.** A
`cross-referenced` event carries the whole source issue INCLUDING ITS
BODY, and `commented` events ride the same page, so all three legs read
off the page that was already bought:
1. **What each merged PR declared to THIS card** — a closing keyword,
`Part of`, `Refs`, a bare mention, or nothing at all. All four grammars
are this file's own (`closingKeywordTargets` / `partOfTargets` /
`refsTargets` / `referencedNumbers`), CALLED and never restated, and
bound PER CARD NUMBER as H7 binds.
2. **The card's own closing condition, QUOTED** — a frozen, capped
anchor set (`H67_CLOSING_CONDITION_ANCHORS`), each entry beside the live
instance it was measured on, read over the body first and then the
thread newest-first. A card that states none gets that said on its row:
the hand read then has nothing to check the landing against, which is
itself the reading.
3. **The newest seat marker, dated against the merge** — H49's
stand-down, reused: a `Claim:` or a `Release:` newer than the newest
merge is a seat that has already looked at this card SINCE the delivery
landed, and either stands the row down structurally.
**Remedy, on every row.** Judge by hand, card by card. 「有已合 PR」 is not a
closing criterion. And before closing anything, carry the thread's
residual readings out into a NEW card first — they are other seats'
after-the-fact measurements and closing buries them, which is why the
filer's two closures filed #18343 and #18364 first.
**⛔ `Part of` cards are never listed as candidates for closing
(acceptance ③).** They are listed, in a bucket whose sentence opens 「⛔
NOT A CANDIDATE FOR CLOSING」, names H49 as the row that owns the shape,
quotes the partial-landing rule verbatim, and says what is owed instead:
a re-read of the remainder, never a close. The reason the bucket has to
speak rather than the row stay silent is measured and is the
load-bearing fact of this card: **#15815 (work done, closed) and #16529
(a true remainder, correctly kept) carry identical machine evidence** —
both reached only by a merged PR declaring `Part of #N`. No bucket may
read as "close this", and the pair is pinned as a case.
## Disjointness, argued on each neighbouring row's own criterion
| row | why it cannot be this row |
|:--|:--|
| **H8** | requires `pm:dispatched`; this population is `pm:queue`
without it (`PM_EXCLUSIVE_STATE_LABELS` excludes the pair, which is H3's
row). Disjoint by label, in every input. |
| **H47** | leg (a) requires an assignee; this population has none. Leg
(b) asks about the OWNERSHIP RECORD's shape with no PR in the input at
all; this row asks whether a PR LANDED and reads the record only to date
it against that merge. A card can fire both — two readings, not one
restated. |
| **H49** | requires `pm:dispatched` AND a non-empty assignee. Disjoint
by both fields. It is the nearest neighbour in substance, which is why
the `Part of`/`Refs` bucket NAMES H49's rule instead of inventing a
second disposition for the same shape. |
| **H58** | reads the card's own declaration that its deliverable is a
RULING, in a heading, over the same population; it buys nothing and
never looks at a PR. This row reads the DELIVERY. |
| **H59** | direction (b) is the one real overlap and it is stated on
the row rather than hidden. H59 starts from H8's merged WINDOW
(`MERGED_WINDOW_DAYS`, 8 days) over every open card, so a delivery older
than the window is invisible to it — which is exactly the tail this card
is about. This row starts from the CARD and reads its whole timeline
with no time bound, over the unassigned queue only. In the intersection
both fire, with different sentences and different remedies. Neither is
narrowed to serve the other. |
| **H66** | reads the NEWEST COMMENT for a RELEASE and buys a COMMENT
page; this reads the TIMELINE for a MERGE and buys a TIMELINE page. The
stand-down legs point opposite ways, which is the cleanest statement of
the difference: **a release as the newest word FIRES H66 and CLEARS this
row.** Pinned as a case. |
## Budget
One timeline page per card, page 1, `per_page=100`. The page is ONE for
H59's reason: the timeline is served oldest-first and no listing payload
carries an event count, so a newest page cannot be located the way
H65/H66 locate a newest comment page — and a page that came back FULL
may hide a later merge, a later open PR or a later `Claim:`, so that
card is UNJUDGED and counted, never clean.
`H67_TIMELINE_READ_CAP` is 120 — a little over a tenth of one hour's
Actions budget added to a run that happens at most once every six hours.
Nothing in this file caches a timeline, so there is **no free half** of
the kind H66 has: every judged card costs its page, and the cap is
therefore the row ceiling too.
**Order: OLDEST-FIRST, the opposite of H66's, deliberately.** H66 chases
a fresh write because a release is one. Here the evidence is a merge
that may be weeks old, so recency says nothing about where to look; what
decides is who gets hurt. The order in force is
「队列每轮清空时最新优先;清不空的那一刻起改最老优先」 and the queue has not emptied, so the cards
a seat reaches NEXT are exactly the cards this row must have read. The
plan sorts by the two legs of 取卡全序 the label page gives for free —
priority rank, then card age — and declares what it does not model
(`target:` board membership, the same-priority 「先 `Bug`」 tiebreak). An
unreadable `created_at` sorts LAST, never first.
**Coverage quadruple on the summary line, unconditionally:** candidates
/ read / listed / not attempted, plus the unjudged count as its own
number. A partial read is never published as a zero.
## Pins
Battery **`H67 queued merged-delivery reading`**, **151 registered**,
pinned at **142** (the neighbours' headroom: H66 183/172, H19 37/34).
`SELF_TEST_BATTERY_FLOOR` rises **2 to 3** with it. The battery floors a
LISTING whose whole value is its discriminators, plus the two negatives
the card rules on twice: a `Part of` card must never read as a candidate
for closing, and an OPEN PR must stand the row down. Cases include the
three instances from the card (#15815-shaped listed with its condition
quoted; #16529-shaped `Part of` listed under 「⛔ NOT A CANDIDATE FOR
CLOSING」 and cleared outright once a release answers the merge;
#16565-shaped bare-mention listed under the weakest bucket), the open-PR
and no-PR silences, the abandoned-PR class, the three page states, the
seven population gates driven whole, the foreign-repo refusal, the
relation ladder, the closing-condition reader and the four shapes it
structurally refuses, the read plan and its cap, the pass audit, the
disjointness pairs, and the quadruple.
```
check-half-states self-test: 4735 cases pass.
Batteries: H66 released queue card 183/172, H19 judged-set founding 37/34,
H67 queued merged-delivery reading 151/142.
```
## Ablation — from the committed fix, on-disk proof both legs
The guard removed is the OPEN-PR stand-down, `if ((timeline.open ??
[]).length > 0) return null;`. Run under `scripts/pm/os-verify-lock.sh`
from the committed state, with an `EXIT INT TERM` trap restoring `git
checkout HEAD --` against the ABSOLUTE path. There is no build or
`dist/` leg: this script is executed from source, so the on-disk proof
IS the whole preflight.
```
HEAD blob = 21a44d9
on-disk pre = 21a44d9
guard occurrences BEFORE = 1 (anchor asserted unique, or the run aborts)
guard occurrences AFTER = 0 injected marker count = 1
on-disk mutated = ef47d63835f3feba6aa8d3b00bb3a892e197ef03
MUTATED self-test exit = 1 -- 2 of 4735 case(s) failed:
x H67 B bucket: an OPEN linked PR stands the row down
x H67 B bucket: ...even when it declares nothing about this card
on-disk restored = 21a44d9
git diff HEAD: EMPTY (restore proven) blob hash matches HEAD: YES
RESTORED self-test exit = 0 -- 4735 cases pass.
```
The direction is the expected one (red), and the failure is SCOPED:
exactly the two cases that assert the guard, and no neighbour's case
moved.
## Live sweep — report-only, from this worktree, under the verify lock
`node scripts/pm/check-half-states.mjs` over
`objectstack-ai/objectstack`, one run, exit 0, lock held 529s (a
shared-box wall clock, not an idle-box figure).
```
Queued cards with a merged delivery (H67):
207 open unassigned `pm:queue` card(s) could be spoken about
120 had ONE timeline page READ this run (cap 120 page(s))
55 LISTED
87 NOT ATTEMPTED at that cap
1 card(s) stayed UNJUDGED
```
Lane breakdown of the 55, joined against a `pm:queue` label page re-read
right after the sweep: `domain:spec` 25, no `domain:` label 14,
`domain:devx` 12, `domain:skills` 2, `domain:cli` 1, and one card
(#12511) that had already left the queue page by the time of the join.
**The devx reading beside the card's 38 — a reading, not a judgement of
those cards.** The devx lane's open unassigned `pm:queue` population is
**53** at the time of this sweep (the card measured 63 on 2026-09-16),
and **12** of them are LISTED by this row. ⛔ That 12 is NOT the card's
38 re-measured and must not be read as a correction of it. Three
declared reasons it cannot be compared directly:
1. **The cap read 120 of 207 candidates, oldest-first across ALL
lanes.** An offline replay of `h67ReadOrder` over a `pm:queue` page read
after the sweep (222 candidates then) puts 18 of the lane's 53 inside
the cap and 35 outside it — so most of the lane is NOT ATTEMPTED, not
clean.
2. **The filer's A bucket is the RAW 「has a merged PR」 count with no
stand-down**, while this row clears a card whose `Claim:` or `Release:`
is newer than the merge, and clears one with an open PR.
3. **The board moved** between 2026-09-16 and this run.
⛔ Nothing here closes, relabels or proposes a state for any of the 55.
Acceptance ①②③⑤ bind whoever closes them, with this reading in hand.
## Gates
Derived from this worktree with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (no hand-fed path list).
The first derivation ran on a tree three commits behind `origin/main`
with `scripts/pm/dispatch-gates.mjs` itself stale in that range;
`origin/main` was merged in and the derivation re-run — the family list
is identical (38, none added, none gone) and the tree is no longer
stale. Reconciliation: `--ran` reports **38 derived, 38 run, 0
NOT-MEASURED, 0 UNRUN**.
Every exit code below was captured with redirect-then-`$?`, never across
a pipe.
```
exit 0 node scripts/check-changeset-no-major.mjs --base origin/main
exit 0 node scripts/check-changeset-no-major.mjs --self-test
exit 0 node scripts/check-ci-filter-parity.mjs
exit 0 node scripts/check-closing-keyword-parity.mjs
exit 0 node scripts/check-closing-keyword-parity.mjs --self-test
exit 0 node scripts/check-comment-mask-corpus.mjs
exit 0 node scripts/check-declaration-mirrors.mjs
exit 0 node scripts/check-declaration-mirrors.mjs --self-test
exit 0 node scripts/check-scripts-symbol-anchors.mjs
exit 0 node scripts/check-scripts-symbol-anchors.mjs --self-test
exit 0 node scripts/check-self-test-wired.mjs
exit 0 node scripts/check-self-test-wired.mjs --self-test
exit 0 node scripts/check-self-test-workflow-commands.mjs
exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test
exit 0 node scripts/check-whole-set-label-write.mjs
exit 0 node scripts/check-whole-set-label-write.mjs --self-test
exit 0 node scripts/pm/bare-root-worklist.mjs --self-test
exit 0 node scripts/pm/board-snapshot.mjs --self-test
exit 0 node scripts/pm/check-governed-queue-guard.mjs --self-test
exit 0 node scripts/pm/sweep-closed-cards.mjs --self-test
exit 0 pnpm check:agent-test-spelling
exit 0 pnpm check:bash32-floor
exit 0 pnpm check:changeset-gate-self-tests
exit 0 pnpm check:cli-command-ids
exit 0 pnpm check:closing-target-claim
exit 0 pnpm check:commit-card-trailers
exit 0 pnpm check:cross-package-test-inputs
exit 0 pnpm check:driver-memory-census
exit 0 pnpm check:entry-guard
exit 0 pnpm check:nul-bytes
exit 0 pnpm check:parse-guard
exit 0 pnpm check:partof-closing-keyword
exit 0 pnpm check:pm-dispatch-gates
exit 0 pnpm check:pm-half-states (under the verify lock)
exit 0 pnpm check:pnpm-filter-targets
exit 0 pnpm check:ratchet-remedy-authority
exit 0 pnpm check:refd-timer-probe
exit 0 pnpm check:watch-hint-literal
exit 0 pnpm lint (repo-wide, `eslint . --no-inline-config`)
exit 1 grep -naP '[control-byte class]' scripts/pm/check-half-states.mjs
(exit 1 = no match = no control byte; captured before any pipe)
```
The gate union above was run on this branch's final content; `pnpm
check:pm-half-states` and `pnpm lint` were re-run after the
`origin/main` merge.
## Two deviations from the dispatch order, both argued rather than taken
silently
1. **The read order is OLDEST-FIRST**, where the dispatch said to bound
it the way H66's buy leg is (a cap, newest-touched first, a coverage
quadruple). The cap and the quadruple are H66's exactly; the ordering is
not, and the row header carries the argument in full: the evidence here
is a merge that may be weeks old, so newest-touched sorts on a signal
that does not exist for this row, while 最老优先 is the order a dispatching
seat actually takes cards in.
2. **`Part of` cards are LISTED**, in a bucket that says 「⛔ NOT A
CANDIDATE FOR CLOSING」. The dispatch left the choice open ("state which
bucket they land in or that they are named as such"). Listing them is
what the filing card asks for — its A bucket is 「必须逐张验的候选集」 and its
instance three (#16565) was a bare-reference card whose work really was
done — and the acceptance-③ refusal is met by the bucket's own words
rather than by silence.
## Out of scope, noted and not fixed here
`RELEASE_COMMENT_MARKER` and `CLAIM_COMMENT_MARKER` anchor the word at
line start and tolerate only a leading blockquote, so a DECORATED
ownership line is invisible to them — measured live on objectstack#16529
comment `5697022358`, which is a real release record H2, H47 and H66
cannot see. That reach is shared with three landed rows and widening it
is a different card; this row declares the loss on its own sentence and
in its summary clause instead, and a control case pins that the
decorated spelling really is refused while the bare one still matches.
Filed to the triage seat in this PR's report comment; carrier for
whoever picks it up: `scripts/pm/check-half-states.mjs`, the two marker
constants.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent c7d4825 commit f8eaf67
1 file changed
Lines changed: 1025 additions & 1 deletion
0 commit comments