Files
reliquary/docs/workpad/reports/2026-10-16-22-gremlins-platform-handoff.md
T

340 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Platform handoff: `spire:gremlins` on Linux and macOS
**Written by**: block 3.7 owner, `claude-opus-5-5[1m]`, 2026-10-16 22:15 +0200 (`date`), from the `headless-forks` branch at `485cc66`.
**For**: an Igor on native Linux and an Igor on macOS. Each starts from a checkout of `headless-forks`, tests the helper on their platform, fixes it and lifts the fail-closed gate there. The branch is merged and released only after both have done this.
**Sources**: the Phase 3 contract digest (`docs/workpad/plans/2026-10-16-18-gremlins-phase3-contract.md`, §8 and its final amendment), the run log (`docs/workpad/reports/2026-10-16-08-headless-forks-run.md`: blocks 1.3, 2.4, 3.2, 3.3, 3.4, 3.4a), the Phase 1 review and re-check (`…-16-headless-forks-phase1-review.md` M14, `…-17-headless-forks-phase1-recheck.md` I2), and the code. Line numbers refer to `plugins/spire/skills/gremlins/gremlins.py` (the helper) and `…/tests/test_gremlins.py` (the tests) at `485cc66`.
---
## 1. Status
- **Tested on Windows only.** On every other platform the gate is closed: `where` reports `ok: false` with the reason and exits 2, `init` refuses, and on an existing state dir `train`, `spawn`, `continue`, `rebase` and `tick` refuse too (exit 2, `error: "platform not supported"`). `wait`, `list`, `usage`, `kill` and `expire` stay open, so you can always stop and inspect what you launched under the override.
- **Release gating (user decision, 2026-10-16).** The release is not cut from Windows alone. The work stays on `headless-forks`; it is pushed but not merged. A Linux Igor and a macOS Igor each test, fix and lift the gate for their platform; only then is the branch merged and released. WSL is fine for development, but it is not native Linux. macOS stays closed until someone has tested on a Mac.
- **The goal file.** These decisions live in `docs/workpad/goals/headless-forks.md` as edited on `main` (sections "Decided … platforms", "Amended … release gating", "Decided … platform seam"). At the time of writing those edits were not yet committed on `main`, and the branch copy of the goal is older. This report therefore restates what you need; do not rely on the goal file in your checkout.
- **No live `claude` call has been made under the new names.** Phases 1 and 2 smoked the helper live on Windows under its old name (`spire:forks`, command `fork`). Phase 3 renamed it, moved the platform code behind a seam and added the gate, all tested with a stub binary only (digest C17). Phase 4 must smoke the renamed helper on Windows before anything ships. Your smoke (§7) is the first live run on your platform.
- **Branch.** `headless-forks`, remote `origin` = `git.g4b.org:dirigence/reliquary`. At the time of writing it had no upstream (not pushed yet). Once the user has pushed it:
```bash
git fetch origin
git switch headless-forks # or: git worktree add ../reliquary-hf headless-forks
git log --oneline -1 # expect the Phase 3 release commit or later
```
Block 3.8 (README row, manifests, full suite, release commit) comes after this report. Read the run log's Phase 3 close first, for anything that changed after this report was written.
- **Your run log.** Record your work in a run log of your own (`docs/workpad/reports/<YYYY-MM-DD-hh>-gremlins-<linux|macos>-port-run.md`, placement per the `workpad` skill), with handoff notes in the format of `plugins/spire/skills/implementation/references/run-log.md`.
## 2. The seam
All platform-specific code sits in one section of the helper, between `# --- platform seam: begin ---` (`:267`) and `# --- platform seam: end ---` (`:734`). Outside it, nothing tests `os.name` or `sys.platform`, and nothing calls `ctypes`, `killpg` or `/proc`. Block 3.2's seam grep proved this (run log, block 3.2, gate 4):
```bash
git grep -n -E "os\.name|sys\.platform|IS_WINDOWS|ctypes|_k32|taskkill|powershell|killpg|setsid|start_new_session|creationflags|cygpath|/proc" -- plugins/spire/skills/gremlins/gremlins.py
```
Keep that true when you fix things: every hit must stay between the markers.
**Selection.** `select_platform()` (`:728`) is the only `os.name` test: `WindowsPlatform()` on `nt`, else `PosixPlatform()`. `PLATFORM = select_platform()` (`:732`). WSL, Linux and macOS all get `PosixPlatform`. Both classes can be built on any host (Windows `ctypes` is loaded lazily), so the path matrix and the gate tests run everywhere.
**The interface** (`Platform`, `:277`) and what `PosixPlatform` (`:604`) does today:
| Member | `PosixPlatform` today | Line |
|---|---|---|
| `name`, `detach_method` | `"posix"`, `"setsid"` | `:607–608` |
| `supported` | property: `sys.platform in TESTED_SYS_PLATFORMS`; the tuple is empty, so always `False` | `:610`, `:614` |
| `describe()` | shared: `{name, sys_platform, wsl, supported, detach}` (`supported` read from the property) | `:285` |
| `is_wsl()` | `WSL_DISTRO_NAME` set, or `microsoft` in `/proc/sys/kernel/osrelease`; unreadable → `False` | `:617` |
| `norm_path(p, cwd)` | `posixpath.normpath(join(cwd, p))`; refuses empty, a drive form (`^[A-Za-z]:`) and any backslash | `:627` |
| `popen_kwargs(new_group)` | `{"start_new_session": new_group}` | `:641` |
| `detach(argv, cwd, log_path)` | `Popen(argv, cwd, stdin=DEVNULL, stdout/stderr=log, start_new_session=True, close_fds=True)`; returns `{ok, method, pid, ctime}` | `:644` |
| `proc_ctime(pid)` | `/proc/<pid>/stat` field 22 (clock ticks since boot); `None` without `/proc` | `:656` |
| `epoch_to_ctime(t)` | `(t - btime) * SC_CLK_TCK` from `/proc/stat`; `None` without `/proc` | `:665` |
| `_running(pid)` | `os.kill(pid, 0)`; `ProcessLookupError` → dead; `PermissionError` → alive | `:676` |
| `pid_alive(pid, ctime)` | shared: `_running`, then the creation-time check when both sides are known | `:313` |
| `process_table()` | `/proc` walk → `[(pid, ppid, comm)]`; `[]` without `/proc` | `:685` |
| `terminate(pid)` | `os.kill(pid, SIGKILL)` | `:705` |
| `tree_kill(pid)` | `os.killpg(pid, SIGKILL)`, `OSError` suppressed; returns `None` | `:712` |
| `restore_no_replace(src, dst)` | `os.link(src, dst)` then `os.remove(src)`; `OSError` → `False` | `:719` |
| `visible_windows()` | `None` (not applicable; the window test skips) | `:338` |
**Delegates.** Neutral code calls these module-level names, and the tests patch them: `proc_ctime` (`:1070`), `epoch_to_ctime` (`:1075`), `pid_alive` (`:1085`), `record_alive` (`:1090`), `process_table` (`:1094`), `norm_path(p, cwd, windows=None)` (`:774`; `windows=True/False` selects a class, for the tests). `terminate(rec)` (`:1154`) checks `record_alive` and then calls `PLATFORM.terminate`. `kill_tree` (`:1161`) makes one `PLATFORM.tree_kill` call, on the first live root (`:1175–1179`). `detach_supervisor` (`:1745`) builds the argv `[supervisor_interpreter(), "-I", script, "_supervise", "--run", R, "--job", J]` and calls `PLATFORM.detach`. `Supervisor.run` starts `claude` with `PLATFORM.popen_kwargs(new_group=True)` (`:3588–3590`). `RegistryLock._restore` (`:941`) calls `PLATFORM.restore_no_replace`. `_run` (`:1208`) uses `PLATFORM.popen_kwargs()`.
**Neutral code that matters to you:** `process_tree` (`:1102`), `kill_tree` (`:1161`) and `snapshot_survivor` (`:1192`), `reap_lost` (`:2047`), `kill_job` (`:2076`), `Supervisor.run` (`:3571`), `supervisor_interpreter` (`:1398`), `scrubbed_env` (`:1265`), `transcript_path` (`:2782`).
**The gate** (neutral, below the seam): `gate_overridden()` (`:742`), `platform_report()` (`:748`), `platform_gate()` (`:753`, the reason text), `require_platform()` (`:762`). `cmd_init` calls `require_platform()` as its first line (`:2560`). The dispatcher refuses `GATED_COMMANDS` (`:124`) right after the N3 check (`:4125–4126`). `tick_pass` returns at once on a closed gate (`:3397`). `cmd_where` (`:2320`) puts the gate reason first in `reasons` and still fills the rest of the report.
## 3. Running the gates there
**Prerequisites.**
- `uv`, with the official installer (`curl -LsSf https://astral.sh/uv/install.sh | sh`, which installs to `~/.local/bin`) or a package manager (`brew install uv` on macOS). The helper and the tests are PEP 723 scripts with no dependencies (`requires-python >= 3.10`); `uv run` picks or downloads a managed CPython.
- `git`.
- A **native** `claude` for your platform, on `PATH` or given with `--binary`. Do not use a Windows `claude.exe` through WSL interop (§4.12).
**Gates** (from the checkout root):
| Gate | Command | Expect |
|---|---|---|
| G-unit | `uv run plugins/spire/skills/gremlins/tests/test_gremlins.py > /tmp/g-unit.txt 2>&1; echo exit=$?` | 165 tests at the WSL check. About 150 s on Windows; WSL took 393 s. unittest writes to stderr, so capture both streams. |
| one class | `uv run plugins/spire/skills/gremlins/tests/test_gremlins.py GateTests PathMatrixTests` | names as for `unittest` (`Class` or `Class.test_name`) |
| G-where | `uv run plugins/spire/skills/gremlins/gremlins.py where` | on a closed gate: exit 2, `ok: false`, `reason` starts "spire:gremlins is not tested on linux yet; …" (or `darwin`, or `linux, WSL`) |
| compile | `uv run python -m py_compile plugins/spire/skills/gremlins/gremlins.py` | exit 0 |
Run one copy of the suite at a time. On Windows the module-end check (`tearDownModule`, tests `:4217`) kills every process whose command line contains `gremlins-test-`, which would include another copy's. On POSIX that check does nothing yet (§4.11).
**The override, `SPIRE_GREMLIN_UNSUPPORTED_OK=1`.** It is a porting aid, documented here and nowhere in `SKILL.md`. When it is set to exactly `1` on an unsupported platform, the gate is open for that call. `where` then reports `platform.override: true`, stays `ok: true` if nothing else fails, and adds one warning ("platform gate overridden (…=1); this platform is untested, use it only to port and test spire:gremlins"). On a supported platform the variable changes nothing (`gate_overridden`, `:742`). Use it to run the smoke before the gate is lifted in code. Remove it from your shell before you check the lifted gate: with the gate lifted, `where` must say `supported: true, override: false` **without** it.
**The suite sets the override itself.** `Base.setUp` (tests `:80–90`) sets `SPIRE_GREMLIN_UNSUPPORTED_OK=1` for every test, so the whole suite exercises the POSIX code on a closed platform; `GateTests.setUp` (tests `:1408–1410`) removes it again. You do not need it in your shell to run the tests.
**What a green suite does and does not prove (C13).** A green run on Linux says nothing about the gate, because every test except `GateTests` runs with the gate overridden. Only `GateTests` (tests `:1405`) cover the gate. And `GateTests` mostly use a fake closed platform (`closed_platform()`, tests `:1387`), so they still pass after you lift the gate. The one that pins the real POSIX value is `test_supported_flags` (tests `:1430`), which today asserts `PosixPlatform().supported is False` for `linux` and `darwin`. Lifting the gate means changing that test on purpose (§8).
## 4. Known POSIX gaps, with code references
Each item says what the code does, what goes wrong, and on which platform. "Seen" means observed in the WSL run (block 3.4); "from the code" means read and reasoned, not observed.
### 4.1 M14: `kill` misses `claude`'s group (Linux and macOS)
- `Supervisor.run` starts `claude` in its own session (`popen_kwargs(new_group=True)` → `start_new_session=True`, `:3590`, `:641`). The supervisor is itself a session leader (`detach`, `:649`).
- `kill_job` (`:2076`) calls `kill_tree(sup, extra=[claude])`. `kill_tree` calls `tree_kill` on the **first live root only** (`:1175–1179`), which is the supervisor: `killpg(<supervisor pid>)` kills the supervisor's group and does not reach `claude`'s.
- What saves Linux is the sweep (`:1180–1188`): every snapshot pid still alive is sent SIGKILL, and the snapshot comes from `process_tree`, which walks `/proc` (`:1115`). Processes `claude` starts between the snapshot and the kill are not in it.
- On macOS (§4.2) the snapshot holds only the two roots. The Phase 1 review wrote that `kill` "leaves `claude` running". By the current code, `kill_job` passes the `claude` record as an extra root, so `claude` itself is killed by the sweep, by pid only. Its descendants (Bash tool commands, `git`, `gpg-agent`) are not found and survive. Check this on the Mac.
- The supervisor's own kills (timeout `:3629`, orphan watchdog `:3647`, model refusal `:3626`) call `kill_tree(<claude>)`, and there `killpg(<claude pid>)` does hit `claude`'s group. M14 is about `kill`, `expire` and anything else that starts from the supervisor.
- Fix options (yours to choose, with a test): call `tree_kill` for **every** live root, not just the first. That changes neutral code, so it also runs on Windows, where an extra `taskkill` of an already-dead tree is harmless. Or have `PosixPlatform.tree_kill` also kill the process groups found in the snapshot. Or start `claude` in the supervisor's group, but then the supervisor's own `killpg(<claude>)` no longer works.
### 4.2 The `/proc` tree walk (macOS)
- `process_table` (`:685`), `proc_ctime` (`:656`) and `epoch_to_ctime` (`:665`) read `/proc`. macOS has none, so they give `[]`, `None` and `None`.
- Consequences on macOS:
- `kill` and `expire` find only the roots (§4.1).
- The post-exit sweep (`:3662`, "kill leftovers of `claude`") finds nothing. `claude` is dead by then, a dead root without a creation time claims nothing (§4.3), and backgrounded commands and daemons survive.
- `lost` (`reap_lost`, `:2047`) kills a live `claude` by its group but cannot find a dead `claude`'s leftovers.
- Every stored pid has `ctime: None` (`pid_record`, `:1080`), so `pid_alive` is pid-only. **A reused pid is taken for the old process.** `kill_tree` would then `killpg` and SIGKILL whatever now owns that pid. This happens in `reap_lost` after a reboot, or in a late `kill` of a long-dead job. It is the most dangerous macOS gap, because it can hit an unrelated process.
- Registry-lock staleness (`RegistryLock._is_stale`, `:931`) is pid-only too: a lock held by a dead process whose pid was reused waits for `LOCK_ABANDONED_S` (300 s) before it is broken.
- Fix direction: a macOS `process_table`, `proc_ctime` and `epoch_to_ctime` inside `PosixPlatform`, chosen by `sys.platform == "darwin"`. They must give `(pid, ppid, name)` and a creation time in one unit that `epoch_to_ctime` can convert to. Two stdlib-only ways: `ctypes` on `libproc` (`proc_listallpids`, `proc_pidinfo(PROC_PIDTBSDINFO)` gives ppid, comm and start time in seconds plus microseconds, and the process status), or `ps -axo pid=,ppid=,lstart=,comm=` (whole-second start times, slower). The `ProcessTreeTests` fake a table (tests `:616`) and run anywhere; add tests for the real macOS reader.
### 4.3 Dead-root claims (I2)
- `process_tree` (`:1102`) takes a dead root's direct children only when `root_ctime < child_ctime <= epoch_to_ctime(alive_until + 1 s)` (`:1125–1145`). Without a recorded root ctime or a working `epoch_to_ctime`, a dead root claims nothing (`:1127–1128`).
- On Linux this works through `/proc/stat` `btime`. On macOS it claims nothing (safe, but leftovers survive), until §4.2 is fixed.
- Accepted residual on every platform: a child started in the root's last heartbeat interval (up to `HEARTBEAT_S` = 10 s) is not claimed (re-check report, I2).
### 4.4 Zombie liveness in `pid_alive` (Linux; seen on WSL)
- `PosixPlatform._running` (`:676`) is `os.kill(pid, 0)`. That succeeds on a zombie (state `Z`), so a killed child that its parent has not yet reaped counts as alive.
- Seen in the WSL suite: `LaunchTests.test_kill_tree`, `test_refusals`, `test_expire_kills_running_jobs`, `test_lost`, `test_orphan_watchdog` and `test_rebase_counts_toward_cap` failed. The tests call the CLI in-process (`call`, tests `:59`), so the test process is the supervisor's parent; the killed supervisor stays a zombie, and `kill` reports `ok: false` with the supervisor as the only survivor. `zombie_probe.py` (WSL scratch) confirmed it: kill-0 says alive, `/proc` state `Z`, gone after reaping.
- In production a detached supervisor's launcher (`spawn`, `train`, …) exits at once, so the supervisor is reparented and reaped. But from the code:
- A `wait` whose tick launches a ping is that ping supervisor's parent for up to 570 s and never reaps it.
- Inside `Supervisor.run`, after `kill_tree(<claude>)` (timeout, orphan, refusal) `claude` is a zombie until `p.wait()` (`:3660`). The verify loop therefore spins for the full `KILL_VERIFY_S` (10 s, `:151`) before the supervisor goes on. The result is unaffected; it costs 10 s per such kill.
- A subreaper that reaps late would make every `kill` report survivors.
- Fix (porter's item, not fixed on Windows): treat state `Z` (and `X`) as dead in `PosixPlatform._running`, from `/proc/<pid>/stat` field 3 on Linux, and from `libproc`'s `pbi_status == SZOMB` (or `ps -o stat=`) on macOS. Add a test that kills a child without reaping it and expects `pid_alive` to say dead.
### 4.5 `test_timeout_and_stalled` expects a tree of at least 3 (Linux; seen on WSL)
- Tests `:3186–3194`: `assertGreaterEqual(len(process_tree(<supervisor>)), 3)`. On Windows the stub runs as supervisor → `cmd.exe` (the `.cmd` wrapper) → python. On Linux the stub's `sh` wrapper `exec`s python, so the tree is supervisor → python: 2.
- This expectation is shaped by the platform, not a product bug. Make it `2` on POSIX with a comment (`WIN_PLATFORM`, tests `:41`), or count the real members (supervisor, `claude` record, stub). Do not loosen it to "≥ 1".
### 4.6 Locks: `restore_no_replace` needs hard links
- `PosixPlatform.restore_no_replace` (`:719`) uses `os.link` + `os.remove` (a put-back that never overwrites). It is used only when a lock that was being broken turns out to be fresh (`RegistryLock._break`, `:945–964`).
- On a filesystem without hard links (some network mounts, exFAT, some container volumes) the put-back fails: the lock stays aside as `registry.<hex>.broken.lock`, with a diagnostic on stderr. Keep the state dir (it sits next to the run log) on a local filesystem, and say so if you find a case.
### 4.7 Detach by `setsid`: survival untested
- `detach` (`:644`) gives the supervisor a new session. Whether it survives (a) the Bash call that launched it returning, (b) that Bash call being stopped (the Bash tool backgrounds a call at its timeout, and a `TaskStop` kills the call's tree), and (c) the end of the Igor's or the Athanor's turn has never been tested on POSIX.
- On Windows this took a ladder of probes (P0.1) and ended with CIM `Win32_Process.Create`. On POSIX the risk is a harness that kills by cgroup or session rather than by process group. Test it (§6, §7 step 15).
### 4.8 The supervisor interpreter
- `supervisor_interpreter()` (`:1398`) returns `sys._base_executable` when it differs from `sys.executable` (a venv launcher); the supervisor runs it with `-I`. On WSL it resolved to the uv-managed base interpreter, and the in-process launches in the suite worked.
- Untested: a real detached supervisor under `uv run` on native Linux and on macOS (a framework Python, `brew` Python, a uv-managed one). `where` shows `python.supervisor_interpreter`; check that it is a real interpreter file that still exists after `uv run` exits.
### 4.9 Path refusals on POSIX
- `PosixPlatform.norm_path` (`:627`) refuses a drive form (`C:…`) and any backslash. This is the only POSIX behaviour change of Phase 3 (digest §3.4). The full matrix (`PathMatrixTests`, tests `:225`) passed on WSL.
- A real POSIX path with a backslash in a file name is refused. That is deliberate, but a porter may meet it.
- macOS paths are case-insensitive on default APFS; the helper compares paths as given (no `normcase` on POSIX). That is harmless unless the same dir is passed with different case.
### 4.10 The env scrub list came from the Windows Desktop host
- `SCRUB_DROP` (`:90`), `SCRUB_DROP_PATTERNS` (`:104`), `SCRUB_KEEP` (`:105`) and `SCRUB_PINNED` (`:111`) were built from the Claude Desktop app's env on Windows (design §4, research E1, P0.0). `scrubbed_env` (`:1265`) keeps everything not dropped.
- On Linux and macOS the host (Desktop app, terminal, IDE) may set other `CLAUDE_*` or `ANTHROPIC_*` variables. `where` lists the ones on no list under `scrub.unknown` (`scrub_unknown`, `:1284`): read it on your host and decide for each one. `USERPROFILE` and `CLAUDE_CODE_GIT_BASH_PATH` in the keep list are Windows-only and harmless elsewhere.
- **The override passes the scrub.** `SPIRE_GREMLIN_UNSUPPORTED_OK` is on no list, so it is copied into every job's env file (`raw/<job>.env.json`). `Supervisor.run` replaces its own `os.environ` with that env (`:3579–3580`), so a supervisor's keep-warm tick (`safe_tick`, `:3658`) sees the gate open and **pings as the orchestrator would** on a porting host. The gremlin's `claude` also carries it, which is harmless (N3 refuses launches inside a gremlin). On Windows this changes nothing. After the gate is lifted it is moot; until then, expect supervisor pings during long smokes. If you want the supervisor to stay closed, add the variable to `SCRUB_DROP`.
### 4.11 The tests' leftover check is Windows-only
- `tearDownModule` (tests `:4217`) returns at once on POSIX (`if not IS_WINDOWS …`). A supervisor or stub that survives the suite therefore goes unnoticed. Add a POSIX branch: `ps -eo pid=,args=` (Linux) or `ps -axo pid=,command=` (macOS), filter on `gremlins-test-`, kill by pid, and fail the suite. Until then, check by hand after each run: `pgrep -af "gremlins-test-|_supervise|claude-stub"`.
### 4.12 A Windows `claude.exe` is accepted under WSL (WSL only; seen)
- `shutil.which("claude")` did not pick the Windows binary on the WSL host (it is named `claude.exe`). But `where --binary /mnt/c/Users/…/claude.exe` with the override reports `ok: true`: `claude.exe --version` runs through interop. The helper would then launch a Windows `claude` under a Linux supervisor, with Linux paths and a Linux env.
- If WSL is ever opened: refuse a `/mnt/<d>/…` binary (or any `*.exe`) in `resolve_binary` / `binary_info` (`:1309`, `:1347`) when `PLATFORM.is_wsl()`.
### 4.13 The transcript slug rule (`transcript_path`)
- `transcript_path` (`:2782`) assumes `~/.claude/projects/<cwd with every non-alphanumeric replaced by '-'>/<session>.jsonl`. This was observed on Windows only (P0.7). It feeds only `max_base_tokens` (a `retrain_due` reason); when the file is missing, the base's stored context is used instead.
- Check after your smoke: `ls ~/.claude/projects/` should show the clone's path with `/` and `.` replaced by `-` (for example `-tmp-gremlins-smoke-AbCd-clone`), and B1's session file in it.
### 4.14 `!` injection with a failing `where`
- `SKILL.md` injects `` !`uv run "${CLAUDE_SKILL_DIR}/gremlins.py" where` ``. On a closed platform that command exits 2. Whether Claude Code still loads the skill and shows the report, or fails the skill load, is untested (digest §12). The text tells the Athanor to treat "no report" as `ok: false`. Load the skill once on your platform with the gate closed and record what happens.
### 4.15 Cosmetic
- `where` joins its reasons with `"; "` (`:2372`). The gate reason ends with a period, so the joined text reads `…Platforms).; no claude binary …`.
## 5. WSL findings (block 3.4)
Run on 2026-10-16, 21:45–21:56, on helper commit `4d8af02` (after 3.1–3.3), from a copy on ext4 (`~/gremlins-wsl-check`). Raw logs were in the session scratchpad (`…\scratchpad\gremlins-wsl\`: `where-*.json`, `suite.txt`, `matrix-gate.txt`, `zombie_probe.py`) and are not in git; the run log's Block 3.4 note is the record.
**Inventory.**
| Item | Value |
|---|---|
| Distro | registered `Ubuntu-20.04`, actually **Ubuntu 22.04.5 LTS** (upgraded in place) |
| Kernel | 6.18.54.1-microsoft-standard-WSL2; `/proc` present; `osrelease` contains `microsoft` |
| `WSL_DISTRO_NAME` | `Ubuntu-20.04` |
| uv | 0.6.8, already at `~/.local/bin/uv` (nothing installed) |
| Python | system `python3` 3.10.12; `uv run` used a managed CPython 3.13.2 |
| git | 2.34.1 |
**PATH interop.**
- 73 PATH entries under `/mnt/` (the Windows PATH appended; no `/etc/wsl.conf`).
- `claude` (no extension): **not found**. `claude.exe` resolves to `/mnt/c/Users/g4b/.local/bin/claude.exe` (the Windows binary, 2.1.283). `…/AppData/Roaming/npm` is on PATH but holds no `claude` shim.
- So `where` without `--binary` reports "no claude binary" on this host. With `--binary /mnt/c/…/claude.exe` and the override, `where` is `ok: true`: a Windows binary under a Linux helper (§4.12).
- `CLAUDE_CODE_EXECPATH` does not cross (unset in WSL; `WSLENV` empty).
**`where` outputs.**
| Case | Exit | Shape |
|---|---|---|
| gate closed, no binary | 2 | `ok: false`; `platform {name: posix, sys_platform: linux, wsl: true, supported: false, detach: setsid, override: false}`; `reason` = the gate reason, then "no claude binary …" |
| override, no binary | 2 | as above without the gate reason; `override: true`; the override warning |
| override, `--binary` the Windows `claude.exe` | 0 | `ok: true`, the override warning |
| from the drvfs path (`/mnt/g/…/gremlins.py`) | same | `script` is the `/mnt/g/…` path |
`python.supervisor_interpreter` resolved to the uv-managed base interpreter.
**Unit suite** (override set by `Base.setUp`): `Ran 165 tests in 393.103s`, **FAILED (failures=9, skipped=3)**.
| Class | Result on WSL |
|---|---|
| `PathTests`, `PathMatrixTests` | pass (`PathTests.test_state_dir_windows_forms` skipped) |
| `GateTests` | 1 failure: `test_closed_gate_on_existing_registry` (cause 2) |
| `LaunchTests` | 7 failures: `test_kill_tree`, `test_refusals`, `test_expire_kills_running_jobs`, `test_lost`, `test_orphan_watchdog`, `test_rebase_counts_toward_cap` (cause 1); `test_timeout_and_stalled` (cause 4); plus `test_delta_tokens_counts_only_what_the_base_does_not_know` (cause 3); `test_no_visible_window` skipped |
| `SubprocessTests` | `test_where_via_uv_host_binary` skipped (no host `claude`) |
| every other class | pass |
`PathMatrixTests GateTests PathTests` alone: 23 tests, 1 failure (cause 2), 1 skip.
**Failure causes.**
1. **A zombie supervisor counts as alive** (6 tests). §4.4. A POSIX item for the porter, not fixed.
2. **`GateTests.test_closed_gate_on_existing_registry`**: its "gate open" setup ran without the override on a non-Windows host. A portable test bug: **fixed in block 3.4a** (`2257e89`; the setup `init` runs under the override env).
3. **`test_delta_tokens_counts_only_what_the_base_does_not_know`**: `trained_at` has whole seconds, and a fast host put the note and the rebase end in the same second. A timing assumption: **fixed in block 3.4a** (mtimes and `trained_at` set explicitly). Both 3.4a fixes passed on WSL afterwards (`Ran 9 tests … OK`).
4. **`test_timeout_and_stalled`** expects a tree of at least 3 (§4.5). A porter's item.
So on a Linux host like this one, expect **7 failures** from the current branch (causes 1 and 4) until you fix them.
**Traps found.**
- From Git Bash, `wsl -d X -- bash -lc '…'` expands `$?` in an outer shell, so every exit code read 0. `wsl -e bash -lc '…'` gives the real codes. Prefix every `wsl` call from Git Bash with `MSYS_NO_PATHCONV=1`.
- Never `wsl --shutdown` or `--terminate` on a shared host.
- Leftovers after the run: none (`ps` by args; no `/tmp/gremlins-test-*`).
## 6. What to test
On each platform, in this order. Record each item in your run log as passed, failed (with the fix), or not run (with the reason).
1. **G-where** with the gate closed: exit 2, the right `sys_platform`, `wsl: false` on native Linux and macOS, the binary block filled with your native `claude`, and `scrub.unknown` read (§4.10).
2. **`PathMatrixTests` and `GateTests`** alone, then **the full suite** to a log file. Linux: expect the 7 known failures (§5) until fixed. macOS: expect more (everything that needs a process table or creation times). Fix, then rerun.
3. **The skill load** with the gate closed (§4.14).
4. **The live smoke** in a scratch clone (§7), with the override set.
5. **`kill` with a hanging gremlin and a grandchild** (§7 step 6). Record every pid with its start time before the kill (`ps -o pid=,lstart=,args= -p <pid>`) and check each pid **and** start time afterwards. Never check by process name.
6. **The orphan watchdog** (§7 step 13): a gremlin with no orchestrator call for longer than `orphan_after` ends `killed`/`orphaned` and its tree is gone.
7. **`lost`** (§7 step 14): kill the supervisor by hand; after `LOST_AFTER_S` (120 s) `list` reports `lost_now` and kills the leftover `claude` tree.
8. **Supervisor survival** (§7 step 15) across (a) the launching Bash call returning, (b) that call being stopped (`TaskStop`), and (c) the end of the Igor's turn.
9. **No stray windows.** Linux desktop: no terminal window opens during a launch. macOS: no Terminal window. None is expected (no console is created), but look once during step 6. `test_no_visible_window` skips on POSIX (`visible_windows()` is `None`).
10. **Zombies**: after each kill, `ps -o pid,stat,args -p <supervisor pid>` must not show a lingering `Z` whose parent is a long-running process.
11. **The transcript slug** (§4.13).
12. **Leftover processes** after the suite and after the smoke, by command line: `pgrep -af "gremlins-test-|_supervise|claude-stub|gremlins-smoke"` gives nothing.
## 7. Smoke recipe
The Phase 1 and Phase 2 smokes under the new names, plus the platform steps. Run it from the Igor's own Bash tool.
- **Every `wait`, `kill` and `expire` call goes through the Bash tool with `timeout: 600000`.** The Bash tool backgrounds a call that passes its timeout instead of killing it, and you lose the output. `wait` blocks up to 570 s; `kill` verifies for up to 10 s per job; `expire` kills its jobs one after another.
- Loop `wait` until exit 0 or 4. Exit 3 means "call again", and `done` may already hold results.
- Keep calling `wait` (or `list`) while a gremlin runs: the orphan watchdog kills a job after `orphan_after` (1200 s) without an orchestrator call, and `kill` and `expire` do not count.
**Setup** (the shell state does not persist between Bash calls, so repeat the variable lines in each call, or put them in a small `env.sh` in the scratch dir and source it):
```bash
export SPIRE_GREMLIN_UNSUPPORTED_OK=1 # only until the gate is lifted in code
CO=/path/to/your/headless-forks/checkout
G="uv run $CO/plugins/spire/skills/gremlins/gremlins.py"
S=/tmp/gremlins-smoke-<stamp> # scratch, outside the checkout
R="$S/smoke-run.md" # run log; the state dir is $S/smoke-run-gremlins
mkdir -p "$S" && git clone --local "$CO" "$S/clone" && : > "$R"
```
Write the briefs as files in `$S` (`brief-*.md`). Use a full model id your settings admit; the earlier smokes used `claude-opus-5-5`.
| # | Command | Must show |
|---|---|---|
| 1 | `$G where` | exit 0; `platform {name: posix, sys_platform: linux\|darwin, wsl: false, supported: false, override: true}`; one warning (the override); `binary` = your native `claude` with a version |
| 2 | `$G init --run "$R" --model claude-opus-5-5 --cwd "$S/clone"` | exit 0; `created: true`; `config.binary_resolved_by` `CLAUDE_CODE_EXECPATH` or `PATH`; `warnings: []`; state dir with `registry.json` and `.gitignore` |
| 3 | `$G train --run "$R" --base B1 --phase 1 --brief "$S/brief-train.md"`, then `$G wait --run "$R" --all` | `{job: j0001, base: B1, session_id}`; `wait` exit 0, `status: ok`; `list`: B1 `active`, base context about 20–30k tokens (Windows: 23,676) |
| 4 | `$G spawn --run "$R" --base B1 --block P1 --phase 1 --brief "$S/brief-p1.md"`, then `wait --all` | launch return `method: "fork"`, `base_state: warm`, `rebase_due: false`; result `ok`, `cache: hit`, `fingerprint: ok`, `models_used` = the model, `note: notes/P1.md`; a new commit in the clone (`git -C "$S/clone" log --oneline -1`) |
| 5 | `$G continue --run "$R" --job j0002 --brief "$S/brief-cont.md"`, then `wait --all` | `ok`, `kind: continue`, `method: null`, `cache: hit`, a second commit. `cost_delta_usd` = the raw `total_cost_usd` difference |
| 6 | `$G spawn … --block K1 --phase 1 --brief "$S/brief-k1.md"`; `$G wait --run "$R" <job> --timeout 60` until `running[0].messages` > 0 and the sleeps run; record the tree (pids + start times; see below); `$G kill --run "$R" --job <job>`; `wait --all` | `wait` exit 3 while it runs; `kill`: `ok: true`, `status: killed`, `survivors: []`, `snapshot` holds the supervisor, `claude`, the shell and **both** sleeps; afterwards every recorded pid + start time is gone; `wait` exit 4, `killed`, `reason: kill`, tokens and cost `null`. macOS before §4.2: expect the sleeps to survive (record it; it is the bug) |
| 7 | `$G spawn … --block P2a --phase 2 --brief "$S/brief-p2a.md"`, then `wait --all` | launch return `rebase_due: true`, `reasons: ["phase_boundary"]`; result `ok`, `hit` (the flags are advisory) |
| 8 | `$G rebase --run "$R" --base B1 --as B2 --phase 2 --brief "$S/brief-rebase.md" --delta-note "$S/smoke-run-gremlins/notes/P1.md"`, then `wait --all` | `{job, base: B2, parent: B1, session_id}`; result `ok`, `kind: rebase`; `list`: B1 `superseded` (`superseded_by: B2`), B2 `active`, `parent: B1`, `lineage_rebases: 1`, context a little larger than B1's |
| 9 | `$G spawn … --base B2 --block P2b --phase 2 --brief "$S/brief-p2b.md"`, then `wait --all` | launch return `rebase_due: false`; result `ok`, `hit` against **B2's** context (first request's `cache_read_input_tokens` ≈ B2's `context_tokens`; Windows: 25,277 of 25,279); a commit |
| 10 | optional, about 35 min: `$G init --run "$R" --warm-margin-s 1800`; then `$G wait --run "$R" --all --timeout 30` every 5 min or less | `changed_fields: ["warm_margin_s"]` (at a 1 h TTL a margin above 1800 is capped to 1800; 3600 or more is refused); about 30 min after B2's last hit a `wait` tick launches a ping (`list`: kind `ping`, B2 `expiring`); the next `wait` returns it in `pings`, not `done`: `ok`, `hit`; B2 `warm` again. Skip if time is short and say so |
| 11 | `$G usage --run "$R" --phase 1 --format md`, `--phase 2 --format md`, `--block P1 --format md`, `$G usage --run "$R"` | exit 0 each; phase 1 table has `train`, `spawn`, `continue` rows and a total; cache-hit ratio `n/n`; the priced total; the P1 row's Commit cell is the real commit; json `reconciled: []`; `ledger.jsonl` has one line per `results/*.json` |
| 12 | `$G list --run "$R"`, then the `pgrep` of §6 item 12 | nothing running except at most a ping; no process left |
| 13 | orphan: `$G init --run "$R" --orphan-after 90`; spawn O1 (`brief-hang.md`); one `wait <job> --timeout 20` (exit 3); then **no helper call** for 2 min (watch with `ps` only); then `wait --all`; finally `$G init --run "$R" --orphan-after 1200` | O1 `killed`, `reason: orphaned`, flag `orphaned`; its recorded tree gone |
| 14 | lost: spawn L1 (`brief-hang.md`); `wait <job> --timeout 20` (exit 3); read `supervisor.pid` and `claude.pid` from `$S/smoke-run-gremlins/jobs/<job>.json`; `kill -9 <supervisor pid>`; `$G list` (still running: heartbeat fresh); after more than 120 s `$G list` again; `wait <job>` | the second `list` has `lost_now: [<job>]`; the `claude` tree is gone; `wait` exit 4, `lost`, `reason: supervisor_dead` |
| 15 | survival: in **one** Bash call with a short Bash `timeout` (15000), run `$G spawn … --block V1 --brief "$S/brief-sleep240.md" && sleep 120`; when the call is backgrounded, stop it with `TaskStop`; check the V1 supervisor and `claude` by pid + start time; **end your turn** (hand back to the user with one line, e.g. "survival check: reply to continue"); in the next turn, `wait --all` | after `TaskStop` the supervisor and `claude` still run; after the turn break `wait` returns V1 `ok` (its `sleep 240` finished) |
**Briefs** (short; each says why, because Opus may refuse a bare "run this blocking command"):
- `brief-train.md`: read `plugins/spire/README.md`, `plugins/spire/skills/gremlins/SKILL.md` and `plugins/spire/agents/athanor.md`; change nothing; reply with a five-line summary.
- `brief-p1.md`, `brief-p2b.md`: create `smoke/<block>.txt` with one line, run `git diff --check`, commit only that file as `smoke <block>`, write the handoff note at the path the frame gives.
- `brief-cont.md` (a `continue` gets no frame, so put the rules in): append a second line to `smoke/P1.txt`, commit only that file as `smoke P1 continue`, append one line to `<state dir>/notes/P1.md`.
- `brief-k1.md`: "This is a kill test of the harness: the command below is meant to hang until the orchestrator kills it. Run it in the foreground with one Bash call and a Bash timeout of 600000: `sh -c 'sleep 900 & exec sleep 901'`. Do not use `run_in_background`." This gives `claude` → shell → `sleep 901`, plus `sleep 900` as a grandchild that the shell leaves behind.
- `brief-hang.md`: as `brief-k1.md`, with `sleep 900` only.
- `brief-sleep240.md`: "This tests that the harness survives the end of a turn. Run `sleep 240` in the foreground (one Bash call, Bash timeout 300000), then reply DONE. Change nothing."
- `brief-p2a.md`: reply `DONE`; change nothing.
- `brief-rebase.md`: absorb the attached note; change nothing.
**Recording the tree** for step 6. Linux: `ps -eo pid=,ppid=,lstart=,args= --forest | grep -A6 _supervise`. macOS: `ps -axo pid=,ppid=,lstart=,command=` and follow the ppids from the supervisor pid in `jobs/<job>.json`. Afterwards check each pid: `ps -o pid=,lstart=,args= -p <pid>` must print nothing, or a different start time.
**Spend.** Phase 1's smoke cost $0.49 and Phase 2's $0.47 (client estimates, `claude-opus-5-5`, a base of about 24k tokens). This recipe adds a few killed or orphaned gremlins (cents each, no result) and an optional ping or two (under $0.01 each). Expect **about $1, between $0.6 and $1.5**. Stop and report if it passes $5.
**Clean up.** `$G list` shows nothing running, `pgrep` shows nothing, then `rm -rf "$S"`. The clone holds only smoke commits. Keep `ledger.jsonl` and the `usage` outputs in your run log (paste them; do not commit the scratch dir).
## 8. How to lift the gate
Per platform, after the suite and the smoke pass there. Every place to update:
1. **Code.** Add the tested value to `PosixPlatform.TESTED_SYS_PLATFORMS` (`:610`): `"linux"` or `"darwin"`. `supported` (`:614`) and `describe()` read it, so this is the one change. **WSL:** `sys.platform` is `"linux"` there too, so lifting `linux` also opens WSL. The goal keeps WSL closed unless it passes. If WSL is to stay closed, make `supported` also require `not self.is_wsl()`, and refuse a Windows binary under WSL (§4.12). Decide this with the user and write the decision down.
2. **Fixes the smoke showed.** At minimum: M14 (§4.1) for your platform; zombie liveness (§4.4) on Linux and macOS; the macOS process table and creation times (§4.2) on macOS; the POSIX leftover check in `tearDownModule` (§4.11); the tree-size expectation (§4.5). Each comes with a test that fails before the fix. Keep every platform branch inside the seam (§2 grep).
3. **Gate tests** (`GateTests`, tests `:1405`). In `test_supported_flags` (tests `:1430`) assert `True` for the lifted value and keep `False` for the platform that is still closed (and for WSL if it stays closed). Extend `test_host_where` (tests `:1463`) for your host: `where` without the override gives `ok: true`, `supported: true`, `override: false`, `warnings: []`. The fake-platform tests stay as they are.
4. **`plugins/spire/skills/gremlins/SKILL.md`**: the frontmatter `description` ("Windows-tested; other platforms fail closed until tested (see Platforms)") and the Platforms section (line 22, which names Linux, WSL and macOS as closed).
5. **The README row** for `gremlins` in `plugins/spire/README.md` (added in block 3.8, same wording as the description).
6. **`plugins/spire/agents/athanor.md`**, the fallback line (line 34, "`where` not ok (Windows-tested; other platforms fail closed until tested …)"), if its wording changes.
7. Also check `plugins/spire/skills/implementation/SKILL.md` line 49 ("It is Windows-tested; other platforms fail closed until tested …") and `plugins/spire/skills/implementation/references/spawning-claude-code.md` line 75 ("Tested on Windows with `claude` 2.1.283 and 2.1.299"). Add your platform and `claude` version there, as an `observed <date>` row where it is a fact about the harness (the file's status vocabulary: `verified`, `observed`, `assumed`, `unknown`, each with a date).
8. **This report.** Mark your platform done in §1 and add what you found under §4 and §5 (or append a dated section for your platform).
9. **The changelog.** There is no CHANGELOG file; the changelog is the body of the release commit (block 3.8, in the style of `7c1ed39`). Its line "tested on Windows; Linux and macOS gated pending the platform handoff" goes stale when you lift a gate. Do not rewrite that commit: state the newly tested platform in your lift commit's body, and make sure the commit that finally merges or releases the branch carries the corrected platform line.
10. **Run the full suite and the smoke again** with the gate lifted and **without** the override in the environment. Record both in your run log.
11. **Do not merge.** The branch is merged only when both Linux and macOS are done (or the user decides otherwise). Use no version number in any platform wording (digest amendment A7): write "tested on Windows and Linux", never a Spire version next to a platform.
## 9. Not covered
- **Docker.** It is available on the dev host and was not used. A Linux container is closer to native Linux than WSL, but it is not a desktop host (no Claude Code app env, no supervisor survival across a real turn).
- **The Rust follow-up.** The user suggested picking up the helper as Rust work as well (goal "Amended … release gating"; `docs/workpad/reports/2026-10-16-01-session-harness-app-forms.md`). It has not been started; nothing here depends on it.
- **The Windows live smoke under the new names** (Phase 4, digest C17). It is separate from yours, and it is also required before anything ships.
- **Codex.** The skill does not apply there.
- **Large bases** through the helper (the smokes used a base of about 24k tokens; P0.2's 182k base was a hand-run probe), and the live partial-ping case (P0.6).