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-dispatcherCloudflare Worker (infra-src/meta-dispatcher/, cron0 */4 * * *+POST /queue-issue) scans GitHub issues, infers weight/role/server_preference, builds PR-dependency waves, and inserts rows into the Supabasedev_r_worker_queuetable (this is the table CLAUDE.md callsp24_queue). It never SSHes anywhere — it only writes queue rows (docs/meta-dispatcher-operations.md). - Consumer —
scripts/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 runsscripts/queue-dispatcher-loop.py, which does RAM-aware bin-packing: it reads per-host capacity fromdev_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 theavailable_ram_gb()RPC (80 % cap), and fills slots by priority. - Spawn —
scripts/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’sclaude_accounts(exit 5 → dispatcher retries the next account), a pre-spawn RAM guard, and launches the worker undersystemd-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 todev_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:
- Headscale/Tailscale was removed on 2026-07-08 (#3294). Per
docs/playbooks/server-ssh-connectivity.mdandCHANGELOG.md, the mesh VPN was decommissioned because arestart: unless-stoppedHeadscale 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. - 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
radieuClaude Max account; never copy.credentials.jsonbetween hosts/processes (the documented cause of the refresh-token race —docs/playbooks/ claude-oauth-reauth.mdOption C, banned); each host/account runs its own independentclaude auth login; per-account hourlyclaude-token-refreshcron (#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 developerid_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.mdserver 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_accountsholds a per-host list of distinct accounts; bms-4 already runs two (claude-runner,claude-runner-2).queue-dispatcher-loop.pyranks the non-depleted accounts by remaining subscription headroom (read from each account’ssub-status.json) and assigns the least-loaded one per spawn (_account_order, the headroom-rank block ~L1554).spawn-worker.shreturns 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 loginand 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-tunnelboth 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 staleSUPABASE_SERVICE_ROLE_KEYinlaptop.envhad 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_capacityrow with its ownclaude_accountsentries (§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 theC:\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:
| Host | Status (verified 2026-08-11) | Realistic worker capacity | Role in target |
|---|---|---|---|
| bms-4 | Only production worker host; holds dispatcher lease | Existing (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 free | 1–2 light workers (CPU-bound, not RAM) | Optional light-only overflow (§5), native-process model, unchanged |
| vps-i1 | Confirmed monitoring-only — not a worker host | 0 (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 use | 3rd 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 byqueue-dispatcher-loop.py) usessystemd-run --scope --MemoryMax=<weight>Gfor cgroup-based memory isolation on bare Linux hosts. NoDockerfile/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 (standardaptinstall), 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-sourcedocker-cedaemon; 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 inprovision-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.shexit 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_gbvalues (queue-dispatcher-loop.py’sWEIGHT_RAM_GB, source of truth for migration038_ram_budget_dispatch.sql):light=3GB,heavy=8GB,playwright=6GB,orchestrator=12GB. heavywas 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.mdseparately documents vps-i1 (an 8 GB host) being unable to sustain evenlight(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.mddocuments 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--rmcleanup 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.mdor 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 containerweight_ram_gbmapping (and any windows-dev-specific override) off the currentheavy=8GBbudget and the demonstrated pattern that budgets need headroom above the proposal’s assumptions, then validate empirically before raisingmax_workers_primeabove 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 itsweight_ram_gbassignment, 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/--cpuscap strategy, the~/projects/FS rule, CF Tunnel reachability (§3), thedev_r_server_capacityrow, and itsclaude_accountsassignment (§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 002 — not 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_ROLEfromjob_typeand adopts the ADR 001 capability scope (dev-issue→dev-coder,infra-task→sys-admin, etc.); withROLE_CAP_ENFORCE=1the 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: asecret-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-coderand 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-managerwork, its host WSL2 environment is added as anagerecipient 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:
- 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.
- 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. - 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 loginflow.
(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/--cpuscap 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)
- 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/--cpuscap strategy; create a realdocs/environments/windows-dev.mdhardware spec; register thedev_r_server_capacityrow starting atmax_workers_prime=1(conservative) andmax_weight_prime='light'; wire CF Tunnel dispatch (§3); addclaude_accountsper §4. Do not raise concurrency above 1–2 until empirically validated against this workstation’s real free RAM/CPU under concurrent interactive use. - 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). - Doc corrections. Fix the stale vps-i1-as-worker reference in
docs/worker-role-scoping-operations.md(vps-i1 is monitoring-only — confirmed, §5a). Fixdocs/playbooks/dispatcher-oom-loop.md’s “Prevention” section, which still lists theheavy6→8GB RAM bump as an open suggestion even though it was applied on 2026-06-25 (#1737, confirmed live inqueue-dispatcher-loop.py’sWEIGHT_RAM_GB— §5b). Add theadding-new-worker.mdandserver-ssh-connectivity.mdsections listed in §7. - (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_capacityrow, confirm the loop dispatches alightjob 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.