Playbook: Queue Dispatcher RAM-Gate Exhaustion
Diagnose and clear the failure mode where the worker-queue dispatcher stops
spawning workers (dispatched 0 jobs) even though the queue is full, because the
RAM budget has drained to zero from phantom stale rows.
Target: < 5 min to resolve. This is the runbook for the exact incident fixed in PR #1340 + migration
037_fix_reset_stale_workers_server_label.sql.
What triggers this
The 2-min dispatcher (scripts/queue-dispatcher.sh → queue-dispatcher-loop.py,
systemd timer on vps-i1 primary / bms-4 backup) gates every spawn on a RAM budget:
avail_ram = available_ram_gb(server) # total_ram − Σ weight_ram_gb of claimed/running rows
if avail_ram < weight_ram_gb[job_weight]: # block — "RAM gate blocked"
available_ram_gb() subtracts the RAM weight of every claimed/running row
from the server budget. If those rows are never cleaned up, the budget only ever
goes down. reset_stale_workers() is the function that is supposed to release
rows older than 2 hours back to queued (or failed) — so a broken
reset_stale_workers() lets stale rows accumulate forever until the budget hits 0
and all dispatches are blocked.
Meanwhile the hourly triage keeps inserting fresh queued rows, so the queue
grows while nothing is dispatched.
Symptoms:
- Dispatcher log:
Server <name>: dispatched 0 jobs this cycledespite a non-empty queue - Dispatcher log: repeated
RAM gate blocked: avail=0.0GB needed=3GB ... - Discord
#infra-scripts-errors: recurringreset_stale_workers() RPC failedembeds fromqueue-dispatcher error on <server> - New
ai-dev-queuedissues sit untouched; SLA watchdog starts flagging them
How to confirm
Run against Supabase (mcp__claude_ai_Supabase__execute_sql, project
mwkqmgadqnkkihjdeqsi, or psql/REST with the service-role key).
-- 1. RAM budget drained? 0 or negative on either server confirms the gate is stuck.
SELECT available_ram_gb('bms-4') AS bms4, available_ram_gb('vps-i1') AS vpsi1;
-- 2. Phantom rows? > 0 stale claimed/running rows older than 2h is the smoking gun.
SELECT count(*) AS stale_rows
FROM dev_r_worker_queue
WHERE status IN ('claimed','running')
AND COALESCE(started_at, claimed_at, queued_at) < NOW() - INTERVAL '2 hours';
-- 3. Queue is actually backed up (work is waiting)
SELECT status, count(*) FROM dev_r_worker_queue GROUP BY status ORDER BY status;
-- 4. Inspect the stale rows
SELECT id, github_issue_number, job_type, weight, status, server_node,
COALESCE(started_at, claimed_at, queued_at) AS age_ref, retry_count, max_retries
FROM dev_r_worker_queue
WHERE status IN ('claimed','running')
AND COALESCE(started_at, claimed_at, queued_at) < NOW() - INTERVAL '2 hours'
ORDER BY age_ref;Confirm the RPC itself is the cause (it should error, not silently no-op):
SELECT reset_stale_workers(); -- if it raises "column ... does not exist", the function is brokenOn the leader server, confirm the dispatcher is the one logging zeros:
ssh root@<leader> 'tail -40 /var/log/p24-infra-dispatcher.log'
# look for: "RAM gate blocked: avail=0.0GB" and "dispatched 0 jobs this cycle"Step-by-step fix
1. Try the function first
SELECT reset_stale_workers();- Returns void without error → the function is healthy; jump to step 3 (verify). The phantom rows were probably from a one-off crash, not a code bug.
- Raises an error (e.g.
column "server_label" does not exist) → the function body is broken. Go to step 2 to repair it, then re-run cleanup.
2. Repair the function + cleanup phantom rows
The known root cause (migration 036) was reset_stale_workers() referencing the
non-existent column server_label on dev_r_worker_queue (the real column is
server_node). The canonical fix is migration
monitoring/supabase/migrations/037_fix_reset_stale_workers_server_label.sql —
apply it if not already applied:
SELECT name FROM supabase_migrations.schema_migrations
WHERE name LIKE '%reset_stale_workers%'; -- empty → not applied yetIf not applied, run migration 037 via mcp__claude_ai_Supabase__apply_migration
(or paste its body into execute_sql). It does two things: recreates
reset_stale_workers() with the correct server_node column, and runs a
one-time cleanup of accumulated phantom rows.
If you cannot apply the migration immediately, do the manual cleanup directly
(this is exactly what migration 037 part 2 does). It releases retryable rows and
fails out exhausted ones, leaving slot-tracking rows (subagent,
github-actions) alone:
-- Retryable: send back to the queue
UPDATE dev_r_worker_queue
SET status='queued', claimed_at=NULL, started_at=NULL, server_node=NULL,
error_message='manual reset — RAM-gate exhaustion playbook'
WHERE status IN ('claimed','running')
AND COALESCE(started_at, claimed_at, queued_at) < NOW() - INTERVAL '2 hours'
AND retry_count < max_retries
AND job_type NOT IN ('subagent','github-actions');
-- Exhausted: mark failed (terminal)
UPDATE dev_r_worker_queue
SET status='failed',
error_message='max retries exceeded — RAM-gate exhaustion playbook'
WHERE status IN ('claimed','running')
AND COALESCE(started_at, claimed_at, queued_at) < NOW() - INTERVAL '2 hours'
AND retry_count >= max_retries
AND job_type NOT IN ('subagent','github-actions');3. Verify RAM recovers
SELECT available_ram_gb('bms-4') AS bms4, available_ram_gb('vps-i1') AS vpsi1;
-- expect positive values: bms-4 ~22 GB budget, vps-i1 lower (8 GB host)4. Watch the next dispatcher cycle (≤ 2 min)
ssh root@<leader> 'tail -f /var/log/p24-infra-dispatcher.log'
# expect: "Server <name>: dispatched N jobs this cycle" (N > 0)If it’s still blocked after one cycle, re-check available_ram_gb() — a worker
that is genuinely running and consuming RAM will (correctly) hold budget; only
stale rows (> 2h) are phantom.
Prevention
- Root cause fixed: migration 037 corrected the
server_label→server_nodecolumn bug, soreset_stale_workers()now succeeds on every dispatcher cycle and phantom rows can no longer accumulate. - Monitor Discord: the dispatcher posts
reset_stale_workers() RPC failedtoP24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URLwhenever the RPC errors (scripts/queue-dispatcher.shstep 2). Treat any such alert as an early warning of this exact failure mode — investigate before the budget drains. - When editing
reset_stale_workers()or any function touchingdev_r_worker_queue: the table usesserver_node, notserver_label(which only exists ondev_r_server_capacity). Always test the function withSELECT reset_stale_workers();after a migration — a column typo fails silently at runtime, not at deploy time. - Periodic spot-check:
SELECT available_ram_gb('bms-4'), available_ram_gb('vps-i1');should always be > 0 during normal operation.
Escalation
If RAM stays at 0 after cleanup and the function runs clean:
- The
dev_r_server_capacityconfig may be wrong — verifyos_ram_gb,reserved_ram_gb,weight_ram_gb, andemergency_max_workersfor the affected server (SELECT * FROM dev_r_server_capacity WHERE enabled;). The budget formula is(os_ram_gb − reserved_ram_gb) × 0.80. - Check for genuinely runaway workers (
pidstill alive on the host) holding real RAM — those are not phantom and should not be force-reset. - Send a P1 Discord alert and open a GitHub issue per the Error Notification
Standard (
CLAUDE.md).
Related
- PR #1340 —
fix: reset_stale_workers() server_label bug blocks 2-min dispatcher - Migration
037_fix_reset_stale_workers_server_label.sql - Migration
038_ram_budget_dispatch.sql—available_ram_gb()+ RAM-budget gate - worker-queue-operations.md
- adding-new-worker.md
- oom-auto-remediation.md