ADR 005: WSL2 + “Superset” Execution Layer and Dispatcher Redesign — Reconciliation

Status: Proposed — design for #6085. No implementation lands in this PR; this ADR evaluates an externally-authored proposal against the systems already in place and records a recommendation. The genuinely person-only sub-decisions are flagged inline as HUMAN DECISION (now narrowed to a single residual item — see §4a). Revised 2026-08-11 (round 2) to fold in three more rounds of owner follow-up on #6085 that landed after the first revision (commit 953ab712): a Docker/ ephemeral-worker proposal analysis with an explicit decline of its “proxy for evading Anthropic’s abuse detection” framing (§9), three locked-in decisions on account redistribution / windows-dev architecture / concurrent-with-interactive-use operation (§4, §5, §5b), and a second round resolving the bms4 proxy scope (reframed, accepted with required safeguards — §9), ADR-002 Q3 (§4a), the windows-dev dispatch direction (§3), and the in-container secrets policy (§8). All of these are now recorded as live design requirements, not deferrals, per the same pattern the first revision used for the multi-subscription load-balancing requirement. Reconciled with wsl1 (2026-08-11, owner decision). While this ADR was being revised, a separate concurrent effort (#6091, merged; pr #6097, design merged) independently provisioned and designed docs/environments/wsl1.md — real infrastructure for this same physical host, under a different name, using an outbound-poll, native-process architecture (no Docker, bypasses SSH dispatch entirely) that is incompatible with this ADR’s §3/§5b Docker + CF-Tunnel-inbound design. The /review-plan pass on #6085 caught this and returned REQUEST_CHANGES pending a human decision. The owner’s resolution: this ADR’s Docker/CF-Tunnel-inbound design is the design of record; wsl1’s self-polling design (#6094/#6097) is superseded and should not be implemented as scoped — see the superseded-notice added to docs/environments/wsl1.md and docs/designs/6094-self-polling-queue-worker.md. Separately, the owner also directed that the wsl1-worker SSH key added to vps-h1 during that provisioning be removed — vps-h1 is marked PROTECTED (“no new services, no agents”) in CLAUDE.md’s server table, and that addition was not an authorized exception. This was done (key removed, backup kept, verified) independent of which architecture ultimately gets built. wsl1.md’s real hardware spec (6 cores, 31GB RAM) should be reused as the basis for §5b’s sizing once the Docker build starts, rather than re-measuring from scratch.

Date: 2026-08-11 Issue: #6085 Relates: ADR 001 (role matrix), ADR 002 (Claude account topology — the ToS Q3 this proposal reopens and, per §4a, resolves by explicit user risk-acceptance), ADR 003 (secret broker), ADR 004 (concurrent-session mutual exclusion); #3294 (Headscale/Tailscale removal), #5544 (research-subagent no-SOPS-decrypt clause), 4706 (role capability scoping), #1680 (per-issue agent_tasks mutex), 4210 (per-account token-refresh cron), docs/playbooks/dispatcher-oom-loop.md, docs/playbooks/worker-capacity-management.md, docs/playbooks/queue-dispatcher-ram-gate-exhaustion.md (real OOM/RAM-gate incident history cited in §5b), monitoring/supabase/migrations/038_ram_budget_dispatch.sql (current weight_ram_gb budgets)

Context

A proposal document from another work session (summarised in #6085) outlines a target architecture: a Windows 10 / 64 GB workstation running WSL2 (Ubuntu) as a “Local Powerhouse” running 4–8 parallel Claude Code agents; a “Superset” CLI (superset workspace) that assigns every task a git worktree and reports completion to Supabase; a new Supabase jobs table with a persistent Python poller on bms-4 that picks a node by free_ram/current_load, picks the least-loaded Anthropic subscription SESSION_KEY, and dispatches over SSH/Tailscale; and a Tailscale mesh VPN for machine-to-machine dispatch in place of Cloudflare for internal traffic. A second, follow-up proposal document (“Dockerized Agent Swarm & Proxy Architecture”) adds Docker-based ephemeral workers (docker run --rm per job) and a “Centralized Subscription Proxy” — both addressed in §5b and §9.

The proposals are written as if these systems do not already exist. They do. This ADR’s job is to reconcile the proposals against the running system, not to adopt them wholesale.

What already exists (and why we should build on it, not beside it)

The p24-infra job-execution layer is a mature two-tier producer/consumer system, not a greenfield:

  • Producer — the meta-dispatcher Cloudflare Worker (infra-src/meta-dispatcher/, cron 0 */4 * * * + POST /queue-issue) scans GitHub issues, infers weight/role/server_preference, builds PR-dependency waves, and inserts rows into the Supabase dev_r_worker_queue table (this is the table CLAUDE.md calls p24_queue). It never SSHes anywhere — it only writes queue rows (docs/meta-dispatcher-operations.md).
  • Consumerscripts/queue-dispatcher.sh (systemd timer, every 2 min on bms-4) elects a leader via a sticky 5-min lease (dev_r_dispatcher_lease), resets stale workers, sets the night-window flag (20:00–02:59 UTC), then runs scripts/queue-dispatcher-loop.py, which does RAM-aware bin-packing: it reads per-host capacity from dev_r_server_capacity (enabled, max_workers_prime/night, max_weight_prime/night, reserved_ram_gb, weight_ram_gb, emergency_max_workers, claude_accounts), computes bookable RAM via the available_ram_gb() RPC (80 % cap), and fills slots by priority.
  • Spawnscripts/spawn-worker.sh (invoked over SSH by the loop) does a per-issue git clone into /tmp/worker-${ISSUE}-${REPO_SLUG} (already worktree-equivalent isolation), a subscription-usage check with automatic rotation across the host’s claude_accounts (exit 5 → dispatcher retries the next account), a pre-spawn RAM guard, and launches the worker under systemd-run --scope --MemoryMax=<weight>G (cgroup memory hard-limit) with a per-role capability scope (ADR 001, 4706).
  • Reporting — every worker registers a session in agent_sessions / dev_r_agent_sessions, heart-beats, and writes terminal status back to dev_r_worker_queue (this ADR’s own worker did exactly that).

So of the five pillars the proposal claims to introduce, four already exist in production: per-task worktree isolation, node selection by free RAM/load, dispatch over SSH, and agents reporting completion to Supabase. The genuinely new elements are only: (1) a WSL2/Windows workstation as a worker host, (2) a Tailscale mesh, (3) a second jobs table, (4) a “Superset” CLI wrapper, and (5) SESSION_KEY/CLAUDE_CONFIG_DIR account-switching. Each is evaluated below.

The decision histories the proposal silently reverses

Two prior, deliberate decisions are contradicted by the proposal and must be addressed head-on, not re-litigated by an agent:

  1. Headscale/Tailscale was removed on 2026-07-08 (#3294). Per docs/playbooks/server-ssh-connectivity.md and CHANGELOG.md, the mesh VPN was decommissioned because a restart: unless-stopped Headscale container entered a restart loop during a recovery cycle and had to be torn out; all servers reverted to public-IP + SSH-key-only (port 22 open, key auth, one canonical user per host). Tailscale clients were purged from bms-2/3/4 and vps-h1. The removal was operational-fragility-driven, and the replacement (direct SSH over public IP) is what the queue consumer already uses. Re-introducing a mesh VPN must clear the same bar that removed the last one.
  2. ADR 002 rejected buying new Claude seats (Option 1) and left the ToS multi-host question (Q3) open, “accepted as a monitored condition.” The account posture is: one radieu Claude Max account; never copy .credentials.json between hosts/processes (the documented cause of the refresh-token race — docs/playbooks/ claude-oauth-reauth.md Option C, banned); each host/account runs its own independent claude auth login; per-account hourly claude-token-refresh cron (#5189/#4210). The openclaw-gateway read-only bind-mount is the repo’s closest analog of same-host concurrent credential access silently breaking the refresh path — and is now the explicit precedent cited against the bms4 proxy’s OAuth-custody design (§9). Note the nuance developed in §4/§4a: ADR-002’s ruling foreclosed purchasing new seats, not load-balancing across accounts that already exist — the current two-account rotation on bms-4 is fully compliant. §4a records the owner’s explicit, informed acceptance of the remaining ToS risk rather than an agent decision.

Options considered

(A) Adopt the WSL2 + Superset proposal as written — new jobs table, standalone poller, Tailscale mesh, SESSION_KEY switching, Superset CLI, Windows/WSL2 as a first-class worker node.

(B) Reject wholesale, change nothing — keep the current single-host (bms-4) dispatcher exactly as is.

(C) Reject the replacement, harvest the good ideas into the existing system incrementally (recommended) — keep meta-dispatcher + dev_r_worker_queue + queue-dispatcher-loop.py + spawn-worker.sh as the one dispatch spine; treat “add a local powerhouse worker” as registering another row in dev_r_server_capacity, which is already the documented extension point (docs/playbooks/adding-new-worker.md, provision-bms-3-dispatch-node.md); do not add a second queue table or a mesh VPN. Docker-based execution is now accepted scope for the windows-dev node specifically (§5b) and a centralized bms4 proxy is now accepted scope with required safeguards (§9) — both per explicit owner decision, not agent inference — but neither replaces the queue spine itself.

Decision

Adopt Option (C). Do not build a parallel dispatch stack. Extend the existing one — and, per the owner’s follow-up rounds, extend it specifically with Docker-based execution on windows-dev and a centralized bms4 proxy, both scoped narrowly and with explicit accepted-risk documentation where they touch known failure classes. The detailed rationale per #6085’s eight original deliverables plus the three follow-up rounds follows; the net recommendation is incremental extension, with one residual item escalated to a human decision (§4a).

1. WSL2 + Superset vs. extending the existing system

Recommendation: extend the existing system. The proposal duplicates a production-grade dispatcher that already implements its headline features (RAM/load-aware node selection, per-task isolation, SSH dispatch, Supabase reporting). A second stack doubles the surface area for the exact failure classes the current one has already hardened against across dozens of issues (mutex collisions #1680, OOM escalation, stale-worker reset, subscription rotation, HA lease failover, job_type/weight CHECK drift). The incremental cost of a new worker host in the current system is one dev_r_server_capacity row plus SSH/CF-Tunnel reachability — near-zero — whereas a parallel stack is a permanent second thing to operate, monitor, and keep in sync.

2. New jobs table vs. existing dev_r_worker_queue

Recommendation: do not create a new jobs table. dev_r_worker_queue already is the jobs table, with a superset of the proposed columns: status, weight, priority, server_preference, server_node, session_id, worker_id, retry_count, spawn_failures, failure_reason, planned_date, next_attempt_at, metadata JSONB, plus RLS (service_role full, grafana_readonly SELECT) and a unique active-row constraint (#5467). Node selection by free RAM/load is already implemented via dev_r_server_capacity + available_ram_gb(). A second table would fork the schema, split the Grafana dashboards, and re-introduce the CHECK-constraint drift problem (#4293/#4605) on a new surface. If the proposal needs a field the queue lacks, add a column or a metadata key — a migration in supabase/migrations/ against the per-repo ledger runner (never apply_migration — banned 5645), not a new relation. This includes any new fields the Docker contract in §5b needs (e.g. a container-id/exit-code column) — extend the existing table.

3. Networking — Tailscale vs. public-IP + SSH-key + CF Tunnel

Recommendation: stay on public-IP + SSH-key; do not re-introduce a mesh VPN. The 2026-07-08 removal (#3294) was not incidental — the mesh became a liability during recovery and direct SSH is strictly simpler for the machine-to-machine dispatch the queue does. Two concrete points:

  • Servers with public IPs (all VPS/BMS): the consumer already dispatches over ssh root@<public IP> with the developer id_ed25519; no mesh is needed and none should be added.
  • A node with no public IP (a home/office workstation — the dev-laptop / windows-dev case): the repo already has a non-mesh answer — a CF Tunnel (lap1-ssh.tunnel.zintegrowana.online) and/or an autossh reverse tunnel through vps-i1 (CLAUDE.md server table). That is a per-node egress path, not a fleet-wide overlay. Re-adding Tailscale to reach one workstation reverses #3294 to solve a problem #3294’s replacement already solves.

windows-dev dispatch direction — resolved (owner follow-up, round 2). windows-dev is dispatched to over CF Tunnel inbound, the identical pattern already used for lap1’s SSH tunnel, extended so the tunnel carries docker exec/docker run invocations (§5b) instead of (or alongside) a bare SSH shell. This is additive to the existing CF Tunnel usage, not a new networking primitive — no Tailscale, no new inbound port, no change to the public-IP+SSH-key posture for the servers that have public IPs.

If a future need genuinely requires a mesh (e.g. many no-public-IP nodes), that is its own ADR that must explicitly show why the #3294 fragility no longer applies — it must not be smuggled in as a side-effect of this proposal.

4. Multi-subscription load-balancing vs. ADR-002 — live requirement, redistribute existing accounts only

The owner’s 2026-08-11 follow-up makes this a current requirement, not a deferral: the goal is 2 → eventually 3–4 concurrent Anthropic subscriptions, spread across up to 3 hosts, with load-balancing logic to pick which subscription handles a given task. The owner’s “locked in” decisions round then resolved the residual ambiguity explicitly: redistribute the accounts already available — bms-4’s claude-runner and claude-runner-2, plus the radieu account used elsewhere — across the 3 hosts, rather than purchasing any new Max/Pro seats. The load-balancing logic picks among these existing accounts per job/per host; it must not assume a growing account pool. This closes off the “are these new purchases?” branch of the original human-decision item — they are not; no seat purchase is authorized by this design.

The mechanism is already implemented — and it is exactly the load-balancing being asked for. Per-task subscription selection across multiple distinct accounts is a production behaviour today, not something to build:

  • dev_r_server_capacity.claude_accounts holds a per-host list of distinct accounts; bms-4 already runs two (claude-runner, claude-runner-2).
  • queue-dispatcher-loop.py ranks the non-depleted accounts by remaining subscription headroom (read from each account’s sub-status.json) and assigns the least-loaded one per spawn (_account_order, the headroom-rank block ~L1554). spawn-worker.sh returns exit 5 when the chosen account is blocked (usage ≥99 %, or ≥95 % with reset >1 d, or a post-spawn weekly-limit hit), and the dispatcher marks it depleted and retries the next account.
  • Each account has its own independent claude auth login and its own credential dir; nothing is shared between concurrent processes — except where the bms4 proxy’s OAuth-custody design (§9) deliberately introduces shared session state as an accepted risk. That is a distinct, explicitly scoped exception, not a reversal of this default.

That model is precisely “pick which subscription handles a given task, balanced by load” — so the requirement is met by extending the data already flowing (add windows-dev and the redistributed accounts to claude_accounts, add windows-dev as a dev_r_server_capacity row), not by the proposal’s new SESSION_KEY/CLAUDE_CONFIG_DIR layer as a standalone mechanism. A CLAUDE_CONFIG_DIR scheme is only acceptable in the form the system already uses it: one config dir per distinct account, one account bound per worker at spawn — never several concurrent agents pointed at one shared credential dir outside the explicitly-accepted bms4-proxy exception in §9. The proposal’s framing (“least-loaded SESSION_KEY”) is a re-description of the existing headroom ranking, so it needs no new mechanism beyond adding windows-dev’s accounts to the ranked list.

What ADR-002 actually foreclosed is narrower than this requirement. ADR-002’s “Rejected (Option 1)” ruling is specifically “no new Claude Max subscriptions will be purchased — a seat-cost decision (Q2) — which this design fully respects by redistributing existing accounts only. It did not forbid load-balancing across accounts that already exist: the current two-account rotation on bms-4 is fully within ADR-002. ADR-002’s Phase 0 experiment (2026-08-01) confirmed mechanism (A) — independent grants on the same account do not invalidate each other; “the race was purely the copy-vector, not an account-wide server-side limitation.” So multiple distinct accounts, each with its own grant, one bound per task is the safe mechanism ADR-002 validated.

Net: the load-balancing design is settled (extend claude_accounts + dev_r_server_capacity with windows-dev, reuse the headroom ranker — no new stack, no new seats). §4a below records that the remaining ToS-comfort question (ADR-002 Q3) is resolved, not by an agent, but by explicit owner risk-acceptance.

4a. ADR-002 Q3 (ToS comfort) — resolved by explicit user risk acceptance, not reopened

The residual human-decision item from the first revision of this ADR asked two things: (a) are the 3–4 subscriptions new purchases, and (b) does ADR-002 Q3’s ToS concern apply at the target account/host count. Both are now resolved by the owner, in the “Follow-up decisions, round 2” comment on #6085 (2026-08-11):

  • (a) is resolved by §4: no purchases — redistribute existing accounts only.
  • (b) is resolved by explicit, informed risk acceptance. The owner’s decision: “assume concurrent same-account usage across hosts is an acceptable risk — do not block on this, do not require reopening ADR-002 formally.” This is a deliberate risk acceptance, made with full visibility into the tension it creates with §9’s bms4 proxy design (the owner was shown the openclaw-gateway comparison explicitly before accepting) — not an agent’s own interpretation of ADR-002, and not a formal reopening/supersession of that ADR’s Status or Decision sections. ADR-002 itself is not edited by this ADR — its Status stays Proposed/Rejected (Option 1) as written; this ADR only records, as its own design input, that the owner has separately made the Q3 call for the windows-dev/bms4-proxy scope described here.

This closes the HUMAN DECISION escalation from the first revision. No follow-up issue is needed to reopen ADR-002 Q3 — the owner already answered it here. The only follow-up items remaining are implementation-scoped (§ Follow-up issues), not decision-scoped.

5. A Windows/WSL2 workstation as a worker node — accepted scope (was HUMAN DECISION), Docker-based, concurrent with interactive use

Live capacity correction (2026-08-11 follow-up). The proposal’s “Local Powerhouse running 4–8 parallel agents” premise does not survive contact with the actual hardware. The owner live-checked lap1 (the existing dev-laptop node) on 2026-08-11: it is alive and healthy (41-day uptime, idle load ~0.3, cloudflared + p24-autossh-tunnel both active) — correcting the earlier “offline in the audit” note — but its ceiling is CPU, not RAM: an i5-7200U with only 4 logical cores (2C/4T) supports 1–2 concurrent workers max, not 4–8. (A stale SUPABASE_SERVICE_ROLE_KEY in laptop.env had been 401-ing every heartbeat — same failure class as #3486 — now fixed and verified HTTP 204.) This ceiling is hardware, independent of the OS/WSL2 choice: a laptop-class node is a 1–2-worker node whether it runs bare Ubuntu or WSL2.

The owner then confirmed (round 1 “locked in” decisions) that windows-dev — this very workstation — is the 3rd host, not a new VPS and not a second laptop, and that its adoption as a worker node is accepted scope, not an open question:

  • Architecture: Docker/WSL2, not lap1’s native-process model. Despite the rearchitecture cost the Docker analysis identified (new RAM-gate/exit-code contract, no proven WSL2 Docker reliability anywhere in this repo, no maintained worker image today), the owner explicitly wants the ephemeral container approach on windows-dev. Design detail in §5b.
  • No idle-only guardrail. Unlike lap1’s “P1–P2 only, outbound-poll, effectively idle-window” framing, windows-dev workers are expected to run while the user is actively using the machine, sharing CPU/RAM with the interactive session rather than waiting for idle time. This is a real UX trade-off, not something to assume away — §5b specifies the required cap strategy.
  • Sizing must use this repo’s own OOM history, not the proposal’s assumptions — see §5b.

This is a materially different posture from lap1 and from the first revision’s “opt-in, light-only, non-load-bearing overflow” recommendation for a generic WSL2 node: windows-dev is accepted as a real, if variable-uptime, worker host, gated on the RAM/CPU cap strategy in §5b actually being implemented before it carries real dispatch weight. Until that cap strategy exists, windows-dev should be registered enabled=false or max_weight_prime='light'-only in dev_r_server_capacity — the acceptance of the architecture does not by itself authorize unthrottled concurrent dispatch on a machine someone is actively working on.

  • Register windows-dev as a dev_r_server_capacity row with its own claude_accounts entries (§4) — no accounts are new, only the host row and its account assignment are.
  • Because dispatch is pull-based on liveness (HEARTBEAT_FRESH_MINUTES, stale-worker reset) and the sticky lease keeps the dispatcher on bms-4, windows-dev dropping offline (machine off, network down) degrades gracefully — its unclaimed rows simply stay queued for bms-4. That remains true under the Docker model.
  • All code under the Linux FS inside WSL2 (~/projects/), never the C:\ mount — this part of the original proposal is sound (Windows-mount I/O + permissions break git and node_modules) and is a provisioning rule regardless of the Docker decision.

It must not hold the dispatcher lease and must not be a dependency for any scheduled job — those constraints are unaffected by the Docker/concurrent-use decisions above.

5a. Capacity & topology target (2026-08-11 follow-up, round 2 clarification) — bms-4 + lap1 + windows-dev

The owner’s confirmed target and topology, reconciled against verified capacity:

HostStatus (verified 2026-08-11)Realistic worker capacityRole in target
bms-4Only production worker host; holds dispatcher leaseExisting (RAM-bin-packed, multi-weight)Base — dispatcher + primary workers, unchanged
lap1 (dev laptop)Alive/healthy; 4 logical cores (i5-7200U); 7.6 GB RAM, ~4.2 GB free1–2 light workers (CPU-bound, not RAM)Optional light-only overflow (§5), native-process model, unchanged
vps-i1Confirmed monitoring-onlynot a worker host0 (do not dispatch)Monitoring/Grafana + the reverse-tunnel relay for lap1 only
windows-dev (this workstation)Confirmed as the 3rd host (owner clarification, superseding the first revision’s “small dedicated VPS” recommendation)Docker-bounded — sized in §5b against real OOM history, not the proposal’s 3–5 GB/worker assumption; concurrent with interactive use3rd host — Docker/WSL2 ephemeral workers, CF Tunnel inbound dispatch (§3)

vps-i1 is confirmed monitoring-only. The stale doc reference in docs/worker-role-scoping-operations.md suggesting vps-i1 might still be a worker is wrong and should be corrected in the follow-up provisioning issue; the canonical topology (bms-4 as the sole existing dedicated-VPS worker host, plus lap1 and windows-dev as variable-uptime nodes) stands. vps-i1 must not be added as a dev_r_server_capacity worker row.

The first revision’s recommendation to make the 3rd host “a small dedicated VPS” is superseded. That recommendation was made before the owner’s clarification that the 3rd host is windows-dev itself; it is retained here in this note only to make the correction explicit, not as remaining advice. The trade-offs that recommendation was based on (Linux-native FS, direct public-IP SSH, an existing /provision-vps automation path) are real and are exactly why §5b treats the Docker/WSL2 path as new, unproven infrastructure that must be built carefully rather than assumed to behave like a VPS host.

Do not scale total fleet concurrency until the bms-3 RAM-weight dispatch bug (#4717/#4728, dispatch still disabled since 2026-07-31) is resolved — adding more concurrent slots on top of an unfixed capacity-accounting bug compounds a known-live problem rather than starting from a clean slate. This is a sequencing constraint on rollout, not on the design here, and applies with extra force to windows-dev given it has no operating history as a worker host at all.

5b. Docker/ephemeral-worker architecture for windows-dev — accepted scope, sized against real OOM history

Status: accepted scope per owner decision (round 1 “locked in” decisions, item 2). This section replaces the earlier “narrow scope, isolation/cleanup only” framing from the Docker-proposal analysis comment with the owner’s confirmed direction: windows-dev runs Docker-based ephemeral workers (docker run --rm per job), not lap1’s native-process model. What follows are the design requirements that must hold given that decision, not an argument against making it.

This is new infrastructure, not a config change — call it out explicitly.

  • Docker is not in the worker spawn path today. The real mechanism (scripts/spawn-worker.sh, invoked via SSH by queue-dispatcher-loop.py) uses systemd-run --scope --MemoryMax=<weight>G for cgroup-based memory isolation on bare Linux hosts. No Dockerfile/image for a “claude-code worker” exists anywhere in this repo today; Docker elsewhere in the repo is only the monitoring stack/exporters, unrelated to job execution.
  • The runtime is Docker Engine (docker-ce) installed natively inside the WSL2 distro (standard apt install), not Docker Desktop (owner decision on #6110, 2026-08-11). Docker Desktop’s paid-tier size requirement applies only to the GUI app, not to the free/open-source docker-ce daemon; windows-dev is headless (SSH + CF Tunnel, no GUI), so the native Engine install is the correct fit and moots the licensing question. Install steps live in provision-windows-dev-docker-worker.md §Prerequisites.
  • Adopting it for windows-dev requires, as new deliverables (tracked in Follow-up issues below, not built by this ADR): a maintained worker image; a verified-reliable Docker runtime under WSL2 for this exact purpose (no evidence anywhere in this repo that this has been run before); and a new RAM-gate/exit-code contract between the container and the dispatcher that maps onto the existing five-code contract (spawn-worker.sh exit 2=OOM, 3=RAM contention, 4=needs heavier weight, 5=subscription blocked) — the dispatcher’s retry/requeue/escalate logic depends on these codes, and a container exiting with Docker’s own OOM-kill signal (137) must be translated into the equivalent queue-row outcome, not left as an unrecognised failure.

RAM sizing — use this repo’s own incident history, not the proposal’s assumptions. The Docker-proposal comment’s own analysis (2026-08-11) already made the mismatch explicit and is the basis for this requirement, not a new finding:

  • Current budgeted weight_ram_gb values (queue-dispatcher-loop.py’s WEIGHT_RAM_GB, source of truth for migration 038_ram_budget_dispatch.sql): light=3GB, heavy=8GB, playwright=6GB, orchestrator=12GB.
  • heavy was already raised from 6GB → 8GB on 2026-06-25 (#1737) — this is a live, already-applied fix, not a pending recommendation. docs/playbooks/dispatcher-oom-loop.md’s “Prevention” section still lists the 6→8GB bump as an open suggestion; that line is stale documentation debt and should be corrected as part of this ADR’s doc-correction follow-up (§7), not read as describing current behaviour. The underlying lesson still holds even after the bump: docs/playbooks/ worker-capacity-management.md separately documents vps-i1 (an 8 GB host) being unable to sustain even light (3 GB) workers alongside its existing services (incident #2979, 36 jobs failed) — i.e. the weight budget being adequate does not guarantee the host has that RAM free, which is exactly the sizing risk windows-dev inherits.
  • docs/playbooks/queue-dispatcher-ram-gate-exhaustion.md documents a separate, systemic failure mode where stale, uncleaned worker state silently drains the RAM budget to zero — exactly the class of problem ephemeral containers with reliable --rm cleanup could genuinely help with, which is the one part of the original Docker proposal’s benefit case that holds up on inspection.
  • Conclusion for windows-dev sizing: do not use the follow-up proposal’s 3–5 GB/worker figure or its 12–16-concurrent-workers density claim — both are optimistic relative to this repo’s measured experience of workers needing more headroom than budgeted, not less, and neither has a validated hardware spec for windows-dev to size against (no docs/environments/windows-dev.md or equivalent exists in this repo today — creating one, with real free-RAM/core-count numbers for this workstation, is a prerequisite before any concurrency count is set, and is listed as a follow-up). Size the container weight_ram_gb mapping (and any windows-dev-specific override) off the current heavy=8GB budget and the demonstrated pattern that budgets need headroom above the proposal’s assumptions, then validate empirically before raising max_workers_prime above 1–2.

RAM/CPU cap strategy — required because windows-dev runs concurrently with interactive use. Per the owner’s decision (round 1, item 3), windows-dev workers do not get an idle-only guardrail — they run while the developer is actively using the same machine for this very Claude Code session and anything else. This is a real UX trade-off and needs an explicit strategy, not a silent assumption that background workers won’t be noticed:

  • Every container must run with an explicit Docker memory cap (-m/--memory) matching its weight_ram_gb assignment, and an explicit CPU cap (--cpus) sized conservatively against the workstation’s actual core count (not the original proposal’s core-count assumptions — measure this workstation directly, same as the lap1 correction did for that host).
  • The cap values must leave headroom for the interactive session (this Claude Code session, IDE, browser, etc.) — the design requirement is that a running background worker must not visibly degrade the developer’s foreground work. This needs empirical tuning once the first container image exists; it is not something this ADR can size numerically without that data.
  • Total concurrent worker RAM+CPU commitment on windows-dev should start conservative (e.g. 1 concurrent light-equivalent container) and be raised only after observing real contention, mirroring how lap1’s ceiling was only trusted after a live hardware check rather than the original proposal’s assumed numbers.

Dispatch path: CF Tunnel inbound, docker exec/docker run at the far end (§3) — no new networking primitive beyond extending the existing CF Tunnel pattern already used for lap1.

This is explicitly separate from the declined proxy framing (§9). The Docker/ephemeral-worker decision here is about where and how a job executes on windows-dev; it says nothing about account routing or session custody, which are §9’s concern and were evaluated (and partly declined, partly accepted) independently.

6. “Superset” CLI vs. the git-worktree-per-task rule already in place

Recommendation: do not introduce Superset as a new dependency. Its core promise — “every task gets its own git worktree” — is already mandatory (CLAUDE.md §Agent Workflow — Worktree Branch Isolation) and already implemented by spawn-worker.sh’s per-issue /tmp/worker-${ISSUE}-${REPO} clone. Its second promise — “agents report completion to Supabase” — is already done via agent_sessions/dev_r_worker_queue. A new CLI would wrap capabilities the system already has, add a version to pin and distribute to every node, and create a second orchestration vocabulary competing with worker-issue.md. If a thin local convenience wrapper is ever wanted for the windows-dev Docker node, it should shell out to the existing spawn-worker.sh contract (extended per §5b), not replace it.

7. Impact on existing playbooks/docs

If Option (C) is accepted, the doc changes needed are additive, and no existing decision is reversed:

  • docs/playbooks/adding-new-worker.md — add a “windows-dev (WSL2/Docker) node” subsection covering the Docker worker image, the RAM-gate/exit-code contract extension (§5b), the -m/--cpus cap strategy, the ~/projects/ FS rule, CF Tunnel reachability (§3), the dev_r_server_capacity row, and its claude_accounts assignment (§4). This is now a concrete, accepted-scope subsection, not a speculative one.
  • docs/playbooks/server-ssh-connectivity.md — document windows-dev’s CF Tunnel reachability alongside lap1’s; no Tailscale.
  • A new docs/environments/windows-dev.md (or equivalent) — real hardware spec (cores, RAM) for this workstation, the prerequisite §5b flags before setting any concurrency count.
  • A new proxy operations doc for the bms4 proxy (§9) — egress IP, account/task routing logic, OAuth session custody, and the required monitoring/circuit-breaker behaviour.
  • ADR 002not amended by this ADR; §4a records the owner’s Q3 risk-acceptance as this design’s own input, not as an edit to ADR-002’s Status/Decision.
  • docs/playbooks/queue-dispatch-cross-project.md, docs/queue-dispatcher-operations.md — note the new windows-dev node and the bms4 proxy in the topology tables.
  • No change to docs/meta-dispatcher-operations.md (producer is untouched) and no new networking doc for a mesh.

8. Role Enforcement & the no-SOPS-decrypt clause for every new agent type — including ephemeral containers

A windows-dev Docker worker inherits the exact same role scope as a bms-4 worker — no exception, and no new secrets policy is invented for containers. This was an explicit open question in the original issue (deliverable 8) and is resolved by the owner (round 2 follow-up) as: apply the existing CLAUDE.md Role Enforcement matrix per-container based on the job’s assigned role. Any execution node added under Option (C) — bare-process or containerized — runs the same spawn-worker.sh contract (extended per §5b for the container case) and therefore:

  • Resolves a CLAUDE_ROLE from job_type and adopts the ADR 001 capability scope (dev-issuedev-coder, infra-tasksys-admin, etc.); with ROLE_CAP_ENFORCE=1 the spawn-time guards (scripts/lib/role-capabilities.py) withhold the age key / SSH auth for denied roles — this must be enabled on windows-dev, not just bms-4, and must be enforced at the container-mount level, not merely as an in-process check: a secret-manager-role container gets the age key mounted in; every other role’s container simply never has it mounted, so there is no transient exposure to withhold after the fact.
  • Concretely, per the owner’s decision: secret-manager-role containers only get the SOPS age key. sys-admin/sys-security-role containers get read-only credential access for verification purposes (not a blanket age-key mount). dev-coder and every other role (feature work, tests, PR review, etc.) get no SOPS/age-key access at all, even transiently — they do not need it and should not have it available in an ephemeral container any more than they would on a bare host.
  • Carries the mandatory #5544 “No SOPS decryption” clause in every research/investigation subagent prompt, identically to bms-4, identically inside a container.
  • If a windows-dev container ever must decrypt for secret-manager work, its host WSL2 environment is added as an age recipient the same way any other host would be — this is an existing SOPS operation (secret-manager role), not a new policy invented for containers.

An “ephemeral local container/process” is not a new trust class; it is a worker on a new host, and the honour-system-plus-spawn-guard model (ADR 001 / #4706) applies to it unchanged. No new agent type introduced by either proposal escapes the role matrix, and this ADR does not add any new secrets-handling rule beyond what CLAUDE.md’s Role Enforcement matrix already states.

9. Centralized bms4 proxy — egress IP + account/task routing + OAuth session custody (accepted, reframed, with required safeguards)

This section replaces an earlier decline, not the whole Docker/proxy follow-up. The Docker ephemeral-worker analysis (§5b) came bundled, in the same follow-up document, with a “Centralized Subscription Proxy” whose stated purpose, as originally pasted into #6085, was to mask multi-worker/multi-location usage from Anthropic’s own abuse detection (“preventing ‘Geographic Travel’ detection”). That framing was declined outright and stays out of scope — it is not a technical scaling question, it is designed to evade a subscription service’s own anti-abuse detection, and no version of this ADR implements it. This is a materially different, and more serious, category than the ADR-002 question about legitimate multi-account/multi-host use, which remains open-and-legitimate to revisit (and is in fact resolved in this ADR at §4a).

A bms4 proxy is back in scope under a different, legitimate purpose (owner follow-up, round 2), confirmed to cover all three of:

  1. Centralized egress IP — a single static IP for outbound worker traffic, for firewall/ allowlist/logging reasons. No auth implications; the least novel of the three.
  2. Centralized account/task routing — bms4 decides which Anthropic account handles which job. This is the same load-balancing logic already described in §4 (the headroom ranker in queue-dispatcher-loop.py); centralizing it on bms4 rather than duplicating the ranking logic on every node (including windows-dev) is a reasonable implementation choice, not a new design.
  3. Centralized OAuth session custody — bms4 holds authenticated sessions and injects them into ephemeral workers (including windows-dev’s Docker containers), so containers don’t each need their own claude auth login flow.

(3) is flagged explicitly as a known, accepted risk — not an oversight. This is architecturally the same pattern as the openclaw-gateway incident already documented in ADR 002 §Phase 0 execution log: a single held credential/session state, bind-mounted read-only into a consumer that cannot write back the rotated refresh token, silently breaking that consumer’s own refresh path independent of any other host’s race. The established safe pattern elsewhere in this repo (§4, ADR-002 Option 1/Option 3) is each host/process doing its own independent claude auth login, never a shared/injected session. The owner was shown this exact comparison and chose to proceed with centralized session custody anyway, for the operational benefit of not needing a per-container interactive login flow — this is recorded here as the owner’s deliberate, informed decision (consistent with §4a’s ADR-002 Q3 resolution), not an agent’s technical recommendation.

Design requirement, given the accepted risk: the proxy must ship with

  • Monitoring/alerting on refresh failures specific to the shared-session path — a new metric or reuse of the existing per-account p4_claude_rt_expires_days{machine,account} series (ADR-002 §Phase 2 item 12), scoped to detect the openclaw-gateway failure signature (refresh succeeds server-side but the rotated token is never persisted back to the held session).
  • A circuit-breaker: when the shared-session refresh path is detected broken, the system must either (a) fail workers over to independent per-container claude auth login (mirroring the existing per-host default), or (b) halt dispatch through the proxy entirely, rather than continuing to route jobs through a session that has silently gone stale. Which of (a)/(b) is the default is an implementation decision for the follow-up build issue, not this ADR — but the ADR requires that one of them exists before the proxy is load-bearing; “it worked once in testing” is explicitly insufficient (owner’s own phrasing).
  • The proxy’s account/task-routing function (purpose 2) reuses the existing headroom-ranking logic (§4) rather than reimplementing it — this is a refactor/centralization of existing logic, not new logic.

This proxy is new infrastructure (not built by this ADR) and is listed in Follow-up issues below, scoped explicitly to purposes 1–3 above and explicitly excluding the declined geo-evasion framing.

Consequences

Positive

  • Zero new dispatch stack to operate; the hardened queue remains the single source of truth.
  • The genuinely useful ideas — more local execution capacity (windows-dev), centralized egress/ routing/session custody (bms4 proxy), and RAM cleanup via ephemeral containers — are captured via the existing extension points and an explicitly-scoped new proxy component, not a wholesale replacement stack.
  • No decision (VPN removal #3294, ADR-002 account posture) is silently reversed; where a real tension exists (ADR-002 Q3 vs. the bms4 proxy’s OAuth custody), it is surfaced explicitly and resolved by the owner’s informed acceptance (§4a, §9), not smuggled in or resolved by an agent.
  • New nodes and containers inherit role scoping and the #5544 clause for free, with the per-container secrets policy now made explicit (§8) rather than left as an open question.
  • The auth-evasion “Centralized Subscription Proxy” framing from the original follow-up doc is explicitly declined and excluded, closing off a real risk of building the wrong thing under a similar-sounding name.

Negative / risks

  • The load-balancing design is settled as an extension of the existing headroom ranker, and no seat purchase is authorized (§4) — but the throughput goal still can’t be rolled out until (a) the bms-3 RAM-weight bug (#4717/#4728) is fixed, and (b) windows-dev’s Docker contract (§5b) and the bms4 proxy’s circuit-breaker (§9) actually exist — sequencing constraints, not design gaps.
  • The “4–8 parallel agents on one workstation” figure from the original proposal is retired by the live lap1 check: laptop-class hosts are 1–2-worker nodes (CPU-bound). windows-dev’s real ceiling is unknown until a hardware spec doc and empirical Docker RAM/CPU tuning exist (§5b) — it should not be assumed to be higher than lap1’s just because it hosts WSL2 instead of bare Linux.
  • windows-dev running workers concurrently with interactive use is a real, accepted UX risk — a misconfigured or under-capped container can degrade the developer’s own foreground session on the very machine driving this design. The -m/--cpus cap strategy in §5b is a requirement precisely because this risk was called out explicitly rather than assumed away.
  • The bms4 proxy’s OAuth session-custody purpose (§9.3) is a deliberately accepted repeat of the one failure pattern this repo has already documented (openclaw-gateway). The circuit-breaker and monitoring requirement mitigate but do not eliminate this; if the shared-session refresh path breaks and the circuit-breaker itself has a gap, this could reproduce the exact incident class ADR-002 was written to reduce. This risk is owned by the owner’s explicit decision, not hidden.
  • A windows-dev Docker worker, even capped, adds a new, previously-unproven infrastructure surface (Docker-on-WSL2 reliability, a new worker image to maintain, a new RAM-gate/exit-code translation layer) that must be built and validated from scratch — there is no existing precedent for it in this repo, unlike lap1’s native-process model.

Follow-up issues to open (only if this ADR is accepted — not this PR)

  1. Provision spec — windows-dev Docker worker. Implementation, sys-admin/dev-coder-scoped: build the worker container image; implement the RAM-gate/exit-code translation contract (§5b); implement the -m/--cpus cap strategy; create a real docs/environments/windows-dev.md hardware spec; register the dev_r_server_capacity row starting at max_workers_prime=1 (conservative) and max_weight_prime='light'; wire CF Tunnel dispatch (§3); add claude_accounts per §4. Do not raise concurrency above 1–2 until empirically validated against this workstation’s real free RAM/CPU under concurrent interactive use.
  2. Build spec — bms4 proxy. Implementation, sys-admin-scoped: build the three purposes in §9 (egress IP, account/task routing reusing the existing headroom ranker, OAuth session custody); implement the refresh-failure monitoring and circuit-breaker required by §9 before the OAuth-custody purpose carries real dispatch traffic; explicitly exclude any geo-evasion/proxy- masking behavior from the original declined framing. Write the corresponding ops doc (§7).
  3. Doc corrections. Fix the stale vps-i1-as-worker reference in docs/worker-role-scoping-operations.md (vps-i1 is monitoring-only — confirmed, §5a). Fix docs/playbooks/dispatcher-oom-loop.md’s “Prevention” section, which still lists the heavy 6→8GB RAM bump as an open suggestion even though it was applied on 2026-06-25 (#1737, confirmed live in queue-dispatcher-loop.py’s WEIGHT_RAM_GB — §5b). Add the adding-new-worker.md and server-ssh-connectivity.md sections listed in §7.
  4. (Sequencing prerequisite) resolve the bms-3 RAM-weight dispatch bug (#4717/#4728) before scaling total fleet concurrency (§5a note) — applies with extra force before windows-dev or the bms4 proxy carry real weight.

No human-action-labelled follow-up remains for the account/ToS question — §4a records that the owner already resolved it on #6085 directly; a future agent should not re-open ADR-002 Q3 as if it were still pending.

Verification (how we will know Option (C) is the right call)

  • The current dispatcher already runs the four “new” pillars in production; a reviewer can confirm each claim against the cited files (infra-src/meta-dispatcher/, scripts/queue-dispatcher-loop.py, scripts/spawn-worker.sh, monitoring/supabase/migrations/031_dev_r_server_capacity.sql, 034_dev_r_worker_queue.sql, 038_ram_budget_dispatch.sql).
  • Adding windows-dev as a light-only overflow node is testable end-to-end with minimal code change once §5b’s container contract exists: insert a dev_r_server_capacity row, confirm the loop dispatches a light job to it via CF Tunnel, confirm the RAM/CPU caps hold under a concurrent interactive-use test on the workstation itself, and confirm it reclaims cleanly when the container exits or the host goes offline.
  • The bms4 proxy’s circuit-breaker (§9) is testable by deliberately breaking the shared-session refresh path in a non-production test and confirming the system either fails over to independent logins or halts dispatch, rather than silently continuing on a stale session.
  • The declined “Centralized Subscription Proxy” (geo-evasion) framing remains unbuilt — a reviewer should confirm the eventual bms4 proxy implementation matches only the three purposes listed in §9, not the original doc’s stated evasion goal.

Implementation scope for #6085 (once this ADR is accepted)

In scope for #6085: this ADR document only. #6085 is plan-only. No code, no schema, no networking, no Docker image, no account-switching, and no proxy implementation is built here — §5b and §9 describe accepted-scope designs, not delivered infrastructure. The follow-up issues above are opened only after the user reviews and approves this design.