GH Actions self-hosted runner — worker-queue registration
Issue: #1923
Status: active (p24-infra side); the consuming workflow lives in radieu/whatsup-android-chat-puller
Why
Self-hosted GH Actions runners (e.g. bms4-android) share a physical host (bms-4) with the Claude
worker fleet. A heavy CI job — a full Android build, connectedAndroidTest (Espresso/e2e), or a
coverage report — saturates the host’s RAM/CPU. If the queue dispatcher spawns Claude workers on
that same host while the build runs, both starve.
The fix: a heavy runner job registers itself in Supabase gh_runner_sessions when it starts and
deregisters when it finishes. queue-dispatcher-loop.py reads that table each cycle and defers
Claude worker dispatch on any server with an active heavy job.
Light/fast jobs (merge commits, changelog updates, label/milestone sync, anything <~30s) do NOT register — the Supabase round-trip overhead is not worth it and would pollute the busy signal.
What counts as “heavy”
| Heavy (register) | Light (no register) |
|---|---|
assembleRelease / assembleDebug full build | PR merge commit |
connectedAndroidTest (e2e, Espresso) | Changelog update |
test with coverage (jacocoTestReport) | Label/milestone sync |
| Any job >~2 min estimated | Any job <~30s estimated |
Components
| Component | Location | Role |
|---|---|---|
gh_runner_sessions table | supabase/migrations/20260628110358_gh_runner_sessions.sql | One row per heavy runner job (running → done/failed) |
runner-register.sh | scripts/runner-register.sh | Called by CI steps: start / finish [done|failed] |
_runner_busy() gate | scripts/queue-dispatcher-loop.py | Per-server check; skips Claude dispatch while a heavy job runs |
build-apk.yml steps | radieu/whatsup-android-chat-puller (separate repo) | Register/deregister around the build |
How it works
- Build starts → first workflow step runs
runner-register.sh start, whichPOSTs a row togh_runner_sessions(status='running') and stashes the row id in/tmp/runner-session-id. - Dispatcher cycle → for each server,
_runner_busy(server_label)queriesgh_runner_sessions?server_node=eq.<label>&status=eq.running&started_at=gte.<now-2h>. If any row is returned, the dispatcher logsdeferring Claude worker dispatch this cycleandcontinues to the next server — no job claim is consumed, queued jobs stay queued for the next cycle / another server. - Build finishes (always, even on failure via
if: always()) →runner-register.sh finish <status>PATCHes the row tostatus='done'|'failed'withfinished_at.
Stale-row self-heal (2h cutoff)
A runner that crashes mid-build never calls finish, leaving a stale running row that would block
dispatch forever. Both the read path (_runner_busy, RUNNER_STALE_MINUTES = 120) and the migration
treat rows older than 2 hours as stale and ignore them. Manual cleanup if needed:
UPDATE gh_runner_sessions SET status='failed', finished_at=now()
WHERE status='running' AND started_at < now() - interval '2 hours';Keep RUNNER_STALE_MINUTES (dispatcher) and the 2h interval (migration comment) in sync if you
change one.
Fail-soft guarantees
runner-register.shalways exits 0 — a Supabase hiccup must never fail a build. A hard curl failure emits a best-effort Discord warning (P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL) but does not open a GH issue (transient telemetry blips would be alert noise)._runner_busy()returns[](not busy) on any error — a telemetry problem never wedges dispatch.
Secrets
runner-register.sh reads SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY from the environment only
(never printed). In CI they come from GH Secrets in the consuming repo. Values originate from
secrets/monitoring.env.sops (SUPABASE_URL) and secrets/n8n-bms4.env.sops
(SUPABASE_SERVICE_ROLE_KEY) — SOPS+age, never committed in plaintext.
Consuming-repo wiring (separate task)
The radieu/whatsup-android-chat-puller workflow change and its GH Secrets are out of scope for
the p24-infra repo and tracked as a follow-up. The build job must:
- Add
SUPABASE_URLandSUPABASE_SERVICE_ROLE_KEYto the repo’s GH Secrets. - Add a first step
run: /opt/p24-infra/scripts/runner-register.sh start. - Add a final step with
if: always():run: /opt/p24-infra/scripts/runner-register.sh finish ${{ job.status == 'success' && 'done' || 'failed' }}.
See issue #1923 for the full build-apk.yml snippet.