Worker Queue — Operations Manual

The unified worker queue routes all Claude Code worker spawns through Supabase and enforces per-server concurrency limits via a centralized HA dispatcher.

Architecture

┌──────────────────────────────────────────────────────────────────┐
│  JOB SUBMITTERS                                                  │
│  hourly-devops-triage (Phase 3) · /new-issue · manual INSERT     │
└──────────────────────────┬───────────────────────────────────────┘
                           │ INSERT  priority int (10=urgent 50=normal 100=low)
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│  dev_r_worker_queue  (Supabase PostgreSQL)                       │
│  status: queued → running → done                                 │
│                         └──→ failed / oom_killed / cancelled     │
└──────────────────────────┬───────────────────────────────────────┘
                           │ every 2 min (systemd timer)
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│  queue-dispatcher.sh  (bms-4)                                    │
│  ① claim_dispatcher_lease('bms-4')  → is leader?                │
│  ② reset_stale_workers()            → re-queue running > 2h     │
│  ③ load dev_r_server_capacity       → enabled servers + limits  │
│  ④ queue-dispatcher-loop.py         → fill slots by priority    │
└──────────────────────────┬───────────────────────────────────────┘
                           │ SSH + sudo spawn-worker.sh
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│  bms-4  (sole active worker host)                                │
│  spawn-worker.sh: systemd-run --scope --MemoryMax=<weight>       │
│  → claude --dangerously-skip-permissions -p "<worker-prompt>"    │
│  → PID returned → dispatcher PATCH status=running, pid=PID       │
│  → worker completes Step 11 → PATCH status=done, completed_at   │
└──────────────────────────────────────────────────────────────────┘

Role-Based Routing (#1706)

The meta-dispatcher classifies every task at enqueue time and writes three columns to dev_r_worker_queue. The worker then reads role to select its agent prompt — no inference at spawn time — and each server claims only the rows it should run.

ColumnValuesMeaning
roleinfra | dev | orchestrator (default dev)Selects the agent prompt in spawn-worker.sh: infrainfra-task-request-worker.md, devworker-issue.md, orchestratorworker-issue.md (Step 5-ORCH fan-out lives there)
repo_contextrepo slug or NULLFor role=dev: repo to clone (e.g. radieu/et-operational-platform). NULL = default radieu/p24-infra
server_preferencevps-i1 | bms-4 | NULLServer affinity. A server claims rows where server_preference = <its label> OR server_preference IS NULL

Classification rules (infra-src/meta-dispatcher/src/classify.ts, applied in singleDispatch() + runMetaDispatcher()):

Signalrolerepo_contextserver_preference
job_type review-pr/review-plan (light)devrepo if dev repo, else NULLvps-i1
Title prefix [alert] or [secret-rotation]infraNULLbms-4
Label infra-task-requestinfraNULLbms-4
Repo radieu/p24-infra + label buginfraNULLbms-4
Dev repo (et-operational-platform, Art-Agency, brandpilot, radekkonarski-personal-brand)devthe repo slugbms-4
Default (p24-infra dev-issue)devNULLNULL (any server)

Rollout safety: all three columns are additive with safe defaults — existing rows backfill to role='dev', NULL repo_context/server_preference. spawn-worker.sh falls back to job_type-based prompt selection when the ROLE arg is empty, so an old dispatcher and the new worker (or vice versa) interoperate during deploy.

Timing Reference

EventInterval / Threshold
Dispatcher cycleevery 2 min (systemd timer on bms-4)
reset_stale_workers()every 2 min (called inside each dispatcher run)
Job stale threshold2h in running → re-queue (max 3 retries, then failed)
Dispatcher lease TTL5 min (any enabled server claims after expiry)
SLA escalation — infra jobs4h with no PR → P1 alert (triage Phase 1C watchdog)
Prometheus stuck-job alertrunning > 2h → WorkerQueueJobStuck warning
NightlyTriageNotRunningno heartbeat for >25h (nightly triage, daily 20:00 UTC) → warning

Job Lifecycle

queued ──(dispatcher claims)──→ running ──(worker Step 11)──→ done
                                   │
                    ┌──────────────┼───────────────────┐
                    ▼              ▼                    ▼
                 failed        oom_killed           cancelled
                    │
             retry_count < 3?
               yes → queued  (re-queued by reset_stale_workers after 2h)
               no  → failed  (terminal)

Self-Healing

FailureDetectionAuto-recovery
Worker process crashes silentlyreset_stale_workers() every 2 minRe-queues after 2h running (max 3 retries)
Worker OOM (exit 2)spawn-worker.sh exit code checkRe-queues as weight=heavy; terminal if heavy also OOM
Dispatcher process diessystemd timer auto-restarts the serviceNext 2-min tick starts a fresh dispatcher run
Dispatcher server (bms-4) rebootsLease expires in 5 minTimer auto-starts post-boot; lease claimed on first run
Lease stuck on dead serverexpires_at < NOW() conditionAny enabled server claims leadership on its next cycle
bms-4 fully offline (extended)No dispatching, no heartbeatNo auto-failover — vps-i1 disabled. Enable manually (see below). NightlyTriageNotRunning fires after >25h.
WIP label orphaned after crashPhase 1A WIP watchdog (hourly triage)Clears stale WIP labels when no active session row exists in Supabase

Dispatcher lease election (sticky leader — no failback)

The active dispatcher is chosen by the claim_dispatcher_lease(p_holder) RPC (monitoring/supabase/migrations/036_worker_queue_operational_extensions.sql), which queue-dispatcher.sh calls once per 2-min cycle. The election is a sticky lease with no failback and no preemption — it does not prefer any “nominal primary”. Two branches:

BranchConditionEffect
Renewholder = p_holder AND expires_at > NOW()Push expires_at to NOW()+5min, return TRUE (stay leader)
Acquireholder IS NULL OR expires_at < NOW()Take the lease, acquired_at=NOW(), generation+1, return TRUE

With a 5-min TTL and a 2-min renew cycle, whoever holds the lease keeps it indefinitely while it stays healthy. A follower (e.g. vps-i1) calls the RPC every cycle, matches neither branch (it is not the holder, and the holder’s lease has not expired), gets FALSE, logs Not leader, and exits. A follower only becomes leader once the current holder misses renewals for >5 min (host down) and the lease expires — and even then the lease does not return to the previous holder on its own; it is claimed by whichever enabled server’s timer fires first during the expired window.

This means a leader never fails back. Once bms-4 acquired the lease it holds it forever, by design. That is why bms-4 has been the leader continuously since 2026-07-30 even though vps-i1 is healthy — expected steady state, not a stuck failover (#5643). This is also preferable: bms-4 is the intended active dispatcher (see Active Worker Hosts — bms-4 enabled, 3 workers / playwright weight; vps-i1 disabled, 1 worker / light).

How to tell renewal from re-acquisition in dev_r_dispatcher_lease:

SELECT holder, acquired_at, expires_at, generation FROM dev_r_dispatcher_lease;
  • acquired_at frozen while expires_at keeps advancing → the same holder is renewing continuously (sticky, healthy). This is the normal picture.
  • acquired_at moving / generation climbing → the lease actually changed hands (a real failover happened because the prior holder went unhealthy).

To deliberately hand leadership back to a specific server, expire the lease so the next cycle re-elects (see Force dispatcher lease release); there is no automatic mechanism for it.

Active Worker Hosts

ServerStatusMax workers prime/nightMax weight
bms-4 (54.36.123.110)enabled3 / 3playwright
vps-i1 (217.154.82.162)disabled

To enable vps-i1 as emergency fallback:

UPDATE dev_r_server_capacity SET enabled = true WHERE server_label = 'vps-i1';

Priority System

Integer valuePriorityUsed by
10urgentInfra alerts, P1 issues
50normalRoutine dev issues
100lowBackground / deferred tasks

Lower number = higher priority. Column type is INTEGER — never pass a string.

Submitting a Job

-- Via Supabase MCP (recommended from local workstation — sb_secret_ key blocks REST)
INSERT INTO dev_r_worker_queue
  (job_type, weight, issue_number, github_issue_number, repo, priority, metadata)
VALUES
  ('dev-issue', 'light', 1234, 1234, 'radieu/p24-infra', 50, '{"source":"manual"}');
# Via hourly-devops-triage function (inside triage script)
Invoke-QueueWorker -issueNum 1234 -jobType "infra-alert" -weight "light" -priority 10
# priority must be [int] 10 / 50 / 100 — not a string
# Via bash on a server
curl -sf -X POST "$SUPABASE_URL/rest/v1/dev_r_worker_queue" \
  -H "apikey: $SUPABASE_SERVICE_KEY" \
  -H "Authorization: Bearer $SUPABASE_SERVICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"job_type":"infra-alert","weight":"light","issue_number":999,"repo":"radieu/p24-infra","priority":10}'

Key Tables

TablePurpose
dev_r_worker_queueAll worker jobs — status, priority, weight, pid, retry_count
dev_r_server_capacityPer-server slot limits and weight class support
dev_r_dispatcher_leaseHA singleton — which server is active dispatcher
dev_r_agent_sessionsPer-session tracking (INSERT at Step 0, PATCH at Step 11)

Monitoring

Grafana dashboard: grafana.vps-i1.infra.zintegrowana.online/d/worker-queue-v1

Panels: queued/running/done/failed stat counters · queue depth timeseries · active workers table with running-time heat-map · recent 25 jobs table with status colour coding.

Prometheus metrics (queue-exporter :9200 on vps-i1):

supabase_queue_depth{queue="dev_r_worker_queue", status="queued"}
supabase_queue_depth{queue="dev_r_worker_queue", status="running"}
supabase_queue_depth{queue="dev_r_worker_queue", status="done"}
supabase_queue_depth{queue="dev_r_worker_queue", status="failed"}
supabase_queue_depth{queue="dev_r_worker_queue", status="oom_killed"}
supabase_queue_depth{queue="dev_r_worker_queue", status="cancelled"}

Alertmanager rules (monitoring/prometheus/rules/ai-workers.yml):

AlertConditionSeverity
WorkerQueueJobStuckany job running > 2hwarning
NightlyTriageNotRunningno triage heartbeat for >25h (nightly, daily 20:00 UTC)warning
NightlyTriageFailinglast triage run ended failedwarning

Quick-check SQL:

-- Queue depth by status
SELECT status, COUNT(*) FROM dev_r_worker_queue GROUP BY status ORDER BY COUNT(*) DESC;
 
-- Who holds the dispatcher lease?
SELECT holder, expires_at, NOW() > expires_at AS expired FROM dev_r_dispatcher_lease;
 
-- Active workers
SELECT id, job_type, server_node, weight, started_at, pid
FROM dev_r_worker_queue WHERE status = 'running';
 
-- Stuck jobs (>2h — alert territory)
SELECT id, job_type, server_node, started_at, retry_count
FROM dev_r_worker_queue
WHERE status = 'running' AND started_at < NOW() - INTERVAL '2 hours';
# Check dispatcher timer on bms-4
ssh root@54.36.123.110 "systemctl status p24-queue-dispatcher.timer"
ssh root@54.36.123.110 "journalctl -u p24-queue-dispatcher.service --since '10 min ago'"

Manual Operations

Re-queue a stuck job

UPDATE dev_r_worker_queue
SET status = 'queued', retry_count = retry_count + 1
WHERE id = <N>;

Fail a job permanently

UPDATE dev_r_worker_queue
SET status = 'failed', error_message = 'manually failed — <reason>'
WHERE id = <N>;

Kill a running worker

# List active worker scopes on bms-4
ssh root@54.36.123.110 "systemctl list-units 'p24-worker-*' --state=active"
 
# Stop a specific worker
ssh root@54.36.123.110 "systemctl stop p24-worker-dev-issue-1234.scope"

Then manually update the queue row or let reset_stale_workers() catch it after 2h.

Force dispatcher lease release

-- Forces any enabled server to claim leadership on its next 2-min cycle
UPDATE dev_r_dispatcher_lease SET expires_at = NOW() - INTERVAL '1 second';

Trigger dispatcher manually

ssh root@54.36.123.110 "systemctl start p24-queue-dispatcher.service"

Force re-queue all stale running jobs now

SELECT public.reset_stale_workers();

Dispatcher Setup (one-time per new server)

# 1. Server identity file
echo "bms-4" > /opt/p24-infra/.server-label
 
# 2. Linger for claude-runner (allows systemd-run from SSH)
loginctl enable-linger claude-runner
 
# 3. Sudoers entry
echo "root ALL=(claude-runner) NOPASSWD: /opt/p24-infra/scripts/spawn-worker.sh" \
  > /etc/sudoers.d/p24-spawn-worker
chmod 440 /etc/sudoers.d/p24-spawn-worker
 
# 4. Enable timer
systemctl enable --now p24-queue-dispatcher.timer

Weight Classes

WeightMemory limitEligible servers
light3 GBbms-4 (and vps-i1 if enabled)
heavy8 GBbms-4
playwright6 GBbms-4 only