Skip to content

feat(ci): detect fleet starvation — the failure mode is silence - #348

Merged
avrabe merged 1 commit into
mainfrom
feat/fleet-status-alerting
Aug 7, 2026
Merged

feat(ci): detect fleet starvation — the failure mode is silence#348
avrabe merged 1 commit into
mainfrom
feat/fleet-status-alerting

Conversation

@avrabe

@avrabe avrabe commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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 required Format check once "ran" 55 minutes then cancelled.

This 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.

The placement rule

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. This is the part not to "optimise" later.

Output

  • status table in the job summary (the "page")
  • one rolling issue on alert — comments on the existing one rather than a new issue per tick

Verification

  • No ${{ }} inside any run: block — every input bound via env:, dereferenced as a quoted shell variable. Checked with a parser over the file, not by eye.
  • YAML parses; runs-on confirmed as [self-hosted, linux, x64, light].
  • Detection logic tested live against the real API: found the queued runs, parsed timestamps (GNU and BSD date), computed ages, and correctly did not alert at 4 minutes.

Follow-ups, not in this PR

  • If you want the fleet table, add the ORG_RUNNER_READ_TOKEN secret.
  • The 30-minute threshold is a first guess; tune once we see a few weeks of real data.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG

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
@avrabe
avrabe enabled auto-merge (squash) August 7, 2026 19:04
@avrabe
avrabe merged commit fa8dc11 into main Aug 7, 2026
11 checks passed
@avrabe
avrabe deleted the feat/fleet-status-alerting branch August 7, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant