feat(pm): H59 reports a merged PR whose closing linkage mis-states a card it names - #17589
Merged
Merged
Conversation
…card it names
GitHub's PR->issue closing linkage is unreliable in both directions, so a
merged PR silently mis-states the state of every issue it mentions. H59 reads
H8's existing merged window a second way and files a report-only row for each
direction:
FALSE CLOSE the PR mentions #N, binds no closing keyword to it, and #N
closed with the merge. The expensive half: work disappears
from the board while reading as done, and no other row here
can catch it because a closed card is swept by nothing.
FALSE OPEN the PR binds a closing keyword to #N and #N is still open.
The cheap half: it surfaces as queue noise.
Measured on the live board before writing the predicate: the `closed` event
carries no `commit_id` at all on this repo's landing path (six cards plus one
page of the repo-wide event stream, zero stamps), so the commit leg is kept
for the commit-message surface and the temporal leg is what carries here.
The band is measured rather than chosen - 321 keyword-bound pairs sit at
p50 1s / p95 2s / p99 4s - and the ruling's one-minute reading is narrowed to
5s because a minute admits a seat closing a card 53s after a merge. A sibling
PR that declared the closure inside the same band explains it and the row
stays quiet; without that guard two of three live candidates are wrong.
Report-only: exit codes unchanged, no label written, nothing reopened. The
remedy names the owning seat's act. Direction (b) costs no request; direction
(a) buys one bounded timeline page for the handful of cards that survive every
free narrowing, capped per sweep with the shortfall printed, and no retry on
any status.
Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK
Co-authored-by: Claude <noreply@anthropic.com>
os-litant
marked this pull request as ready for review
September 11, 2026 01:25
os-litant
deleted the
claude/issue-17466-merged-pr-closing-linkage-row
branch
September 11, 2026 01:50
This was referenced Sep 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #17466
One new report-only row in
scripts/pm/check-half-states.mjs— H59, over the merged-PR window the sweep already walks. Nothing else: no new workflow (the card's option 2 is refused), no second closing-keyword grammar, no label written, exit codes unchanged, no other row.GitHub's PR-to-issue closing linkage is unreliable in both directions, so a merged PR mis-states the state of every card it names, and no seat can reason about closure from a PR's text. H59 reads the same merged window a second way and files a row for each direction.
The two directions, and their fixtures
(a) FALSE CLOSE — the expensive half. The PR body names a card by number, binds no closing keyword to that number, and the card went to the closed state with the merge. Work disappears from the board while reading as done, and it is the one failure no other row here can catch: every other predicate in this file reads open cards, so a card in the closed state is swept by nothing.
(b) FALSE OPEN — the cheap half. The body binds a closing keyword to a number and the card is still open after the merge. It surfaces as queue noise and somebody eventually re-reads the card.
Both fixtures in
--self-testcarry the filing card's two measured sibling-board shapes, written with this repo's own measured event shape (nocommit_id, a 1–2s lag). The fixtures are offline literals; the self-test never touches GitHub.The measured shape of a close-by-merge — and the assumption it killed
Zone-2's assumed evidence was the
closedevent naming the merge commit. Measured on this board before the predicate was written, it is not available at all:So the commit leg is kept — it is exact wherever GitHub does stamp it, which a keyword in a pushed commit message does, and that is H23's surface — but it fires on nothing here. The temporal leg is what carries this board, and the ruling names it: the close sits inside the merge's own moment with no other actor.
The band is measured, and the ruling's "merge minute" was too wide. Over the same window, every (merged PR, card) pair where the body binds a keyword and the card's closure falls inside H22's window — 332 pairs, 321 within ±5 minutes of the merge:
GitHub's own act lands in seconds. A full minute admits a different actor entirely, and this board has the specimen: card #16638 went to the closed state 53s after PR #16950 merged, by
os-project-manager, with no window PR binding a keyword to it — a seat closing a card because the delivery landed, which is the protocol working. A 60s band reports it; the measured band does not.H59_MERGE_CLOSE_BAND_MSis 5s, which is the operational reading of the ruling's own 「with no other actor」, and the one 31–60s pair is named in the banner rather than rounded away.The sibling guard. Merges land in batches — PRs #17558 and #17559 landed in the same second — so a card closing inside one PR's band may have been closed by a sibling that declared it properly. Measured at a 60s band there were three candidates; two were explained by a sibling's declaration and one was the seat close above. With the measured band and this guard the live board produces zero (a) rows.
The window, the narrowing and the cap
The population starts as H8's merged window — the same bounded listing, not a second one — and mentions are read with
referencedNumbers, this file's one reference grammar. Measured on the live board (2026-09-11): 1,185 merged PRs, 5,830 references, 1,106 of them keyword-bound. A per-card read for each of the 3,282 unbound mention pairs is not a budget any sweep has, so the row narrows before it buys anything and every narrowing is counted rather than absorbed:closed_atfrom H22's closed-card window, so a mentioned-but-unbound card that is neither open nor inside that window is UNJUDGED and counted (linkageOutOfWindow), never clean. That makes this row's real reach the shorter of the two windows (3 days), and the clause says so instead of letting a reader assume eight;H59_CARD_READ_CEILING= 40 cards per sweep with the shortfall printed. The page is one on purpose: the timeline is oldest-first, so a card whose history exceeds a page is UNJUDGED and counted (measured on six live cards: 16–40 events). ⛔ No retry loop on any status; a failed read is UNJUDGED and counted.listRecentIssueEvents' "only reader of event history", now "only repo-wide reader"). H13/H18's claims are scoped to the label-application event and are byte-identical — H59 reads aclosedevent and never a label one.The live sweep
One report-mode sweep of this repo through the #13544 proxy re-exec, exit 0 (report-only, unchanged):
The new clause, verbatim:
One row fired, and it is a real one on this board — direction (b), quoted as the sweep printed it:
⛔ Zero (a) rows on this repo, which is exactly what the dispatch said it would not claim otherwise: the filing evidence is a sibling board's, and this sweep reports what this board has.
Ablation
Two legs, each: mutate → prove on disk (injected/removed
grep -cplus a blob hash that differs fromHEAD) → run → restore withgit checkout HEAD -- ABS_PATH→ prove restored (blob identical toHEAD,git diff HEADempty) → re-run. Atrap … EXIT INT TERMholds the restore on the crash path; the proof is the hashes, not the trap.Direction observed: turns red on leg 1, and on leg 2 turns red in both senses at once — the (b) fixture loses its row source and the (a) guard loses its silencer.
The one judgement, on the four axes
The judgement this card carries is the cap and the band (40 cards per sweep; 5s rather than a minute). 实际业务需求: both numbers come from a measurement of this board, not from taste — the band from 321 keyword-bound pairs (p95 2s) and the cap from the survivor set that reading produced (one card), and the wide reading was measured wrong, reporting a seat's correct act on card #16638 as a defect; "reads like it's useful" is exactly what a 60s band would have been. 项目长远合理性: the row rides windows and grammars the sweeper already pays for — one closing-keyword grammar, one reference grammar, H8's window, H22's window — so there is no second dialect to drift and no second schedule to die; the refused option 2 would have been new standing machinery with its own token and its own way of failing silently. 防 AI 写代码犯错: the failure is the declared-versus-enforced shape at its purest — a body that declares a closure the runtime did not perform, and a body that performs one it never declared — and the row refuses every lenient reading: an unplaceable closure is UNJUDGED, a card never read is UNJUDGED, a full timeline page is UNJUDGED, none of them clean, and the row says CANDIDATE where its evidence is circumstantial rather than asserting a mechanism. 创业阶段不扩散需求: no new workflow, no new label, no new state, no new regex and no new window — one row on a tool that already runs on a schedule, with both budget numbers set narrow by default and widening left as an argument someone has to make.
Verification
Gate families derived from the FINAL diff in the worktree with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths — the script derives its own change set; it reported1 path(s) vs merge base 216b06682, three-dot). Every exit code captured before any pipe. All 39 derived commands ran; reconciled with--ran.Plus, by name:
Repo-wide lint, not a narrowing:
npx eslint . --no-inline-config --format jsonexited 0 over 6,595 files, 0 errors, 0 warnings, run at35cf894b. The population and the counts are read from eslint's own run rather than declared, so no narrowing argument is owed — and for completeness, this repo never enables type-aware linting (calculateConfigForFileon the edited file reportsparserOptions.projectundefined;eslint.config.mjssays so in its own words), which is why a targeted run on this file would have been sound too.Not run locally, declared for CI: everything
dispatch-gatesitself named as outside the 39 — the 47 artifact-roster families (a fact about a list, not about these paths), the 11 wide-population families, the 10 pending-changeset families, the 4 workflow-value families and the 1 path-scheduled CI job. NOT MEASURED, not clean.Changeset: none —
scripts/pm/**publishes nothing from any released package. Theskip-changesetlabel is applied.Acceptance notes
noted, not filed:the card's verbatim measurement and the dispatch disagree on whether the sibling-board FALSE OPEN instance bound a keyword. Carrier: this PR's fixture note above; the next author of this row is who would trip over it. Not a defect in either artefact — the predicate is unambiguous.noted, not filed:H22's closed-card window (3 days) is shorter than H8's merged window (8 days), so this row's (a) direction reaches only 3 days while its own population is defined over 8. It is disclosed in the clause on every run rather than hidden. Carrier: none today — widening H22's window is that row's decision, not this one's, and nothing else is blocked on it.noted, not filed:theclosedevent's actor could discriminate a seat close from GitHub's own act if the PR's merger were in hand, butmerged_byis absent from/pullslist rows and this row buys no per-PR detail read. The actor is printed in the row for the reader instead. Carrier: none — a detail read per merged PR is a budget question for a later card.Clause-②: no
Authored in Claude Code session
session_01YKEjmbYNvYWJvWGSWx26zK(durable attribution in prose, per the footer rule).Generated by Claude Code