Why a PR goes red in the merge queue

Queue reds that were the PR's own fault, split four ways: its checks never ran the test (A), main moved under it (B), machines or flake (C), other (D).

2026-10-05 · merge_group runs 10-03 → 10-05 23:00Z (2,086 runs, 146 culprit rows, 110 PRs) · the 09-30 → 10-02 regime (full suite every train) is not included

Half the PR-fault reds are tests the PR never ran; a third are the machines; one in nine is main moving under the PR. A 47%: the PR lane was capped, so only the queue ran the test. C 38%: a spot runner was lost (35 of 55) or a known flaky test fired. B 12%: passed on its base, broke on newer main — 6 of 17 rows with hard evidence. "Re-validate on fresh main" fixes about one red in ten; a full set on capped PRs and the spot fix cover the other eight.

BucketRowsSharePRsWhereFix
A never ran on the PR6947%49Full set 63 · Guard pins 6. 61 of 69: PR lane capped.Full set on capped PRs, at approval.
B main moved under it1712%13Guard pins 8 · affected tests 5 · queue-only tsc 3 (49 red runs).Re-validate on fresh main; pre-queue type check.
C infra or flaky5538%46Spot / runner lost 35 · flaky issue 10 · passed on re-queue 10.Spot-loss fix; quarantine 4 files.
D other53%21 queued while red; 4 unattributable.—

Row = PR × check. Per PR (A › B › D › C): A 49 · B 13 · C 46 · D 2. 94 rows excluded as cascades or main-was-red.

How this was measured, and where it is uncertain

Source. scripts/ci-flow/queue-vs-pr-join.ts over every merge_group run of ci.yml in the window: 2,086 runs, 759 with a red cause job, 850 red-job events = 543 inherited from an earlier group in the chain + 295 first-in-chain + 12 ambiguous. Only the first group a failure appears in names a culprit (flow-baseline's real_failure); cascades are never counted. That gives 240 first-fails (one per PR × failing check or test file). For each, the culprit PR's own pull_request runs at the head it was enqueued with were read, including the vitest logs, to answer: did the job run, did the file run, did it pass.

Rules, written before the numbers. Excluded: the same signature first-failing for 2+ PRs on one base (main was red), or an unlanded entry ahead in the chain failing the same signature (collateral). C: a runner/spot/shutdown signature in the job log or in the CI factory's per-run facts, a flaky-test issue naming the file, or the same enqueued PR head later passing that check in another group. A: the job or file did not run on the PR (affected lane capped, always-run step skipped, fence subset pruned the pin, next build deferred). B: ran and passed on the PR, or the test was added/edited on main after the PR's last validation, or a type error that only the queue's cold tsc (inside the Build sub-check) reports. D: the rest.

Where this can be wrong.

A — the PR never ran the failing test (69 rows, 49 PRs)

61 rows: the PR's affected selection was over the cap, so the PR ran a narrower list and the queue ran the full set (mode=full). 6 rows: the always-run drift/decide step was skipped on the PR (capped or deferred). 2 rows: the guard fence subset pruned the pin on the PR. 57 distinct files; the only repeater is scripts/escalation-crucible/__tests__/registry.test.ts (5 PRs — a registry test that any new entry breaks).

Fix. When the PR lane is capped, run the full set on the PR once — at approval (or at ready-for-review), not on every push. That is 61 of 146 culprit rows (42%) removed before the queue, at one full-set run per capped PR.

B — passed on its base, broke on newer main (17 rows, 13 PRs)

11 rows ran and passed on the PR's own CI; 3 are tests that landed on main after the PR was last validated (the PR's tree never had them — a freshness class, not a selection class); 3 are type errors the queue's cold tsc found inside the Build sub-check. Commits behind at queue time, test rows: 3, 9, 9, 18, 20, 22, 25, 25, 25, 28, 28, 131, 323, 323 (median ~25, matching the ~15 median lag of the fleet's PRs only loosely — the B rows skew stale).

Fix. Re-validate on fresh main before queueing — it would have caught the 6 confirmed rows and probably the 3 type errors (49 red runs between them, plus 2 on a fourth file not traced to a culprit — the costliest rows on this page per culprit). For the 8 unconfirmed rows the honest statement is "B or an undetected flake"; a fresh-main re-run also settles that question, so it earns its place either way. The pre-queue type check being built addresses exactly the 3-row class; this page says that class is small in rows but large in cascade.

C — infra or a flaky test (55 rows, 46 PRs)

35 rows: the runner died — "AWS interrupted the EC2 Spot instance" or "The runner has received a shutdown signal" — 4 on 10-03, 16 on 10-04, 15 on 10-05, after the 10-03 move to spot everywhere. 24 of the 35 are on the CI Checks job (type check, lint, DDB escapes, build), the longest-lived single job in the queue leg. 20 rows: flaky — 10 named by a flaky-test issue, 10 where the same PR head passed on re-queue. Four files carry most of the flake: leasing-compare.test.tsx (unhandled error, 4 PRs), ci-unit-tests-writer-vacuous-green.test.ts (4 PRs), self-review-unresolvable-range.test.ts, check-availability.test.ts.

Fix. The spot-loss fix (retry a reclaimed job inside the run, or on-demand for the CI Checks job alone) is worth 35 of 146 culprit rows (24%) and is the right fix. The flake half is four files; quarantining them removes ~14 rows.

D — something else (5 rows, 2 PRs)
Pre-10-03 regime, for contrast (not bucketed)

From the CI factory's per-run facts, 09-30 22:40Z → 10-03 00:00Z: 2,926 merge_group runs (1,080 green, 1,449 red, 84 cancelled, 313 unread); red jobs across the 1,090 red runs with facts: Guard pins 551, Full set (one machine) 364, CI Checks 206, Unit Tests (affected) 9. Spot or runner-shutdown signatures in that period: 5 of the 1,090 red runs, against 63 of 774 after the 10-03 move to spot everywhere. These are red-job counts including cascades, not culprit attributions — a like-for-like A/B/C/D for that window needs another join run (~2,500 calls).

Data: ~/.claude/services/ci-factory-mq/health-cache.json (facts per run), flow/flow-baseline-rolling24h.json (control), join output and bucket rules in the analysis session's scratchpad (analyze.py, bevidence.py). Related: ci-rethink-2026-10, ci-velocity-plan-2026-10.

PropFlow Docs