Skip to content

CI capacity: 56 of 63 job definitions target ubuntu-latest while 12 self-hosted runners idle — 401 jobs queued, 0 in progress during the v0.59 merge wave #1062

Description

@avrabe

The measurement

During the v0.59 merge wave (8 PRs in flight) CI reached a hard stall:

2026-08-26T02:20Z   completed=34   in_progress=0   queued=401
oldest queued job waiting ~52 min; two samples 20 min apart both showed
in_progress=0 with completed unchanged at 34.

Meanwhile the org's self-hosted fleet was 12 runners online, every one
busy=false
.

That is not a misconfiguration — I checked, and the labels match:

requested by workflows runners satisfying it
[self-hosted, linux, x64, rust-cpu] 7 of 12
[self-hosted, linux, x64, light] 1 of 12

The fleet is schedulable. It is simply almost never asked for:

runs-on: ubuntu-latest                        56 job definitions
runs-on: [self-hosted, linux, x64, rust-cpu]   3
runs-on: [self-hosted, linux, x64, light]      2
runs-on: macos-latest                          1

56 of 63 job definitions target GitHub-hosted runners. synth runs ~50 jobs
per PR (the oracle sweep is the bulk of it), so N PRs in flight queue ~50N jobs
against GitHub-hosted concurrency shared with the rest of the org — gale and loom
were queued at the same moment — while 12 paid runners idle.

Why it matters beyond waiting

The hub model this repo uses (parallel lanes, PR-not-merge, coordinator gates)
structurally produces bursts of 6–8 simultaneous PRs. At ~50 jobs each that is
a 300–400 job burst every wave. The stall is therefore not a one-off; it is the
expected steady state of the working model, and it currently gates every merge
and therefore every release cut.

What I did NOT do, and why

I did not move jobs to the self-hosted fleet. Under this repo's branch
protection a required check that cannot run deadlocks all merges — so
retargeting any of the 9 required contexts (Format, Clippy, Test,
Z3 Verification, Claim Check, Version Pin Sweep, Bazel Build & Proofs,
Kani Verification, Rivet Validation) is a change to gate infrastructure and
should be deliberate, not a side effect of clearing a queue.

I did cancel three superseded runs (older SHAs on branches that had been
force-pushed, including a deliberate red-first probe whose RED result is already
recorded in #1060's body). Verified surgical: current-head rollups unchanged and
zero required checks left CANCELLED on any of the 8 open PRs.

Options, roughly in increasing order of commitment

  1. Move the non-required oracle sweep to rust-cpu. The ~40 scripts/repro/*
    execution differentials are the bulk of the burst and none of them are in the
    required set, so a mis-scheduled one blocks nothing. Largest relief, lowest
    risk.
  2. Stagger lane pushes so a wave lands as 2–3 PRs rather than 8. Cheap, but
    it slows the hub and does not use the idle capacity.
  3. Retarget some required checks to self-hosted. Highest relief, highest
    risk — needs the deadlock caveat above respected, and probably a period of
    running both before flipping the required context.
  4. More GitHub-hosted concurrency. Spend, not engineering.

Option 1 looks like the right first move: it is reversible, it touches nothing
required, and it aims exactly at the jobs that make the burst large.

Reproduce

gh api orgs/pulseengine/actions/runners --jq '.runners[]|"\(.name) \(.labels|map(.name)|join(","))"'
grep -hoE '^\s+runs-on:.*' .github/workflows/*.yml | sed 's/^\s*runs-on:\s*//' | sort | uniq -c | sort -rn

Note for whoever measures the queue: gh run list --json status reports run
status, and a run stays queued while its jobs complete. Census at job level
(actions/runs/<id>/jobs) or the number is misleading.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions