| name | diagnose-ci-queue |
| description | Use when multiple PRs are stuck BLOCKED or failing CI. Clusters failures by signature, distinguishes systemic main-line bugs from per-PR content bugs, and dispatches O(root-causes) agents rather than O(PRs). Triggers on /diagnose-ci-queue, "queue is stuck", "PRs blocked", "CI is red", "merge queue jammed". |
| when_to_use | When ≥2 open PRs are BLOCKED, when a single workflow keeps failing across unrelated PRs, or before you investigate "why is THIS PR failing". |
| allowed-tools | Bash, Read, Agent |
| arguments | ["optional PR numbers to focus on","default all open"] |
/diagnose-ci-queue — Triage systemic vs per-PR CI failures
A 5-step protocol that converts O(N) per-PR investigation into O(root-causes) systemic investigation. Codifies the 2026-05-11 apex lesson: 15 PRs blocked, 6 wasted diagnostic agents, 1 actual root cause.
When NOT to use
- Single PR failing — just investigate the PR directly.
- All checks GREEN, just slow — no root-cause work needed.
- You already know the root cause and just need to ship the fix — go ship it.
Protocol
Step 1 — Enumerate the failure landscape
gh pr list --state open --limit 100 --json number,statusCheckRollup,mergeStateStatus,isDraft \
--jq '.[] | select(.isDraft==false and .mergeStateStatus=="BLOCKED") |
{n:.number,
fails:[.statusCheckRollup[] | select(.conclusion=="FAILURE") | .name]}'
Count cross-check (mandatory): independently recount with a single fixed command
(... --jq '[...] | length') and abort the triage on divergence >1 — one agent's
diligence is not a guarantee; the workflow port (.claude/workflows/ci-queue-triage.js
in apex) implements this as a hard gate.
If the result is empty, the queue is healthy — exit. If you see ≥2 BLOCKED PRs, continue.
Step 2 — Cluster by failure signature
Count how many PRs share each failing check name. Output should look like:
magnet-file-discipline (strict on PRs): 15 PRs ← clearly systemic
evaluate: 15 PRs ← downstream meta-gate, ignore
data-plane (C + PostgreSQL integration): 8 PRs ← systemic
CodeQL (TypeScript): 6 PRs ← systemic (likely workflow-config)
governance-heavy-validation: 8 PRs ← systemic
some-pr-specific-test: 1 PR ← per-PR content
Threshold: ≥3 PRs sharing a check name = systemic candidate.
Drop meta-gates from the systemic list at this step. They're downstream of real gates:
evaluate (asserts mergeable_state == clean)
Governance & contracts (aggregates sub-gates)
pr-on-plan (sometimes a meta on plan changes)
Step 2.5 — Check for billing/quota failures (5 sec; prevents hours of phantom debugging)
GitHub Actions billing-limit failures surface as job FAILURE with empty logs — invisible via --log-failed because the job never produced log output. The smoking gun is in the run's ANNOTATIONS block, requiring plain gh run view <id>.
Critical: sample the MOST RECENT failed run on a BLOCKED PR, not the first one enumerated. When billing flips from blocked → resolved (or vice versa) mid-investigation, OLD runs have real-failure logs while NEW runs are billing-blocked (or vice versa). Sampling the wrong one yields a false negative.
PR=<one of the BLOCKED PRs>
RUN_ID=$(gh pr view "$PR" --json statusCheckRollup \
--jq '[.statusCheckRollup[] | select(.conclusion=="FAILURE")] | sort_by(.startedAt) | .[-1].detailsUrl' \
| grep -oE 'runs/[0-9]+' | head -1 | cut -d/ -f2)
gh run view "$RUN_ID" 2>&1 | grep -iE "billing|payment|spending|not started"
for RUN_ID in $(gh pr list --state open --json statusCheckRollup --limit 5 \
--jq '.[].statusCheckRollup[] | select(.conclusion=="FAILURE") | .detailsUrl' \
| grep -oE 'runs/[0-9]+' | cut -d/ -f2 | sort -un | tail -3); do
echo "=== Run $RUN_ID ==="
gh run view "$RUN_ID" 2>&1 | grep -iE "not started|billing|payment" | head -2
done
If you see The job was not started because recent account payments have failed or your spending limit needs to be increased:
- STOP — this is not a code problem. The queue can't drain until the user fixes billing at https://github.com/settings/billing/spending_limit.
- Tell the user, with that link.
- After resolution, re-trigger with
gh run rerun <run_id> --failed for each PR.
- Skip steps 3-5 entirely.
Anti-pattern 1 (parallel-burst): do NOT fan out N parallel rebases when the spending limit is unknown — large simultaneous bursts of CI runs are themselves a way to trip the limit. If the user just resolved billing, sequence rebases one at a time and watch each complete before kicking the next.
Anti-pattern 2 (stale-sample false negative): do NOT sample the FIRST failed run via [...] | .[0]. When CI history mixes pre-fix and post-fix runs, the first by enumeration order may have real-failure logs while NEWER runs are billing-blocked. The lockstep failure pattern (≥10 unrelated workflows failing in identical sets across PRs in seconds) is more diagnostic than any single run's logs.
Lockstep timing signature: when a check normally taking minutes (CodeQL: 30min, Secret scan: 2min) fails in 2-9 seconds across multiple unrelated PRs, the jobs never started. That timing is the smoking gun even before annotations are inspected.
Tag the failure mode for posterity: when a check's local re-run on the same content is green but CI shows FAILURE with empty logs OR sub-10-second job durations across many PRs, billing is the first hypothesis, not a code bug.
Step 2.5 lessons learned (case studies)
- 2026-05-11 apex session (first encounter): Step 2.5 caught billing on first probe, queue stayed jammed until $400 boost.
- 2026-05-12 apex session (false-negative gotcha): Step 2.5 initially returned "not billing" because it sampled an OLDER failed run with valid logs. The newer runs were billing-blocked. Lesson: sort by
startedAt and sample the LATEST, not the first. (Codified in the updated jq query above.)
Step 2.6 — Account-level concurrent-runner cap (distinct from billing)
GitHub Actions plans cap concurrent jobs separately from spending. Even after the spending-limit boost is resolved, jobs may still sit queued if the account's concurrent-runner allotment is exhausted by your parallel bursts.
Signature (different from billing):
gh run list --status in_progress | wc -l → 0 (or very few)
gh run list --status queued | wc -l → ≥10
- Queued runs have no
not started/billing annotation — they're just sitting
- Wait time per queued job: minutes-to-hours rather than seconds
IP=$(gh run list --status in_progress --limit 50 --json status --jq 'length')
Q=$(gh run list --status queued --limit 50 --json status --jq 'length')
echo "in_progress=$IP queued=$Q"
If exhausted:
- You cannot fix this in code. Tell the user; they need to either wait for natural drain (~hour, depending on queue depth) OR bump the account's concurrent-runner plan tier.
- Do NOT fan out more rebases — that worsens the queue depth without releasing slots.
- Optionally:
gh run cancel <id> on stale duplicate runs to free slots (find via grouping by (name, headBranch) and keeping only newest).
Lesson learned (2026-05-12 apex session): After billing was boosted to $400, I rebased 13 PRs in parallel. The boost increased the spending cap but NOT the concurrent-runner allotment. Result: 22 queued / 0 in-progress for ~45 minutes until the keystone PR finally picked up a runner. The user pointed out they shouldn't be capped again — surfacing the distinct second cap is now codified here.
Step 2.7 — Workflow timeout-cliff signature
When a JOB completes with cancelled at an elapsed time that exactly equals its declared timeout-minutes, the cancellation is the JOB's own timeout firing — NOT cancel-in-progress, NOT concurrency, NOT billing.
Signature (distinct from all above):
- Job
conclusion: cancelled AND
- Elapsed time
completedAt - startedAt ≈ timeout-minutes × 60 seconds, within ±60s
- Across multiple PRs that DO trigger this workflow path
- The workflow run reaches its own internal end-step (not killed at the start)
JOB=<the cancelled job id>
gh api repos/<owner>/<repo>/actions/jobs/$JOB --jq '{
started: .started_at,
completed: .completed_at,
duration_min: (((.completed_at | fromdateiso8601) - (.started_at | fromdateiso8601)) / 60 | floor)
}'
Fix: PR raising timeout-minutes on that specific job to 60 or 90, with magnet-allow-reason if needed (workflow files are typically magnets).
Anti-pattern: blanket-raising timeouts across all jobs. Per "Measure before tuning", only raise the jobs with cliff evidence. Otherwise you mask real perf regressions and inflate the cost ceiling.
Lesson learned (2026-05-12 apex session): 41 jobs across .github/workflows/ had timeout-minutes: 30. Two (governance-heavy, CodeQL TS) cliffed; the other 39 may or may not. Tracked under SET-979 for on-demand bumps as evidence accumulates. The instrumentation gap: no tool today computes per-job p95 runtime vs declared timeout. Optional follow-up in SET-979.
Mental model — three distinct CI-pipeline-stall failure modes
| Stall type | Job conclusion | Job duration | Annotation | Fix surface |
|---|
| Billing exhausted | FAILURE | <10s | "job was not started" | User: pay/bump limit |
| Concurrency cap exhausted | (never starts) | 0s | none, just queued | Wait or bump plan |
| Workflow timeout cliff | CANCELLED | exactly = timeout-minutes×60 | none | PR raising timeout |
| (Plus the magnet/script bugs in Steps 1-2.) | | | | |
When investigating "why is the queue stuck", the first 3 sub-steps (2.5 / 2.6 / 2.7) cover the three platform-level stalls — none of which respond to code fixes against the PR's content. Skip Steps 3-5 if any of these is the answer.
Step 3 — Verify against main
For each systemic candidate, check whether main itself is failing the same check:
gh run list --branch main --limit 5 --json status,conclusion,name,createdAt \
--jq '.[] | select(.conclusion=="FAILURE")'
OR (more direct): check what was committed since the PRs were branched. If a fix already landed on main, the rebase-and-retrigger path applies.
For each cluster, classify:
- Main is broken too → upstream workflow/script bug. Fix it once.
- Main was broken; fix already landed → PRs are stale. Rebase and retrigger.
- Main is green, PRs fail → either env diff (CodeQL config, runner image) OR shared dep regression. Investigate.
Step 4 — Dispatch agents in cost-optimal order
For each ROOT CAUSE (not each PR), dispatch ONE agent. Use the Agent tool with isolation: worktree. Brief the agent with:
- Specific failure signature (exact check name + log excerpt)
- Whether main is also broken
- Hypothesis: "This looks like X based on Y signals"
- Goal: fix the root cause, not the symptom in any individual PR
For bootstrap problems (fix PR will hit the same broken gate it's fixing):
Step 5 — Drain the queue
After root-cause fix(es) land on main:
for pr in <list of blocked PRs>; do
gh pr update-branch "$pr"
done
If update-branch doesn't fire CI (path-filter miss), see project rule empty-commit-retrigger-for-blocked-prs.md.
Output
Return a structured report:
## CI Queue Triage Report (<date>)
Failure clusters:
magnet-file-discipline: 15 PRs — SYSTEMIC (workflow shell bug)
data-plane: 8 PRs — STALE (fix landed at <commit>)
CodeQL TS timeout: 6 PRs — WORKFLOW-CONFIG (out of scope, file separately)
some-test.spec.ts: 1 PR — PER-PR CONTENT
Action plan:
1. Dispatch agent to fix magnet-discipline workflow → admin-merge bootstrap
2. Rebase 8 stale PRs → CI re-runs against new main
3. File workflow-config issue for CodeQL TS timeout
4. Investigate 1 per-PR content failure separately
Agent dispatches: 2 (one root cause + one per-PR)
PR rebases: 8
PRs unblocked when complete: 23/24
Anti-patterns
- ❌ Spawning N diagnostic agents before clustering — pay 1 per root cause, not 1 per PR.
- ❌ Investigating
evaluate as a primary failure — it's downstream of the real check.
- ❌ Admin-merging individual PRs when the queue itself is the bug.
- ❌ Skipping Step 3 (check main) — without it you can't tell systemic from per-PR.
- ❌ Empty-commit-retriggering a PR whose underlying gate is genuinely broken — you just re-fire the same failure.
Case study — 2026-05-11 apex session (the lesson source)
| Step | What we did | What we should have done |
|---|
| Initial signal | 15 PRs BLOCKED on magnet-file-discipline + evaluate | Same |
| Investigation | Dispatched D-agents per-PR (#519, #525, #528, #572) | Run Step 2 cluster analysis first |
| Diagnosis time | ~6 agent-runs across 4 D-agents | 1 agent-run on the workflow file |
| Fix | 3-line shell change in .github/workflows/lint-magnet-files.yml | Same |
| Drain | gh pr update-branch × 15 (the right move, late) | Same |
The fix (#656) was the right shape. The diagnostic dispatch was 5× over-budget because the systemic pattern wasn't recognized at Step 2.
Related
~/.claude/rules/ci-queue-triage.md — always-on rule with the trigger heuristic.
~/.claude/skills/verify/SKILL.md — for verifying a fix landed correctly.
~/.claude/skills/best-of-n/SKILL.md — if root cause is genuinely ambiguous, fan out N candidates and aggregate.
- Project rule (apex):
.claude/rules/empty-commit-retrigger-for-blocked-prs.md — the orthogonal case where path-filters skip workflows (different failure mode).
Added 2026-06-06 (apex 30-merge session)
| Signal | Probable diagnosis |
|---|
~150 service jobs "fail" simultaneously, each conclusion: cancelled, 0 steps run | Cancellation storm — a rebase/ready-for-review push killed the in-flight wave via concurrency groups. Cancelled ≠ failed: retrigger a clean wave (empty commit), don't debug per job. |
| Same check fails on PR, passes on fresh main | Stale-base photograph — high merge velocity invalidates open PRs' runs. gh pr update-branch, don't debug the PR. |
| Check fails ONLY when a deeper layer first becomes reachable (gate "never ran this far before") | Onion-layer latency: each fixed gate exposes the next pre-existing failure. Expect N layers; verify each is on main before attributing to the PR. |