feat(ci): detect fleet starvation — the failure mode is silence - #348
Merged
Conversation
On 2026-08-06/07 the self-hosted fleet degraded three times: one wedged runner, then 4 of 7 offline, then 6 of 7. Each time CI simply stopped progressing and looked IDENTICAL to a slow build — no error, no alert, no failed check. One incident burned ~3 hours with a PR sitting at 5/57 checks. Every diagnosis was a human hand-querying the runners API. Nothing here was broken in a way any existing check could see, because nothing was broken: jobs queue forever against a label with no live runner. TWO SIGNALS, IN THIS ORDER: 1. STARVATION (always on, no secret required). Any run queued past 30 minutes. Repo scope only, and it watches the SYMPTOM — so it fires for causes we have not thought of, not just the ones we have. This is the load-bearing check. Validated against today's incident: PR #339 was queued 08:46->11:38 = 172 minutes. This would have alerted at the 30-minute mark, ~2.5 hours before a human noticed. 2. FLEET INVENTORY (optional). Per-runner online/offline/busy, plus WEDGE detection: `offline AND busy`. A runner that drops while HOLDING a job keeps that job assigned forever and no other runner can take it — a naive online-count misses this entirely, which is why a required Format check once "ran" for 55 minutes and then cancelled. Needs /orgs/{org}/actions/runners, which requires admin:org — the default GITHUB_TOKEN CANNOT read it. Set ORG_RUNNER_READ_TOKEN to enable. Without the secret the job still runs and reports starvation only: it degrades, it does not fail. PLACEMENT RULE, NON-NEGOTIABLE: runs on `light`, never `rust-cpu`. A monitor scheduled on the fleet it monitors goes down with that fleet — which is exactly how the outage stayed invisible. Output: a status table in the job summary, and ONE ROLLING ISSUE on alert (comment, not a new issue per tick). Security: no `${{ }}` is expanded inside any `run:` block; every input is bound through `env:` and dereferenced as a quoted shell variable. Verified with a parser over the file, not by eye. Tested locally against the live API: found the queued runs, parsed timestamps (GNU and BSD `date` both), computed ages, and correctly did NOT alert at 4 min. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
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 free
to 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.
Asked for after today’s outages. The page is the second thing worth having; the alert is the first, because the failure mode is silence.
What went wrong today
The self-hosted fleet degraded three times on 2026-08-06/07: one wedged runner, then 4 of 7 offline, then 6 of 7. Each time CI simply stopped progressing and looked identical to a slow build — no error, no alert, no failed check. One incident burned ~3 hours with a PR sitting at 5/57 checks. Every diagnosis was a human hand-querying the runners API.
Nothing existing could have caught it, because nothing was broken: jobs queue forever against a label with no live runner.
Two signals, deliberately in this order
1 · Starvation — always on, no secret required. Any run queued past 30 minutes. Repo scope only, and it watches the symptom, so it fires for causes we haven’t thought of rather than only the ones we have.
Validated against today: #339 was queued 08:46→11:38 = 172 minutes. This alerts at the 30-minute mark — about 2½ hours before a human noticed.
2 · Fleet inventory — optional. Per-runner online/offline/busy, plus wedge detection (
offline AND busy). A runner that drops while holding a job keeps that job assigned forever and no one else can take it; a naive online-count misses it entirely, which is why a requiredFormatcheck once "ran" 55 minutes then cancelled.This needs
/orgs/{org}/actions/runners, which requiresadmin:org— the defaultGITHUB_TOKENcannot read it. SetORG_RUNNER_READ_TOKENto enable. Without the secret the job still runs and reports starvation only: it degrades, it does not fail.The placement rule
Runs on
light, neverrust-cpu. A monitor scheduled on the fleet it monitors goes down with that fleet — which is exactly how the outage stayed invisible. This is the part not to "optimise" later.Output
Verification
${{ }}inside anyrun:block — every input bound viaenv:, dereferenced as a quoted shell variable. Checked with a parser over the file, not by eye.runs-onconfirmed as[self-hosted, linux, x64, light].date), computed ages, and correctly did not alert at 4 minutes.Follow-ups, not in this PR
ORG_RUNNER_READ_TOKENsecret.🤖 Generated with Claude Code
https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG