Skip to content

feat(pm): H16 — report an open non-draft PR stuck in a merge conflict - #9985

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-9980-h16-dirty-pr
Aug 19, 2026
Merged

feat(pm): H16 — report an open non-draft PR stuck in a merge conflict#9985
os-warren merged 1 commit into
mainfrom
claude/issue-9980-h16-dirty-pr

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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_state is dirty past 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_state is absent from the list payload. Measured here both ways: the /pulls listing rows carry state/draft/merged/updated_at/head/base and no mergeability key at all, while a single-PR GET on #9826 answered mergeable_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. h16NeedsDetail answers 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 literal dirty fires, 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_at is a value that should have been readable and whose absence hides a real card, so it still flags; unknown is 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 real draft field.

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_merge is 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 the DOMAIN_HALF_STATE_STALE_HOURS precedent — 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 by main advancing 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_at used 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, because updated_at measures 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 .mjs run from source by node; the self-test imports nothing through a package exports field or a dist/, 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:

legpredictedobserved
delete the dirty gateRED, exactly 6 cases (the non-dirty-is-silent set), and the never-narrower table case stays green6 of 352 case(s) failed — the 6 named, table case green
add H12's auto_merge suppressionTypeError crash, not a tally: a suppressed row returns null and the runner throws on .includescrash at the exact predicted case, check-half-states.mjs:2921
threshold 2 → 200TypeError crash, same mechanism (the 4h default fixture goes null)crash at check-half-states.mjs:2886

The 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 by git 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.

⚠️The dirty reading is the incident's, not a live one. Re-measured while implementing this, that PR answers mergeable_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.mjs with no path arguments (the script takes its own change set from the merge base), run after the final commit at d6669295c. Exit codes captured before any pipe; each gate's own verdict line quoted:

gateexitverdict line
pnpm check:pm-half-states0✓ check-half-states self-test: 352 cases pass.
pnpm check:partof-closing-keyword0✓ check-partof-closing-keyword self-test: 28 cases pass.
pnpm check:cross-package-test-inputs0OK: 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)0same line as above
pnpm check:nul-bytes0check-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-keyword run bare (no PR_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-changeset applied 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

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-warrenos-warren added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 19, 2026 — with Claude
@os-warren
os-warren marked this pull request as ready for review August 19, 2026 12:03
@os-warren
os-warren enabled auto-merge August 19, 2026 12:03
@os-warren
os-warren added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit 521f99fAug 19, 2026
25 checks passed
@os-warren
os-warren deleted the claude/issue-9980-h16-dirty-pr branch August 19, 2026 12:29
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

patrol: H16 — open non-draft PR stuck in merge-conflict (mergeable_state dirty) beyond threshold

1 participant

@os-warren