Playbook: Provision bms-3 as a 3rd Claude Agent Dispatch Node
Issue: #2147 · Strategy Phase 2 item [11]
RAM Guard: #3397 — Mongo primary/secondary
hard gate + GB bin-packing scheduler (this doc’s model, superseding the original static-count seed)
Scope: Bring bms-3 (51.68.155.224) online as a third worker dispatch node, raising parallel
capacity from 6-7 → up to 11-12 agents without new hardware.
⚠️ bms-3 is a production MongoDB rs0 voting member + Pinbox24 staging host. Every step below runs on a live database server. The MongoDB PRIMARY/SECONDARY must never be CPU/RAM starved. Caps start deliberately low and ramp up only after the soak test. This is a human-supervised server operation — annotate each mutating SSH/SCP with
# PLAYBOOK: provision-bms-3-dispatch-node.md.
Status (2026-07-08): The two blockers that force-disabled bms-3 (
20260702100000_disable_bms3_dispatch_node.sql) are resolved and re-verified live as of this date — SSH works (key-based,BatchMode=yes, no interaction) and MongoDB replication lag is0with bms-3 confirmedSECONDARY(see #2382, #2384, both closed with the verification evidence). The RAM Guard (#3397) below is what makes it safe to flipenabled=trueagain — it was not flipped automatically as part of that work; the final activation is still the human-supervised step in the “Activation” section further down.
Additional prerequisite resolved (#3427, 2026-07-09):
spawn-worker.shpreviously ignored per-serverweight_ram_gbentirely — itscase "$WEIGHT"block hardcodedlight) MEM_GB=3regardless of server, so the actual systemd cgroup limit and pre-spawn free-RAM check on bms-3 used 3 GB per light worker even though the RAM Guard’s own bin-packing ledger (get_ram_usage/job_raminqueue-dispatcher-loop.py) assumed 1 GB — a 3x ledger-vs-enforcement mismatch that could have let bms-3 be oversubscribed to real RAM well past the ~17.6 GB budget once enabled. Fixed byqueue-dispatcher-loop.py’sspawn_worker()now resolving and passing the per-server value through asCLAUDE_WORKER_MEM_GB, honoured byspawn-worker.sh. Step 2 below (deploy scripts to bms-3) must deploy the fixedspawn-worker.sh+queue-dispatcher-loop.py— a copy dated before #3427 reintroduces this exact mismatch.
RAM Guard model (#3397) — read this before touching enabled
The original bring-up (#2147) used a static worker-count cap (max_workers_prime=1, fixed 4 GB
budget). That model is superseded — bms-3 now has:
- A live Mongo-role hard gate. Every dispatcher cycle (~2 min),
queue-dispatcher-loop.py’sbms3_mongo_guard_cycle()reads bms-3’s own MongoDB replica-set state via the existingmongodb-exporter-bms3container (runs ON bms-4 itself, port 9217 — seebms-4/docker-compose.yml; no new Mongo credentials, no new reachability path). If bms-3 is not confirmed SECONDARY (i.e. it isPRIMARYor the read fails →UNKNOWN), the guard hard-flipsdev_r_server_capacity.enabled = FALSEfor bms-3 and skips it for the current cycle too (not just the next one) — no new workers are dispatched. Once bms-3 is confirmedSECONDARYagain, the guard automatically re-enables dispatch — but only if the row is currently disabled because the guard itself disabled it (tracked via themongo_guard_disabledcolumn). A human-initiated disable (e.g. the ships-disabled pre-soak-test state, or a deliberate maintenance disable) is never silently overridden by the guard. - GB bin-packing instead of a worker-count cap.
weight_ram_gbfor bms-3 is{"light":1,"heavy":8,...}— “small” (light) workers cost 1 GB each, no fixed count cap;max_workers_prime/max_workers_nightare set high (30) purely so that column never becomes the binding constraint — live GB headroom is. The worker budget while SECONDARY isos_ram_gb (31) - reserved_ram_gb (9)) * 0.80 ≈ 17.6 GB(kept just under the 18 GB target because those two columns areINT, notNUMERIC). While PRIMARY the budget is effectively 0 — the Mongo-role gate above blocks all dispatch regardless of any RAM math. - Heavy jobs are lowest priority, capped at 1 concurrent, never preempt. The dispatch loop runs
a small-first, heavy-last two-pass order on bms-3 — the opposite of bms-4’s heavy-first
policy (
docs/playbooks/queue-dispatcher-priority.md§“bms-4 heavy-first”). Small (light) jobs bin-pack against the full budget first; at most one heavy job (BMS3_MAX_CONCURRENT_HEAVY = 1) dispatches afterward, and only into whatever headroom remains — nothing in the dispatcher ever kills a running job to make room, so a queued heavy job simply waits until small jobs finish and free enough GB naturally. - A Prometheus guard-failure safety net.
Bms3DispatchEnabledWhilePrimary(monitoring/prometheus/rules/servers.yml, groupbms3_ram_guard) fires if bms-3 ever reportsPRIMARYwhile dispatch capacity is still enabled — i.e. if the guard above failed to flip in time. Mirrors theVpsI1WorkersUnexpectedlyEnabledpattern indocs/playbooks/worker-capacity-management.md.
Migration: supabase/migrations/20260708130000_bms3_ram_guard.sql. Code:
scripts/queue-dispatcher-loop.py (bms3_mongo_role, bms3_mongo_guard_cycle,
_bms3_guard_disable, _bms3_guard_reenable, the BMS3_LABEL-gated dispatch_passes branch).
Tests: scripts/tests/test_bms3_ram_guard.py.
How dispatch targeting actually works (read first)
The dispatcher is fully DB-driven. There is no static SERVERS config file.
scripts/queue-dispatcher.sh builds DISPATCHER_SERVERS at runtime from:
SELECT … FROM dev_r_server_capacity WHERE enabled = true;So “add bms-3 to the SERVERS list” = insert/enable the bms-3 row in dev_r_server_capacity.
Consequence — order matters: the moment the bms-3 row is enabled = true, both dispatcher
leaders (vps-i1 + bms-4) immediately try to ssh root@51.68.155.224 and run spawn-worker.sh. If
the host is not yet prepared, every spawn fails (exit 255) → spawn_failures + Discord error spam +
backoff. Therefore enabled = true is the LAST step, after install + verification.
The migration supabase/migrations/20260630011055_bms3_dispatch_node_capacity.sql seeds the row with
enabled = false and conservative caps. It does not activate anything.
Capacity row (seeded by #2147’s migration, updated by #3397’s RAM Guard migration)
| Column | Value | Why |
|---|---|---|
server_label | bms-3 | PK |
ssh_host | 51.68.155.224 | dispatcher SSH target |
ssh_user | root | dispatcher SSHes as root, then sus to the worker user (migration 037) — not claude-runner |
enabled | false | inert until provisioned + soak started — not touched by the #3397 migration; flipping to true remains the human “Activation” step below |
mongo_guard_disabled | false | new in #3397 — true only while the Mongo-role guard itself is the reason enabled=false; lets the guard auto-re-enable without ever overriding a human disable |
max_workers_prime / night | 30 / 30 | intentionally high (#3397) — no static worker-count cap; GB bin-packing is the real limit |
max_weight_prime / night | heavy / heavy | small (light) jobs + at most 1 heavy; still no playwright/orchestrator on a DB host |
emergency_max_workers | 1 | legacy hard cap column — not read by the current dispatch loop |
os_ram_gb / reserved_ram_gb | 31 / 9 | confirmed live via free -g 2026-07-08 → (31-9)*0.80 ≈ 17.6 GB worker budget while SECONDARY only (0 while PRIMARY, enforced by the Mongo-role hard gate, not by these columns) |
worker_ram_gb | 1 | small-worker OOM guard (was 3; #3397 changed “small” workers to 1 GB each) |
weight_ram_gb | {"light":1,"heavy":8,"subagent":0,"playwright":8,"orchestrator":12} | per-server RAM-per-weight override (#3397) — light=1 GB is bms-3-specific, others are fleet defaults |
claude_accounts | NULL | dispatcher falls back to ["claude-runner"]; set the real account list in step 1 |
The original issue’s aspirational target was “3-4 agents” under a static count cap. #3397 replaced that framing entirely: there is no longer a target agent count — the GB budget (~17.6 GB) and the live Mongo-role gate are what bound bms-3’s load. Ramp-up now means widening the RAM budget (raising
reserved_ram_gbdown, i.e. lowering it) after a soak test proves headroom, not raising a worker count.
Live provisioning steps (human action)
Run from a session that can SSH to bms-3. Read the secrets-safety rules first — never echo key values.
1. Create the worker user + Claude account
ssh root@51.68.155.224 # PLAYBOOK: provision-bms-3-dispatch-node.md
# Create claude-runner (same as bms-4) if absent
id claude-runner >/dev/null 2>&1 || useradd -m -s /bin/bash claude-runner
# Set CLAUDE_WORKER_ID per the naming convention (see adding-new-worker.md)
echo 'export CLAUDE_WORKER_ID=bms3-cw-1' >> /home/claude-runner/.bashrcInstall Claude Code for claude-runner and authenticate the subscription (OAuth). If bms-3 carries
more than one account, update dev_r_server_capacity.claude_accounts for bms-3 to the JSONB list
([{"user":"claude-runner","depleted":false,...}]), mirroring the bms-4 seed in
supabase/migrations/20260628102631_claude_accounts_depletion.sql.
2. Copy the dispatch scripts from bms-4
# From a host with access to both (or via the repo deploy path /opt/p24-infra):
scp /opt/p24-infra/scripts/spawn-worker.sh root@51.68.155.224:/opt/p24-infra/scripts/ # PLAYBOOK: provision-bms-3-dispatch-node.md
scp /opt/p24-infra/scripts/queue-dispatcher-loop.py root@51.68.155.224:/opt/p24-infra/scripts/ # PLAYBOOK: provision-bms-3-dispatch-node.md
scp /opt/p24-infra/scripts/queue-dispatcher.sh root@51.68.155.224:/opt/p24-infra/scripts/ # PLAYBOOK: provision-bms-3-dispatch-node.mdPreferred: pull via the existing
/opt/p24-infragit deploy on bms-3 rather than scp, so the scripts stay in sync withmain.
3. Provide the dispatcher’s env + SSH key
queue-dispatcher.sh loads secrets from an EnvironmentFile and reads the host label from
/opt/p24-infra/.server-label. The dispatcher SSHes to workers with /root/.ssh/p24-dispatcher.
ssh root@51.68.155.224 # PLAYBOOK: provision-bms-3-dispatch-node.md
echo 'bms-3' > /opt/p24-infra/.server-label
# Ensure /opt/p24-infra/bms-4/.env equivalent exists for bms-3 (SUPABASE_SERVICE_ROLE_KEY,
# P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL, GH_TOKEN). Source from SOPS — never paste values:
# sops -d --input-type dotenv --output-type dotenv secrets/n8n-bms4.env.sops | grep '^KEY=' …
# Install the dispatcher SSH key /root/.ssh/p24-dispatcher (same key bms-4 uses) and ensure
# bms-3's own sshd authorizes it for root (self-SSH for local spawn).bms-3 only needs to act as a worker target; it does not need to be a dispatcher leader. The vps-i1/bms-4 leaders already dispatch to it once enabled. Adding the
*/2cron on bms-3 (step 4) is only required if you want bms-3 to also compete for the dispatcher lease (HA). For the minimum bring-up, skip the cron and let the existing leaders spawn onto bms-3.
4. (Optional, HA) Add the dispatcher cron/timer on bms-3
Only if bms-3 should also be a dispatcher leader (it will claim the lease via
claim_dispatcher_lease() and skip when another leader holds it):
*/2 * * * * /opt/p24-infra/scripts/queue-dispatcher.shOr replicate the bms-4 systemd timer (queue-dispatcher.timer + .service).
5. Smoke test BEFORE enabling
With the row still enabled = false, dry-run a spawn as a leader would:
ssh root@51.68.155.224 "su -s /bin/bash claude-runner -c 'cd /opt/p24-infra && ls scripts/spawn-worker.sh && echo OK'" # PLAYBOOK: provision-bms-3-dispatch-node.mdConfirm: claude-runner exists, scripts present + executable, Claude Code authenticated, dispatcher
SSH key works (ssh -i /root/.ssh/p24-dispatcher root@51.68.155.224 true from a leader returns 0).
Activation (flip the switch)
Only after step 5 passes. Enable the row so the leaders start dispatching to bms-3:
UPDATE public.dev_r_server_capacity SET enabled = true WHERE server_label = 'bms-3';
UPDATE public.dev_r_services SET status = 'active' WHERE service_name = 'queue-dispatcher@bms-3';Within ~2 min the next dispatch cycle will place a light worker on bms-3. Watch the dispatcher log:
ssh root@54.36.123.110 "tail -n 50 /var/log/p24-infra-dispatcher.log" # PLAYBOOK: provision-bms-3-dispatch-node.mdSoak test (2 weeks)
Goal: confirm the added load never starves MongoDB, AND that the RAM Guard (#3397) actually protects it under a real or simulated failover. Monitor for 14 days:
- MongoDB PRIMARY/SECONDARY CPU (bms-2 PRIMARY, bms-3 SECONDARY) — Grafana node_exporter panels. Alert if bms-3 sustained CPU > 80% or MongoDB replication lag rises.
- bms-3 RAM — workers must stay within the ~17.6 GB budget; watch for OOM kills (
dmesg -T | grep -i oom). - Pinbox24 staging responsiveness on bms-3.
- Worker success rate on bms-3 (
dev_r_worker_queuerows withserver_node='bms-3'). - Guard flip-to-disable within one cycle. Simulate (or wait for a real) rs0 election that
promotes bms-3 to PRIMARY. Confirm:
dev_r_server_capacity.enabledflips tofalseandmongo_guard_disabledflips totruefor bms-3 within one dispatcher cycle (~2 min) of the election.- No new
dev_r_worker_queuerows getserver_node='bms-3'after that point. - A Discord warning is posted (
bms3_mongo_guard_cycle→_bms3_guard_disable). - If the flip does not happen,
Bms3DispatchEnabledWhilePrimary(Prometheus) should fire within ~2 minutes as the safety net — confirm it does, as part of the drill.
- Guard flip-to-re-enable once SECONDARY returns. After the simulated election reverses (bms-3
back to SECONDARY), confirm
enabledauto-flips back totrue(only becausemongo_guard_disabledwastrue) and dispatch resumes within one cycle, without any human step. - Heavy-job policy. Queue a mix of light + heavy jobs; confirm small (light) jobs dispatch first and bin-pack against the live GB budget, at most 1 heavy job runs concurrently, and a queued heavy job never causes a running light job to be killed.
If clean for 2 weeks, ramp up cautiously (one step at a time, re-soak each) by lowering
reserved_ram_gb (widening the GB budget) — never by raising max_workers_prime/night, which
are deliberately uncapped already (#3397) and are not the lever that controls load. Confirm real
headroom exists (Mongo CPU/RAM, Pinbox24 staging responsiveness) before each step.
Rollback
If CPU/RAM contention or MongoDB impact is detected at any point:
-- Immediate: stop new dispatch to bms-3 (running workers finish naturally)
UPDATE public.dev_r_server_capacity SET enabled = false, mongo_guard_disabled = false WHERE server_label = 'bms-3';Explicitly set
mongo_guard_disabled = falsealongsideenabled = falsefor a manual/human rollback. Otherwise, if the row happened to already bemongo_guard_disabled = truefrom a prior guard-driven disable, the Mongo-role guard could auto-re-enable it the next time bms-3 reports SECONDARY — overriding this deliberate rollback. Setting the marker tofalsemakes this disable unambiguously human-initiated.
Then (optional, full removal):
DELETE FROM public.dev_r_server_capacity WHERE server_label = 'bms-3';
UPDATE public.dev_r_services SET status = 'retired' WHERE service_name = 'queue-dispatcher@bms-3';On the host: remove the */2 dispatcher cron/timer if added in step 4. No data is lost — bms-3
keeps running MongoDB + staging exactly as before.
Checklist
- Migration
20260630011055_bms3_dispatch_node_capacity.sqlapplied (row present,enabled=false) - Migration
20260708130000_bms3_ram_guard.sqlapplied (RAM Guard model,mongo_guard_disabledcolumn, ~17.6 GB budget, small=1 GB/heavy=8 GB weight_ram_gb, uncapped max_workers_prime/night) — #3397 - SSH to bms-3 confirmed live (#2382, resolved and closed)
- MongoDB replication lag confirmed
0, bms-3 confirmedSECONDARY(#2384, resolved and closed) -
spawn-worker.shhonours per-serverweight_ram_gbviaCLAUDE_WORKER_MEM_GBinstead of a hardcoded global table — merged (#3427); the deployed copy on bms-3 (step 2) must postdate this - claude-runner user +
CLAUDE_WORKER_ID=bms3-cw-1set; Claude Code authenticated -
spawn-worker.sh+queue-dispatcher-loop.pydeployed to/opt/p24-infra/scripts(postdating #3427) -
/opt/p24-infra/.server-label=bms-3; dispatcher env + SSH key installed - Smoke test passes (scripts present, SSH key works) while still disabled
-
enabled = trueflipped (human-supervised — NOT part of #3397); first worker dispatched + completed on bms-3 - 2-week soak: MongoDB CPU/lag, bms-3 RAM, staging, worker success rate all nominal
- Soak also confirms: guard disables within 1 cycle of a PRIMARY reading, re-enables within 1
cycle of SECONDARY returning,
Bms3DispatchEnabledWhilePrimaryalert fires if the guard ever fails to flip, and heavy jobs never preempt running small jobs - Rollback tested (set
enabled = false, mongo_guard_disabled = false, confirm no new spawns)
Written 2026-06-30 — Implements #2147. Related: adding-new-worker.md, worker-queue-operations.md, queue-dispatcher-operations.md.
Audit Log — Log to infra_operations
After this operation completes, log it to the infra_operations audit table.
Python (Linux server — bms-4, vps-i1, vps-h1, or similar):
import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
log_op(
actor="claude", # "radieu" for manual human ops, "claude" for agent
op_type="deploy",
resource="bms-3-dispatch-node",
result="success", # "success" | "failed" | "skipped"
detail="bms-3 dispatch node provisioned — claude-runner installed and registered",
env="bms-3",
gh_issue=2730,
)PowerShell (Windows dev machine):
$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'deploy', 'bms-3-dispatch-node', 'success', 'bms-3 dispatch node provisioned — claude-runner installed and registered', 'bms-3')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''