Repository navigation
ci: a run that never gets a runner is reported as failure, indistinguishable from a real test failure #15139
Description
What
A workflow run that never obtains a runner is reported to the PR as failure — the same signal a real test failure produces. Nothing distinguishes "your code is broken" from "no runner was available", so the standing rule red CI never merges fires on an infrastructure condition, and whoever reads it goes hunting for a defect in their diff that does not exist.
Evidence
Hardened Smoke Test, run 32984154162, on a PR branch:
| field | value |
|---|---|
created_at |
2026-08-26T15:07:57Z |
updated_at |
2026-08-26T15:22:39Z |
status / conclusion |
completed / failure |
| its only job | status: queued, zero steps executed |
| annotations | none |
A job with zero steps and no annotations did not fail a test — it was never dispatched. Fourteen minutes and forty-two seconds after creation the run was closed as failure.
The same workflow on a different branch at 15:22:42Z recorded startup_failure instead, so the outcome for the identical condition is not even stable.
Queue state measured at 15:49:19Z:
| metric | value |
|---|---|
| queued runs, repo-wide | 157 |
| in-progress runs | 2 |
| self-hosted runners | 2, both online, both busy=false |
| oldest queued run | 15:10:02Z — 39 minutes waiting |
Runners idle while 157 runs queue means the queued jobs are not waiting on the self-hosted pool.
Why it matters
The previous commit on the same branch passed this exact workflow (success). Only a test file changed between them. The failure was therefore guaranteed to be misread as a regression, and was: the delta was inspected, the job log fetched, the run object queried, and the steps enumerated before the zero-steps/queued shape made it obvious. That is the cost, per occurrence, multiplied by every PR in the backlog.
It also corrupts the merge gate in the safe direction only by luck — a dispatch failure blocks a good PR today, but the same indistinguishability means nobody can tell a genuinely red required context from a starved one without opening the run object by hand.
Acceptance criteria
- A run that ends without its job executing a single step is reported distinguishably from a test failure — a
neutral/cancelledconclusion, a named annotation on the check, or an automatic re-queue. Not afailurewith an empty job. - Establish why 157 runs queue while both self-hosted runners sit
online, busy=false— whether the queued jobs target hosted runners, an account concurrency cap, or a dispatch gate — and record the finding either way. - If the answer is a concurrency cap, the queue depth at which runs start timing out is written down somewhere a reader finds before assuming their diff is red.
-
CI Dispatch Watchdog/PR Queue Gateare checked for whether either already intends to cover this; if one does, fix it rather than adding a third mechanism.
Related
- Found while delivering bug(tests): api.terminal.router enumerates zero routes under python-suite shard 12/12 (unidentified sys.modules pollution) #15087 / PR test(terminal): fail the terminal route sweep when it enumerates nothing (#15087) #15113, where it cost a full root-cause pass on a clean diff.
Activity
mrveiss commentedon Aug 26, 2026
Same class, second facet:
cancelledalso reads asfailHit while delivering PR #15113. The check
No open blocks-merge issues reference this PRsurfaced as a failure. It was not one:98236533493status: completed,conclusion: cancelledoutput.title/output.summarynull/nullVerified independently that the gate had nothing to say: the only two open
blocks-mergeissues are #14812 and #14677, and neither mentions #15113. A fresh run of the same gate isqueuedon the new head.The cancellation itself is correct behaviour — concurrency superseding an obsolete run is what should happen. The cost is in the reporting:
gh pr checksbucketscancelledunderfail, so a superseded run and a genuinely red gate are the same string to anything reading that output, including an agent following red CI never merges. That is the same failure this issue is about — a non-failure presented as a failure — arriving by a second route.Worth folding into the first acceptance criterion: whatever distinguishes a starved run should also distinguish a superseded one, or the guidance for reading check status should say explicitly that
failingh pr checksis not the same asconclusion: failureand must be confirmed against the run object.mrveiss commentedon Aug 28, 2026
A third non-code cause producing an identical red, observed today
This issue records that a run which never gets a runner is reported as
failure, indistinguishable from a real test failure. Adding a second, distinct cause with the same symptom, because the set matters more than the instance.Observed on PR #15155,
python-suite shard 12/12(job 98783580325). The check readsfailure. The job got a runner and executed 9 of 14 steps, so it is not the starved class this issue describes. What actually happened:Step 4 (
Run ./.github/actions/setup-python-suite) failed on anapt-gettimeout — exit 124 three times. Step 9 (Run unit tests — slm-backend) then failed downstream. No test result was produced at all.So there are now at least three causes that present as the same red tile:
aptretry textThe first two are re-queueable; the third must never be. They are currently told apart only by pulling
GET /actions/jobs/{id}and reading the step list — which is a step most readers, and most tooling, will not take.A concrete way this bites. A merge gate written against
GET /commits/{sha}/check-runscannot make this distinction at all, because that endpoint carries nostepsarray — only the jobs endpoint does. A gate that readsstepsfrom the list response finds nothing every time and, depending on which way it defaults, either treats every red as infrastructure (and re-queues real failures) or every infrastructure blip as a real failure. I hit the first of those today and had to fix it: the correct default is that an undeterminable cause is not infrastructure, because mislabelling a real failure as a flake is the costly direction.Worth folding into this issue's acceptance criteria: whatever reports the distinction should report which of the three it is, not merely "not a real failure".
fix(ci): say the diff is not indicted, not that re-running will fix it (
feat(ci): name which of five conditions produced a red check (#15139) (…
mrveiss commentedon Aug 31, 2026
A concrete mechanism: re-dispatch instead of concluding
The evidence above already contains the detection fingerprint. A never-dispatched run is
distinguishable from a real failure without any guesswork:
conclusionfailurefailureorstartup_failure(this issue records both for the identical condition)statuscompletedqueuedGitHub will not let a concluded run un-conclude, so "report it as pending" is not available.
Re-dispatching is:
POST /actions/runs/{id}/rerunon a run matching that fingerprint turns aninfrastructure conclusion back into a pending check, which is the behaviour the required-context
contract actually wants. Red stays reserved for runs that executed something.
Where it belongs:
ci-dispatch-watchdog.yml. That workflow already sweeps every open PR headon a schedule and already owns this problem's mirror image — #12823 (runs created but parked
action_required) and #13045 (runs created but never allocating a job, context never reporting).Both are "CI did not happen"; this is the third member of that family, differing only in that the
run closes itself as red instead of hanging. It should not become a fourth workflow.
Guards it needs, so a re-dispatch loop cannot mask a real problem:
configuration problem, and the second failure should stand and be visible
because it was retried" is legible rather than silent
This is not owner-blocked. It was batched with the owner-decision set on 31 Aug, but unlike
#15107 (a branch-protection contract decision) this one is implementable as it stands. Only #15107
needs an owner.
Related: the fork-safety constraint is about to change
ci-dispatch-watchdog.ymlrefuses to approve fork runs because "this repository ... runspull_requestjobs on the self-hosted runner, so approving a fork run would executecontributor-supplied code on the owner's machine".
PR #15394 moves the last 7 self-hosted-pinned jobs to
ubuntu-latest(#15395). Once it lands, nopull_requestjob targets a self-hosted runner and that specific reasoning no longer applies —the #12823 parking this workflow exists to work around may become approvable rather than merely
reportable. Worth re-reading that block against the merged state rather than assuming either way.
mrveiss commentedon Aug 31, 2026
The re-dispatch must be admission-controlled, or it becomes the problem it fixes
Adding the limits the mechanism above has to respect. A watchdog that re-runs eagerly re-queues
into whatever was already too full to dispatch -- the same speculative-work failure as the rest of
this pipeline, with a retry loop attached.
The limits that bind here:
GITHUB_TOKENREST rate limitAdmission control, before any re-run:
sweep -- a re-run cannot dispatch and only adds to the backlog.
how a watchdog turns one outage into a rate-limit exhaustion.
X-RateLimit-Remainingand stop early with a published status rather than failingmid-sweep -- a watchdog that dies halfway leaves exactly the ambiguous state it exists to
remove.
per_page=100over per-run lookups; one listing answers formany runs.
configuration signal and should stand, visibly, rather than be retried away.
The ordering matters: fix the dispatch pressure first, then add the retry. With #15394 landed
the self-hosted queue drains into idle hosted capacity, and the non-dispatch condition this issue
is about becomes rare enough that a small, budgeted retry is sufficient. Adding the retry while
the pressure is still there would mostly be retrying into a wall.
fix(ci): keep the watchdog under its size ratchet, repin the guard (#…
fix(test): fit the repinned guard inside its size ceiling (#15139)
fix(ci): re-dispatch infrastructure-caused reds, and close the merge_…
github-actions commentedon Aug 31, 2026
PR #15396 (merged to
Dev_new_gui) references this issue with a close keyword.If this issue is fully resolved, close it manually. If work remains, no action is needed.
mrveiss commentedon Aug 31, 2026
AC1 and AC4 delivered —
dfa525dc19(PR #15396). AC2 partial, AC3 outstanding. Issue stays open.AC1 — a non-dispatched run is now distinguishable. Verified in merged
Dev_new_gui:pipeline-scripts/ci_dispatch_retry.pypresent, andci-dispatch-watchdog.ymlinvokes it. Theroute chosen is the automatic re-queue the AC lists third: GitHub will not let a concluded run
un-conclude, so
neutralwas unavailable, and a re-dispatch returns the check to pending. Red goesback to meaning "something executed and failed".
AC4 — checked, and fixed in place rather than added beside.
ci_dispatch_watchdog.ymlalreadyowned this problem's mirror image: #12823 (runs parked
action_required) and #13045 (runs createdthat never allocate a job). This is the third member of that family, differing only in that the run
closes itself red instead of hanging.
ci_red_cause.pyalready classified the cause and re-queuednothing; the missing half was acting on it. No third mechanism.
AC2 — measured today, and the answer is not a single cause
Both of this issue's readings are correct at their own moment, which is itself the finding:
busy=falsebusy=truebusy=trueThree contributors separated:
request, and median queue delay 0s — capacity is not normally the constraint, volume is.
today and they came back. Largest single block, ahead of every human branch combined.
At 17:00 the four deepest queues were exactly
AutoBot CI/CD Pipeline,Code Quality,Enforce Pre-commit HooksandFrontend Testing Suite— 29 of 89. PR ci: make the required contexts reportable — merge_group triggers (#14054) + off self-hosted (#15395) #15394 removed thatcontributor entirely; no job targets self-hosted any more.
So on 26 Aug the runners were genuinely idle and irrelevant; today they were saturated and central.
Two machines do not degrade gracefully — they are either unused or the whole bottleneck.
A caution this issue's own evidence supports: a queue-depth snapshot is not a wedge. #15185
recorded, then retracted, exactly that inference after watching 44 runs complete in 25 minutes. Run
counts also mislead — 6 in-progress runs was 7 in-progress jobs, and the ~20 free-tier ceiling
is on jobs.
AC3 — still open, and it needs a number nobody has
The queue depth at which runs start timing out is not established. It cannot be read off the
observations above: the two runs this issue caught (
failureat 14m42s, andstartup_failureforthe identical condition) came from different depths, and the outcome was not even stable between
them. Establishing it needs the re-dispatch shipped in AC1 to log depth-at-decision over a period —
which it now can, since it reads in-progress count and rate-limit budget before every decision.
Remaining on this issue: AC2's write-up is above but AC3's threshold is not established. Not
closing.
test(ci): install and remove the loaded modules in the same try/final…
fix(ci): sweep open PR heads for the infrastructure-retry watchdog (#…
github-actions commentedon Aug 31, 2026
PR #15399 (merged to
Dev_new_gui) references this issue with a close keyword.If this issue is fully resolved, close it manually. If work remains, no action is needed.
mrveiss commentedon Aug 31, 2026
Closing — the mechanism now reaches the case this issue is about.
029f456de7(PR #15399), on top ofdfa525dc19(PR #15396), verified against mergedDev_new_gui.#15396 built the re-dispatch machinery but it could not act on an open PR's red check. Post-merge review established why: the step is skipped on
pull_request— the only event where a PR head sha resolves — and on every other eventHEAD_SHAfell back togithub.sha, the base branch tip. Meanwhilerun(sha, dry_run)classified exactly one sha with noopen_pull_requestsreference.So a PR reddened by an empty runner pool stayed red while the sweep re-dispatched the base branch's own already-merged tip.
On merged base now:
The three surviving
HEAD_SHAreferences belong to the read-onlyci_red_cause.pystep, which is correct — that one reports on the firing PR's head and never writes back, which is precisely why it can run before the self-modification guard.The admission control survived the widening, which was the real risk. Going from one sha to N open PRs is the natural place to make the budget per-PR; it is not.
redispatch()walks a single flattened candidate list built across every open PR with onedonecounter and abreak, so exhausting on the first PR's reds stops the sweep before the second is touched. Per-PR budgets are how a watchdog turns one outage into rate-limit exhaustion.All five gates are pinned by tests that did not exist before, each proven by mutation rather than asserted after the fact:
That last mutation is what proves the fix: with the open-PR sweep removed, nothing is re-dispatched at all — which was the merged behaviour, demonstrated rather than argued.
One irony worth recording. CI reported
python-suite shard 5/12as FAILURE on this PR. Both pytest runs passed — 2293 and 161 — and asys.modulesleak guard failed after them, because my new test file left three modules installed. A green test run presented as a test failure, in the PR that fixes exactly that reporting ambiguity. Fixed by install-and-remove in the sametry/finallyrather than by baselining (the guard's own instruction: it only shrinks), and verified with a probe test reportingLEAKED=none.mrveiss commentedon Sep 1, 2026
Confirming the closure holds on AC3, and recording the defect I shipped
Closing-comment review from the other side of this work.
AC3 does not block the closure, and the reason is worth writing down so nobody reopens it
later. It is conditional:
The established answer was not a concurrency cap. Measured across 31 Aug: fan-out (#15357 — 63
checks and 117 runner-minutes per PR, median queue delay 0s), dependabot volume (9 PRs
regenerating ~50 queued runs; 126 cancelled across two sweeps and they returned), and — until PR
#15394 — four per-PR-required workflows targeting two self-hosted machines, 29 of 89 queued
runs at 17:00 while hosted capacity ran 7 jobs against a ~20 ceiling. Hosted concurrency was never
observed as the binding constraint. The precondition never fires, so AC3 is vacuous rather than
outstanding. My earlier comment listed it as unmet without noting it was conditional.
And the defect in
dfa525dc19was mine. I wired the retry stepif: github.event_name != 'pull_request'— reasoning that PR events run PR-head code and the sweep is scoped to the firingPR — without following through to what
HEAD_SHAthen resolves to. On every remaining event it isgithub.sha, the base tip. So the machinery re-dispatched the base branch's already-merged headand could never touch the open PR reds it was built for. The feature shipped unable to do its job,
and my PR body claimed only that the re-dispatch path "needs a real non-dispatched run to act on" —
which read as untested, not unreachable.
029f456de7(#15399) is the fix, and it is better evidenced than the original: five gates pinnedby mutation rather than assertion, including
collect_heads(...) -> []proving that without theopen-PR sweep nothing is re-dispatched at all — i.e. demonstrating the merged behaviour rather than
arguing about it.
Two things I take from this for anyone picking up adjacent work:
if:that excludes an event also silently changes what every${{ }}in that step resolvesto. The event guard and the sha expression have to be read together.
unreachability was provable statically, and a mutation test found it in one pass.