🤖 forks: launch, supervise, wait, kill (block 1.2)
This commit is contained in:
@@ -96,3 +96,41 @@ Gate after 1.1 (Athanor, 12:29): G-unit `Ran 52 tests … OK`, exit 0; G-where e
|
||||
- Left for later: everything the digest assigns to 1.2 (templates, launch, supervise, results, ledger lines, `wait`/`list`/`expire`/`kill`, no-visible-window test, `run.last_orchestrator_call_at`); Phase 2 commands.
|
||||
- Expected broken: `train`, `fork`, `continue`, `wait`, `list`, `expire`, `kill`, `_supervise` exit 2 "not implemented" (block 1.2); `rebase`, `usage`, `tick` (Phase 2).
|
||||
- Commits: `d36ac2f` (code, tests, preamble, this note), then a one-line follow-up filling in this hash.
|
||||
|
||||
### Block 1.2 — Launch and supervise
|
||||
|
||||
- Model: `claude-opus-5-5[1m]`
|
||||
- Start / end: Fri Oct 16 12:31:01 WEDT 2026 / Fri Oct 16 15:48:45 WEDT 2026 (`date`; the host clock read 12:31 at my first call and 15:42 at the first manual run, so the span includes a jump like the one Phase 0 saw; the gates ran 15:47:38–15:48:30)
|
||||
- Done: `forks.py` gains `train`, `fork`, `continue`, `wait`, `list`, `expire`, `kill`, `_supervise`. Launch writes the job (registry index, status `created`) under the lock, `briefs/<job>.md` (template + brief) and `raw/<job>.env.json` (scrubbed env with `CLAUDE_CODE_EXECPATH` = stored binary and `SPIRE_FORKS_CHILD=1`), then spawns `<base interp> -I forks.py _supervise --run --job` through `powershell` (`CREATE_NO_WINDOW`) → CIM `Win32_Process.Create` with `ShowWindow=0`, command line and cwd passed in `SPIRE_FORKS_CMD`/`SPIRE_FORKS_CWD`; `ReturnValue` ≠ 0 ends the job `failed`/`spawn_failed`. The supervisor redirects its stdio to `raw/<job>.sup.log`, starts `claude` with exactly the env file and `CREATE_NO_WINDOW` (stored absolute binary path, full fingerprinted command line every call, `--session-id` on train and fork, `--resume … --fork-session` on fork, `--resume` only on continue, `--max-budget-usd` passed through), feeds the prompt on stdin, parses the stream (`StreamState`: init, message ids, first/last assistant usage, `system/informational` restriction warning, result), and is the only writer of `jobs/<job>.json` (heartbeat every 10 s). Watchdogs: `max_minutes` → kill tree, `timeout`; `stall_after_s` of no stdout → flag `stalled`; N1 orphan check every 1 s → kill tree, `killed` + `orphaned`; restriction warning → kill tree, `failed`/`model_refused`. Finalising (`finalize_job`, once, under the lock): `results/<job>.json` (plan §4.2 fields + `paths`, `reason`, `model_requested`), ledger line (design §6 + `status`, `flags`, `reason`), registry update (status, cumulative cost and `modelUsage` for the session, base `last_hit`; on train: base `active` with `base_commit`, context tokens, training cost, cumulative `modelUsage`, runtime fingerprint). Usage rule, `usage_mismatch`, cache verdict, `cold_start`, C1 warmth, fingerprint `ok|drift:<fields>` (launch: `binary`, `append_system_prompt`, `agent`; runtime: `tools`, `permissionMode`, `plugins`, `agents`, `apiKeySource` against the base's init). `wait` per C4, `list` (all bases with warmth, all jobs, running info), `lost` reaper (heartbeat > 120 s and supervisor pid + ctime dead), `kill` (Toolhelp32 tree snapshot with ctimes → `taskkill /T /F` → sweep with `TerminateProcess` by pid + ctime → verify up to 10 s), `expire` (retire, kill running jobs of the base, flag `expired`). Refusals: N2, N3, cap, launch-field overrides on fork/continue (each field accepted by the parser only to be refused), base not active without `--force`, continue of a running or non-fork job, missing binary (`reason: binary_missing`), base name taken. New `templates/train.md` and `templates/fork.md` (stale-snapshot sentence, delta header from `git --no-optional-locks log --oneline <base_commit>..HEAD` and `diff --shortstat <base_commit>`, commit rule per mode, note path, logs dir). `preamble.md` unchanged. Tests: 17 new (69 total): a generated stream stub `claude` (`stub/claude.cmd` → base interpreter `-I stub.py`, behaviour per call from `@stub …` lines in the brief, sessions kept beside it so resume/fork report cumulative `modelUsage` and `total_cost_usd`), real CIM launches with the stub as `--binary`, `RuleTests` on the real P0.2 seq-1 numbers, and a module-end check that no process whose command line contains the tests' temp prefix survives.
|
||||
- Gates:
|
||||
- G-unit `uv run plugins/spire/skills/forks/tests/test_forks.py` → exit 0, `Ran 69 tests in 52.001s` / `OK` (suite wall 52 s; two earlier full runs at 68 tests: 51.7 s each, OK).
|
||||
- G-where `uv run plugins/spire/skills/forks/forks.py where` → exit 0, stdout one line, a JSON object, `ok: true`, `spawn: cim-hidden`, binary 2.1.299.
|
||||
- Leftover processes after the gates: none. Checked by command line (`forks-test-`, `_supervise`, my manual scratch run `b12-manual`) via `Get-CimInstance Win32_Process`, not by name; also asserted by the suite's `tearDownModule`.
|
||||
- Manual check (scratch run in the session scratchpad, stub binary): the supervisor's parent is `WmiPrvSE.exe`; its tree is python → conhost, cmd (claude) → conhost, python stub → python grandchild; `kill` snapshot 6 pids, `taskkill_rc` 0, no survivors.
|
||||
- Deviations from plan:
|
||||
- `fork --max-minutes` and `continue --max-minutes` take a positive float (the tests use 0.06 min); `init --max-minutes` stays an integer.
|
||||
- `fork`/`continue` also run the `lost` reaper before the cap check (the digest names `wait` and `list`), so a dead supervisor cannot block the cap.
|
||||
- The `lost` reaper kills the job's leftover `claude` tree (from the job file) before marking it `lost`; the digest only says to mark it.
|
||||
- On the restriction warning the supervisor kills `claude` at once instead of letting the substituted model finish (saves the spend; the result still reads `failed`/`model_refused`).
|
||||
- After `claude` exits, the supervisor sweeps any descendants of it that are still alive (the "backgrounded `sleep 240`" trap); logged in `sup.log`.
|
||||
- `train` takes no launch-field overrides (the design's table allows them; the plan's invariant and the digest do not); they are usage errors there.
|
||||
- Flagged for review (semantics the digest left open):
|
||||
- `wait --all` = every job not yet *settled*; a job is settled when it was in the condition of a `wait` that returned 0 or 4. `done` holds only results not delivered before. So the mixed case gives 3 (failed result in `done`), then 4 when the rest finish (the failed job stays in the condition without being returned again), then 0 on an empty set. Explicit job lists use the same delivery marks. `--any`: met when any member has finished (or the set is empty); exit 4 if any finished member is not `ok`. `wait` with neither jobs nor `--all` is a usage error; `--timeout` > 570 is refused (exit 2).
|
||||
- `reason` vocabulary: `model_refused`, `is_error`, `exit_code`, `no_result`, `spawn_failed`, `launch_error`, `supervisor_error` (failed); `kill`, `expire`, `orphaned` (killed); `supervisor_dead` (lost). `timeout` has `reason: null` (the digest: null unless failed/killed/lost). Details go to the registry's `reason_detail` (shown by `list`), not into the result or ledger.
|
||||
- Precedence when deciding a finished call: model refused > no result (`exit_code` if non-zero, else `no_result`) > `is_error` > non-zero exit. A model is "refused" when the warning appeared or the requested id (with `[1m]`) is not among the `modelUsage` keys that grew; extra models do not count.
|
||||
- Without a `result` event (killed, lost, died): `tokens` are all `null`, `cost_delta_usd` `null`, `models_used` `[]`, `turns` = distinct assistant message ids seen; cache verdict still from the first assistant event if one arrived.
|
||||
- `last_hit` for a train is the time of its **last** assistant event (the full prefix is written there); for fork/continue the first, written to the base as soon as it arrives. A continue refreshes its base's `last_hit`.
|
||||
- `base_state` is `null` for train jobs; `cold_start` is only computed for fork/continue (a train is always cold).
|
||||
- `continue` sends the brief as given (no template; there is no `templates/continue.md`). Its `parent_session` is the fork's parent (the base session); its cost and `modelUsage` "before" are the fork session's last cumulative totals (updated by every continue), falling back to the base's when the fork has none (a killed fork: the delta then includes the killed fork's spend).
|
||||
- `note` = `notes/<block>.md` (block sanitised) if that file exists when the job ends, else `null`; `fork.md` tells the fork that path. Train jobs have `note: null`.
|
||||
- A failed training leaves the base `failed`; `train` may reuse a name only when that base is `failed`.
|
||||
- `kill` of a job that is no longer running exits 0 with `killed: false` and its status; survivors after the 10 s sweep give `ok: false` (exit still 0).
|
||||
- A `kill` that lands while the supervisor holds the registry lock to finalise leaves a stale lock (broken after 30 s) and, in a narrow window, a duplicate ledger line. `kill` marks `kill_requested` first; a supervisor that finalises after that records `killed` unless the call ended `ok`.
|
||||
- `wait` updates `last_orchestrator_call_at` at its start and end only (a 570 s wait stays under the 1200 s `orphan_after`).
|
||||
- Stall is measured on any stdout line, not only parsed JSON events.
|
||||
- `process_tree` follows parent pids through dead processes (so orphans of a dead `claude` are found) but stops at a node whose pid now belongs to a process with another creation time; this reuse guard is not exercised by a test.
|
||||
- `spawn_failed` shows up in `wait`, not in the launch return (the launch return is C1's fixed shape).
|
||||
- Not tested: the POSIX path (`setsid` spawn, process-group kill, `/proc` tree) — written, never run; `--agent` drift (only the code path, no test); a real `claude` (no test spends tokens; Phase 1.3 smoke); supervisor survival across the end of the orchestrator's turn (1.3).
|
||||
- Left for later: warmth inference, pings, `rebase`, `rebase_due`/`retrain_due` reasons, `usage`, `tick` (Phase 2); `SKILL.md` (Phase 3).
|
||||
- Expected broken: `rebase`, `usage`, `tick` exit 2 "not implemented" (Phase 2).
|
||||
- Commits: see the follow-up line below.
|
||||
|
||||
Reference in New Issue
Block a user