Uh oh!
There was an error while loading. Please reload this page.
feat(pm): H16 — report an open non-draft PR stuck in a merge conflict - #9985
Merged
Conversation
A merge conflict starts no CI run, raises no event and turns no check red, so every proxy signal a patrol reads keeps reporting health — and with auto-merge armed the PR additionally reads as "the queue is handling it". None of H1-H15 could express the state; the measured specimen hung ~4h with nobody aware. H16 flags an open, non-draft PR whose `mergeable_state` is `dirty` past MERGE_CONFLICT_STALE_HOURS (2h), naming the PR and the card(s) it holds. Report-only, an ordinary row in both renderers. - `mergeable_state` is absent from the /pulls LIST payload, so this is the first item needing a per-PR GET. `h16NeedsDetail` bounds it to candidates the list row already settles as stuck-shaped; the gathering policy is exported and pinned, and can never be narrower than the predicate. - `unknown`/null readings are SKIPPED, never vouched for: GitHub computes mergeability asynchronously, so that reading is "ask again later". - `auto_merge` is deliberately NOT read in H12's finding-reducing direction — auto-merge does not resolve conflicts, so an armed dirty PR is the disease. - A failed detail GET never fails the sweep; NO candidate readable at all is a transport classification (#4690), not a clean board. Self-test 292 -> 352 cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeA3nU1B5Q2pgxqxgUrexd
os-warren
marked this pull request as ready for review
August 19, 2026 12:03
os-warren
enabled auto-merge
August 19, 2026 12:03
This was referenced Aug 19, 2026
Uh oh!
There was an error while loading. Please reload this page.
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 freeto 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#9980
A merge conflict is the one board state none of H1–H15 could express. It starts no CI run, raises no event and turns no check red, so every signal a patrol reads by proxy keeps reporting health — and when auto-merge is armed the PR additionally reads as "the queue is handling it", which is H12's reading, correct there and exactly wrong here. The measured specimen hung ~4h with nobody aware. The incident's lesson, verbatim: 「一个无法表达某状态的仪器,会把它报成它能表达的最近状态。」
H16 flags an open, non-draft PR whose
mergeable_stateisdirtypast a named threshold, naming the PR and the card(s) it is holding. Report-only, an ordinary row in both renderers.The four platform facts, and what each forced
1.
mergeable_stateis absent from the list payload. Measured here both ways: the/pullslisting rows carrystate/draft/merged/updated_at/head/baseand no mergeability key at all, while a single-PR GET on #9826 answeredmergeable_state. So H16 is the first item in this file needing a per-PR GET.That made the gathering policy the load-bearing part, so it is exported and pinned rather than buried in the sweep loop — the same placement, and the same reason, as
needsRepoProbe.h16NeedsDetailanswers from the list row already in hand using the halves of the predicate that need no mergeability (non-draft, not merged, and either aged past the threshold or carrying an unreadable timestamp), so the request count is bounded by the stuck population, not the open one, and only PRs this sweep already listed are ever fetched. The invariant that matters is asserted as an implication over the whole fixture table rather than case by case: the gate can never be narrower than the predicate — every row H16 would flag is a row it fetches.2. Mergeability is computed asynchronously.
unknown/null is neither clean nor dirty; it is the platform saying "ask again later". Only the literaldirtyfires, and an unknown is skipped in silence — never vouched for, never guessed — per this file's standing narrowness discipline.This is the one place H16 departs from the #4690 direction the aged items take, and the docblock argues the asymmetry rather than leaving it to be read as an inconsistency: an unreadable
updated_atis a value that should have been readable and whose absence hides a real card, so it still flags;unknownis a correct platform answer, and firing on it would put a row on the anchor for every PR whose mergeability happened to be cold at sweep time — noise that would bury the real rows. Drafts are skipped, and so is any row without a realdraftfield.3. A failed detail GET must not fail the sweep — but a total failure is not a clean board. One unreadable PR drops out of H16 as a bounded gap; no candidate readable at all is a transport classification (#4690), rethrown for the outer net to re-probe. That judgement is
h16DetailPassUnreadable, extracted as a pure function specifically so it could be pinned — it was the one piece of real logic that would otherwise have sat untested inside a non-exported loop. Zero candidates is deliberately not a failure: that is the healthiest possible board.The row-granular half of the same posture is in the summary line, which now reports
merge state read on X of Y H16 candidate(s). Without it, a detail pass that read nothing would be indistinguishable from a board with no conflicts.4.
auto_mergeis deliberately NOT read in H12's finding-reducing direction. Auto-merge does not resolve conflicts — a PR armed while dirty simply never lands — so here the armed state is evidence of the disease, never of a handler. Pinned in both directions, including the contrast that makes it deliberate rather than an oversight: one PR row, two predicates, opposite readings of the same armed field.The threshold, and the timestamp it ages
MERGE_CONFLICT_STALE_HOURS = 2, following theDOMAIN_HALF_STATE_STALE_HOURSprecedent — and matching it for the same underlying reason rather than by coincidence: both measure a loop that should already have turned over, not intake latency. Conflicts here are overwhelmingly created bymainadvancing under an open PR (~18 merges on a working day), so resolution is mechanical and a lane PM's landing window turns over far faster. 2h leaves a normal resolution a full window while catching the measured incident at roughly half its life.A conflict carries no timestamp of its own, so the clock is the PR's
updated_atused as a proxy, and the docblock states the error direction rather than leaving it to be discovered: a conflict created minutes ago on a PR last touched hours ago flags at once, becauseupdated_atmeasures silence on the PR and not the age of the conflict — and base advancing, the usual cause, does not touch the PR row at all. That over-reports in exactly one shape and under-reports in none, which is the direction this file keeps everywhere. The proxy is named in the finding text, so a reader shown "~4h" knows which quantity that is. It is marked ⛔ not to be "fixed" with a per-PR timeline fetch — an extra request per candidate to sharpen a report-only row, a trade H15 already declines by name.Reverse verification — directions predicted in writing first
No rebuild leg is owed and none was skipped: this is a plain
.mjsrun from source bynode; the self-test imports nothing through a packageexportsfield or adist/, and no vitest alias is involved. There is no artifact between the edit and the measurement.Predicted before running, then observed — all three exactly as predicted:
dirtygate6 of 352 case(s) failed— the 6 named, table case greenauto_mergesuppressionnulland the runner throws on.includescheck-half-states.mjs:2921check-half-states.mjs:2886The table case staying green through leg 1 is the point of asserting an implication rather than a count: rows that newly flag under the ablation are also rows the gate fetches, so the invariant genuinely still holds there.
Restored after each leg from the commit (
git checkout d6669295c -- ...), proven byte-identical bygit hash-object=bd7775bace54a32d9a6a7f8c6a9a0d06aeba974a, equal to the committed blob — not by a matching diffstat — and re-run green each time.Incident fixture
PR #9826's measured shape is pinned as a case: dirty, auto-merge armed, ~4h, body declaring the card it was holding (#9763). The predicate fires regardless of the arming, names that card, and reports the measured age; the counterfactual pin is the same PR one hour in, correctly silent.
dirtyreading is the incident's, not a live one. Re-measured while implementing this, that PR answersmergeable_state: "blocked"— the conflict has since been resolved. It is pinned as a historical shape deliberately, and the fixture comment says so, so nobody later "verifies" it against a live PR that no longer carries it.Gates
Union derived by
node scripts/pm/dispatch-gates.mjswith no path arguments (the script takes its own change set from the merge base), run after the final commit atd6669295c. Exit codes captured before any pipe; each gate's own verdict line quoted:pnpm check:pm-half-states✓ check-half-states self-test: 352 cases pass.pnpm check:partof-closing-keyword✓ check-partof-closing-keyword self-test: 28 cases pass.pnpm check:cross-package-test-inputsOK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.node scripts/check-cross-package-test-inputs.mjs(ci.yml form)pnpm check:nul-bytescheck-nul-bytes: OK (scanned 6322 text file(s) -- 6322 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes).Self-test counts: 292 → 352.
One declared narrowing. The sixth derived entry is the live sweep (
node scripts/pm/check-half-states.mjs, half-state-patrol.yml), which cannot run in this container — measured rather than assumed, via the script's own--probe: exit 3,PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential, with the anonymous path also unusable. That is the third container class this file's header already documents. CI runs that entry on a runner.check:partof-closing-keywordrun bare (noPR_BODY) also exits 2 by design — it needs the workflow's context — which is why the packaged script form is--self-test, and that is what is quoted above.No changeset: internal PM tooling under
scripts/pm/, nothing published changes.skip-changesetapplied at PR creation.Not a governed path (
scripts/pm/), so this opens as a draft for the PM to flip ready.Generated by Claude Code