windows-dev — WSL2 Docker Ephemeral-Worker Node
Status update (2026-08-11): restored as the design of record; go-live in progress (#6163). This was briefly marked superseded by Pane (tracked in #6132); that pivot was itself reverted the same day after Pane’s headless daemon setup hit an unresolved, architectural hang (confirmed via 4 independently ruled-out theories — display availability, network/DNS, WSL2 connectivity, virtual framebuffer — root cause per Pane’s own docs is that its “remote daemon” has no genuine headless code path). See #6132’s final comment for the full writeup. The #6163 go-live has now implemented the dispatcher-loop CF-Tunnel/
docker runintegration (see §Dispatch path); the remaining steps are the on-host provisioning (image build, CF Tunnel reachability verification, per-accountclaude auth login) + soak + flippingenabled=true— not a from-scratch redesign.
Environment ID: windows-dev
Type: local-dev (worker host)
Status: design of record (#6110 / ADR-005 §5b),
go-live in progress (#6163) — dispatcher docker-dispatch path implemented; capacity row ships
DISABLED until the on-host provisioning + soak complete.
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.tunnel.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 | false | inert until image + tunnel + dispatcher docker-path + soak exist |
max_workers / prime / night | 1 / 1 / 1 | conservative; raise to 1–2 only after empirical validation (§5b) |
max_weight_* | light only | no heavy/playwright until soak proves headroom |
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 | 1 | hard cap |
claude_accounts | claude-runner, claude-runner-2, radieu | existing accounts only, no new seats (§4); concurrent same-account use is an accepted risk (§4a) |
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.tunnel.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.
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.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.
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