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 run integration (see §Dispatch path); the remaining steps are the on-host provisioning (image build, CF Tunnel reachability verification, per-account claude auth login) + soak + flipping enabled=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):

FieldValue
Labelwindows-dev
Host OSWindows 10 (dev-workstation, see dev-workstation.md)
Guest OSUbuntu 26.04 LTS (WSL2, systemd enabled)
CPU6 cores
RAM46 GB (WSL2 allocation — raised from 31 GB via ~/.wslconfig, weight-tier update 2026-08-11)
Disk954 GB free
InboundCF 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):

FieldValueWhy
enabledfalseinert until image + tunnel + dispatcher docker-path + soak exist
max_workers / prime / night1 / 1 / 1conservative; raise to 1–2 only after empirical validation (§5b)
max_weight_*light onlyno heavy/playwright until soak proves headroom
weight_ram_gbsuperlight=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_gb2 (of 46)headroom for the interactive session on the same box
emergency_max_workers1hard cap
claude_accountsclaude-runner, claude-runner-2, radieuexisting 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 outcomeWrapper emitsDispatcher behaviour
clean run0done
.State.OOMKilled==true OR exit 1372OOM → re-dispatch heavier / small-host reroute
in-container spawn-worker.sh RAM pre-check3 (passthrough)requeue unchanged
needs heavier weight4 (passthrough)escalate tier
subscription blocked5 (passthrough)try next account
git clone/auth failure6 (passthrough)reroute to bms-4
container could not run (docker 125/126/127)255treat 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-security get read-only credential access for verification.
  • dev-coder and 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.