windows-dev — WSL2 Docker Ephemeral-Worker Node
Status update (2026-10-14): LIVE —
enabled=true, scaled for 24/7 idle-most-of-the-time operation. #6185 (WSL2 instability) and 4728 (bms-3 RAM-weight bug, the fleet-concurrency sequencing guard) are both CLOSED. On-host prerequisites verified live: Docker 29.7.2 native in WSL2 Ubuntu, systemd + cloudflared active, per-accountclaude auth logindone forclaude-runner/claude-runner-2,claude-token-refresh+claude-sub-usagecrons installed (mirroring lap2’s #7965 pattern — previously missing here). Capacity raised from the MVP1/1 light-onlyposture tomax_workers_prime=4/max_workers_night=8,max_weight_prime=max_weight_night=heavy— the owner’s workstation sits on 24/7 but is rarely interactively used, so night capacity now exceeds day capacity (opposite of the original conservative default). #6740 (CF Access Service Token) and #7980 (SSH auth) both resolved and verified end-to-end, same day. The CF Access service token was already deployed (CF_ACCESS_WINDOWS_DEV_CLIENT_ID/_SECRETinsecrets/n8n-bms4.env.sops, synced to bms-4). The real blocker wasdev_r_server_capacity.ssh_user='claude'— no such user exists on the WSL2 host (lap2 correctly usesroot); fixed by settingssh_user='root'to match. bms-4’sp24-dispatcherpubkey was already present in/root/.ssh/authorized_keyson the guest. Verified from bms-4:ssh -i /root/.ssh/p24-dispatcher -o ProxyCommand="cloudflared access ssh --hostname %h" root@windows-dev-ssh.zintegrowana.onlinesucceeds, no OTP prompt, no publickey failure.current_workersindev_r_server_capacityhas been observed stale/mismatched against actual WSL docker-container count — verify viawsl -d Ubuntu --exec docker psrather than trusting the DB column alone until that’s root-caused.
Environment ID: windows-dev
Type: local-dev (worker host)
Status: LIVE and dispatchable end-to-end (#6110 / ADR-005 §5b,
ADR-006 Phase 1/2 complete) — dev_r_server_capacity.enabled=true,
ssh_user=root, scaled for idle-resource utilization.
This is the design of record for a Docker/WSL2 worker on the developer workstation, per the owner decision reconciled in ADR-005 (the Pane detour in #6132 was reverted). It supersedes
wsl1.md’s self-polling / native-process architecture (that doc’s hardware spec is reused below; its design is not).
Purpose
The dev workstation’s WSL2 Ubuntu instance, run as the 3rd/4th worker host (after bms-4 and the
lap1 laptop) using Docker ephemeral workers — one docker run --rm-style container per job.
Unlike lap1’s “idle-window only” framing, windows-dev workers run concurrently with interactive
use (this Claude Code session, IDE, browser) — so every container is hard-capped on RAM and CPU
to protect the developer’s foreground work (ADR-005 §5b).
It is a variable-uptime overflow node, never load-bearing: it must not hold the dispatcher lease and must not be a dependency for any scheduled job. If the machine is off or the tunnel is down, its unclaimed queue rows simply stay queued for bms-4 (pull-based dispatch on liveness).
Hardware
Real spec (reused from wsl1.md; same physical host, WSL2 allocation):
| Field | Value |
|---|---|
| Label | windows-dev |
| Host OS | Windows 10 (dev-workstation, see dev-workstation.md) |
| Guest OS | Ubuntu 26.04 LTS (WSL2, systemd enabled) |
| CPU | 6 cores |
| RAM | 46 GB (WSL2 allocation — raised from 31 GB via ~/.wslconfig, weight-tier update 2026-08-11) |
| Disk | 954 GB free |
| Inbound | CF Tunnel: windows-dev-ssh.zintegrowana.online (no public IP; ADR-005 §3) |
Capacity (dev_r_server_capacity — ships DISABLED)
Set by migration supabase/migrations/20260811052102_windows_dev_docker_worker_capacity_6110.sql,
superseded by 20260811133644_windows_dev_weight_tiers_update.sql (owner weight-tier redefinition +
WSL2 ceiling raised to 46 GB):
| Field | Value | Why |
|---|---|---|
enabled | true | live (2026-10-14) — see status banner above |
max_workers_prime / max_workers_night | 4 / 8 | raised 2026-10-14: the workstation is on 24/7 but rarely interactively used, so night capacity now exceeds day capacity (inverse of the original MVP default) |
max_weight_prime / max_weight_night | heavy / heavy | raised 2026-10-14 (night was light-only before — backwards, since night is when the host is most idle). playwright is NOT yet enabled — same gating (#7718-style smoke test) as lap2 |
weight_ram_gb | superlight=1, light=2, normal=4, heavy=7 (playwright=7, subagent=1, orchestrator=12) | owner-defined tiers (2026-08-11); each == the Docker -m hard cap for that weight |
reserved_ram_gb | 2 (of 46) | headroom for the interactive session on the same box |
emergency_max_workers | 10 | raised 2026-10-14 (was 1) — hard cap, not the steady-state cap |
claude_accounts | claude-runner, claude-runner-2 | existing accounts only, no new seats (§4). radieu deliberately excluded (2026-09-01, owner decision) — it’s the developer’s own daily-driver account, and automated dispatch on it would compete with interactive use on this same workstation right when it’s being used |
Usable budget: (46 − 2) × 0.80 ≈ 35.2 GB → a light worker uses 2 GB, well under cap;
max_weight stays light-only regardless (capacity raised, dispatch ceiling unchanged).
Dispatch path (CF Tunnel inbound — ADR-005 §3)
bms-4 dispatcher (queue-dispatcher-loop.py)
→ CF Tunnel (windows-dev-ssh.zintegrowana.online)
→ scripts/run-windows-dev-worker.sh ISSUE JOB_TYPE WEIGHT ...
→ docker run --memory <G> --memory-swap <G> --cpus <n> p24-windows-dev-worker <args>
→ entrypoint.sh → spawn-worker.sh (WORKER_ISOLATION=docker, foreground, no systemd)
→ translate outcome → dispatcher's five-code contract
Unlike Linux hosts (dispatcher SSHes spawn-worker.sh directly), windows-dev is reached over its CF
Tunnel and the far end runs run-windows-dev-worker.sh. The dispatcher-loop change to invoke this
path is implemented (#6163) — queue-dispatcher-loop.py’s spawn_worker() detects a
windows-dev/docker-run host, wraps SSH in a cloudflared access ssh ProxyCommand (the lap1
pattern), and launches run-windows-dev-worker.sh instead of spawn-worker.sh. The row still stays
enabled=false until the on-host provisioning + soak complete (see the go-live playbook).
Detached, fire-and-forget launch. Because the container runs the Claude worker in the
foreground for the whole job (spawn-worker.sh WORKER_ISOLATION=docker), the dispatcher launches
it DETACHED (setsid … &) and returns as soon as it is spawned — exactly like the bare-host
systemd-run --scope & detach — rather than blocking the 2-min dispatch loop for the job’s lifetime.
Completion is self-reported by the in-container queue-row status/heartbeat PATCH (+ the dispatcher’s
stale-worker reset as the safety net). The wrapper’s exit-code translation below therefore applies
when it is run synchronously (the manual smoke test), not on the live detached dispatch path;
synchronous exit-5 account rotation is consequently not available on this path (acceptable for the
light-only, single-worker MVP — ADR-005 §5b).
Exit-code / RAM-gate translation contract (the core of #6110 item 2)
Docker signals an OOM-kill with its own code (137 = 128+SIGKILL) and a .State.OOMKilled flag,
neither of which the dispatcher’s OOM path (exit 2) recognises. run-windows-dev-worker.sh
translates so the dispatcher’s retry/requeue/escalate logic is unchanged:
| Container outcome | Wrapper emits | Dispatcher behaviour |
|---|---|---|
| clean run | 0 | done |
.State.OOMKilled==true OR exit 137 | 2 | OOM → re-dispatch heavier / small-host reroute |
in-container spawn-worker.sh RAM pre-check | 3 (passthrough) | requeue unchanged |
| needs heavier weight | 4 (passthrough) | escalate tier |
| subscription blocked | 5 (passthrough) | try next account |
| git clone/auth failure | 6 (passthrough) | reroute to bms-4 |
| container could not run (docker 125/126/127) | 255 | treat as connectivity failure (reroute/backoff) |
The -m/--cpus caps (light → 2 GB / 1 CPU; normal → 4 GB / 2 CPUs; heavy → 7 GB / 3 CPUs)
are applied by the wrapper and must equal the weight_ram_gb budget above. Tested in
scripts/tests/test_run_windows_dev_worker.py; the dispatcher-side path is tested in
scripts/tests/test_spawn_windows_dev_dispatch.py.
OAuth token refresh + sub-usage crons (WSL2 guest, added 2026-10-14)
Mirrors lap2’s #7965 pattern (see lap2.md) —
the standard Ansible-managed claude-token-refresh cron targets /home/<user>/.claude/ and does
not apply to a host whose Docker workers mount credentials from /root/.claude-configs/<account>/
on the WSL2 guest instead. Installed directly on this host (was previously missing — a gap found
during the 2026-10-14 go-live check):
/etc/cron.d/claude-token-refresh— every 15 min, bothclaude-runnerandclaude-runner-2,--uid 1001(theclaudeuser’s uid insidep24-windows-dev-worker, confirmed viadocker run --rm --entrypoint id p24-windows-dev-worker -u claude)./etc/cron.d/claude-sub-usage— every 10 min, both accounts,CLAUDE_SUB_MACHINE=windows-devso theClaudeRunnerTokenExpired/ClaudeRunnerTokenExpiringSoonPrometheus gauges label correctly.
Verify: tail /var/log/claude-token-refresh.log / tail /var/log/claude-sub-usage.log inside WSL2
after one cycle.
Real auth probe cron (WSL2 guest, #7965)
claude-auth-synthetic-check.py now supports --config-base <dir> and --machine <label> (#7965,
mirroring lap2 — see lap2.md §Real auth probe).
Add /etc/cron.d/claude-auth-synthetic-check (create if absent) on the WSL2 guest:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
*/15 * * * * root python3 /opt/p24-infra/scripts/claude-auth-synthetic-check.py \
--config-base /root/.claude-configs \
--machine windows-dev \
>> /var/log/claude-auth-synthetic.log 2>&1
This iterates all account subdirs under /root/.claude-configs/ (claude-runner, claude-runner-2)
and pushes p4_claude_auth_valid{machine="windows-dev",account="…"} to the vps-i1 Pushgateway.
Without it, a dead token on windows-dev is caught only reactively (after a job fails with
spawn_oauth_expired) rather than by the proactive ClaudeAuthSyntheticFailing alert (30 min).
Verify after one cycle (≤15 min):
# On the WSL2 guest
tail -20 /var/log/claude-auth-synthetic.log # "Auth VALID for radieu/ecotrans on windows-dev"
# On vps-i1 (Pushgateway)
ssh root@217.154.82.162 "curl -s http://localhost:9091/metrics | grep p4_claude_auth_valid | grep windows-dev"Secrets access (ADR-005 §8 — per-role, at the container-mount level)
The container inherits the same CLAUDE.md Role Enforcement matrix as a bms-4 worker, enforced at the mount level, not just in-process:
secret-manager-role containers only get the SOPS age key mounted in.sys-admin/sys-securityget read-only credential access for verification (not yet wired on the docker-run path — a follow-up to #7104).dev-coderand every other role get no SOPS/age-key access at all, even transiently — the key is simply never mounted, so there is nothing to withhold after the fact.
Every research/investigation subagent still carries the mandatory #5544 “No SOPS decryption” clause, identically inside a container.
How it is enforced (#7104). scripts/run-windows-dev-worker.sh reads the ROLE positional arg
($7) and adds a read-only age-key mount only when it is secret-manager:
-v $WORKER_AGE_KEY_FILE:/home/claude/.age/p24-infra-keys.txt:ro (the container path
spawn-worker.sh exports as SOPS_AGE_KEY_FILE). Every other role builds an empty mount array, so
the key is never bind-mounted.
WORKER_AGE_KEY_FILEis a host env var set at provisioning — this host’s own automation age-key path, deliberately not hardcoded and distinct from the interactive developer’s personal key. Set it before dispatching anysecret-managerjob here.- A
secret-managerjob dispatched withWORKER_AGE_KEY_FILEunset or pointing at a missing file fails closed (the wrapper exits 1 — the validation-failure code — rather than launching a SOPS-blind container).
Related docs
- ADR-005 — the design of record (§3/§4/§5b/§8)
docs/playbooks/provision-windows-dev-docker-worker.md— build + activation runbookwsl1.md— superseded architecture; source of the reused hardware specdev-workstation.md— the Windows host WSL runs onlap1.md— the sibling laptop overflow node (native-process, not Docker)docker/windows-dev-worker/— the worker image;scripts/run-windows-dev-worker.sh— host wrapper