diff --git a/docs/workpad/reports/2026-10-16-08-headless-forks-run.md b/docs/workpad/reports/2026-10-16-08-headless-forks-run.md index de017c8..2ca1eb6 100644 --- a/docs/workpad/reports/2026-10-16-08-headless-forks-run.md +++ b/docs/workpad/reports/2026-10-16-08-headless-forks-run.md @@ -258,3 +258,53 @@ Deviation from the build plan's block table: M8, M16 and the billed-error test g | Block | Owner (model id) | Commit | Tokens (if known) | |---|---|---|---| + +### Block 2.1 — Warmth and keep-warm + +- Model: `claude-opus-5-5[1m]` +- Start / end: Fri Oct 16 16:42:41 WEDT 2026 / Fri Oct 16 16:57:23 WEDT 2026 (`date`; the end is the code commit, this note followed) +- Done (`forks.py`, `tests/test_forks.py`): + - **TTL inference** (`infer_ttl`): after every call on a base that has a `result` (train, fork, continue, ping; failed calls that billed too), `base.ttl_s` = 3600 if it wrote `ephemeral_1h`, 300 if it wrote only `ephemeral_5m`, else the previous known value; `base.ttl_from` = that job. With nothing known, warmth uses the config's `ttl_s`. **`ttl_downgrade`** on a result whose call wrote 5m and no 1h tokens (any kind). + - **Warmth** (`base_warmth`): ttl = the base's inferred TTL, else config; margin = `init --warm-margin-s` when set (new config key `warm_margin_s`, default `null`), else `max(120, ttl/10)`. Output gains `ttl_s`, `margin_s` (so `list` base rows show them; `fork`'s launch return keeps its fixed shape). `list` base rows also show `ttl_from`, `ping_in_flight`. `cold_start` (C5) now follows the inferred TTL through `base_state_at_launch`. + - **Ping rule** (`ping_decision`): `pings` on → base `active` with a session → state `expiring` → no ping in flight → activity → `pings_since_real_call < break_even`; the first failing condition is the `why` (`pings_off`, `status:`, `state:`, `in_flight:`, `no_activity`, `cap:/`). `break_even` in integer hundredths: 19 at 1 h, 11 at 5 min (floats give 18 for 1.9/0.1). + - **Pings** (`tick_pass`): kind `ping`, own job id, `--resume --fork-session --session-id --max-turns 1`, prompt `PING_PROMPT` = `Reply OK`, launch fields from `launch_record(base, cfg)`, the same CIM hidden spawn, result and ledger line through the normal finaliser. Cache verdict as a fork's; `ok` + `hit`/`partial` refreshes `last_hit` to the ping's first assistant event (at finalise); a `miss`, a failed/killed/lost ping, or no assistant event adds `ping_missed` and leaves `last_hit`. Not in the cap (`_running_capped` already counts only fork/continue). + - **C3(d)**: the decision, the job entry and `base.ping_in_flight = ` are written in one locked registry write; the finaliser clears the mark (any status); a mark whose job is no longer running does not block. + - **Tick**: in every supervisor every `TICK_SUP_S` = 60 s (first after 60 s); in `wait` at its start and every `TICK_WAIT_S` = 30 s (skipped inside a fork); `tick --run R` by hand (new command, N3-refused: `tick` added to `CHILD_REFUSED`; out of the not-implemented set). Tick failures inside `wait`/the supervisor are logged, never raised (`safe_tick`). + - **`wait`**: stamps `run.last_wait_at` (start and end; not from a fork); `--all` excludes pings; naming a ping job is exit 2; `pings` = every finished ping result of the run not returned before (each once, also on exit 3); pings never enter `done` or the exit code. + - **M16**: the job file's progress counter is `messages`; `wait`'s `running[]` entries and `list`'s running job rows carry `messages` too. Result, ledger and registry `turns` stay `num_turns`. + - **M8**: new `test_no_visible_window` (Windows; skipped when `EnumWindows` sees no visible window): positive control = a `CREATE_NEW_CONSOLE` python with `SW_SHOWMINNOACTIVE` and a marker title, detected as a new `ConsoleWindowClass`/`CASCADIA_HOSTING_WINDOW_CLASS` window (or one with the marker title), then killed at once and its window checked gone; then, during a hanging fork with a grandchild, 5 samples of (a) no visible window owned by any pid of the job's tree, (b) no console/Terminal window that was not there before the launch. The old window lines left `test_kill_tree_and_no_visible_window`, now `test_kill_tree` (its kill and CIM-parent assertions unchanged). + - **Billed error**: stub directive `billed` (with `error` or `apierr=`: the requested model bills its turns, then the result is an error); `test_error_after_requested_model_billed`: `failed`/`is_error` and `failed`/`api_error`, `models_used` = the requested id, cost Δ > 0, never `model_refused`. + - Stub also honours `--max-turns` and a `ttl=5m` directive (writes land in `ephemeral_5m`). + - Tests: 16 new (102 total). `WarmthTests` (pure, injected clock): boundaries at 1 h and 5 min (inferred and configured), `--warm-margin-s` override at both TTLs; TTL inference 1h-only, 5m-only, both, read-only (prev 3600 / 300 / none), nothing; `ttl_downgrade`; `break_even`; eligibility per state, status, `pings off`; activity window (wait 599/601 s, running fork/continue, ended 590/610 s, train/ping do not count); caps 19 and 11 (failed pings count, other bases' do not, a real call resets); in flight and stale in-flight; pings not in the cap. `TickTests` (real state dir and lock, spawn and binary check stubbed): two concurrent tickers that both judged the base eligible launch exactly one ping (barrier inside the binary check), third ticker skips, finalise clears the mark, next tick pings again; `tick` command JSON and N3 refusal; nothing-to-do pass takes no lock; binary missing skips. `LaunchTests`: M8, billed error, TTL downgrade + `cold_start` against the inferred 300 s + back to 3600, ping end to end (launched by `wait`'s tick, returned once in `pings`, argv, `partial` refreshes; `tick` by hand: `miss` and failed ping → `ping_missed`, `last_hit` unchanged; mark cleared; three ledger lines), tick cadence in `wait` (≥ 3 calls, none in a fork) and in an in-process supervisor (≥ 3 calls; job file has `messages`, not `turns`). +- Gates: + - G-unit `uv run plugins/spire/skills/forks/tests/test_forks.py` → exit 0, `Ran 102 tests in 86.227s` / `OK` (16:53:54); second run exit 0, `Ran 102 tests in 84.680s` / `OK` (16:55:42–16:57:07). No new test skipped on this host (M8 ran). + - G-where `uv run plugins/spire/skills/forks/forks.py where` → exit 0, one line, one JSON object, `ok: true`, binary 2.1.299, `warnings: []`. + - Leftover processes: none (`Get-CimInstance Win32_Process` by command line: the tests' temp prefix, `_supervise`, `poscontrol`; only the sweep itself matched); also the suite's `tearDownModule`. + - No live `claude` calls (stub only). +- Existing tests changed (none loosened): + - `CliTests.test_phase2_not_implemented`: `tick` removed (implemented now); `test_commands_need_init` gains `tick`. + - `LaunchTests.test_kill_tree_and_no_visible_window` → `test_kill_tree` without the window block; the window assertions moved, widened, into `test_no_visible_window`; `assertGreater(len(all_windows), 0)` became a skip (M8's specified change). + - `LaunchTests.test_wait_exit_codes`: `running[0]` key set has `messages` instead of `turns` (M16). + - Stub: `error` still bills nothing unless `billed` is given. +- Deviations from plan (digest): + - TTL after a read-only call (or one with no cache activity) keeps the previous known TTL, also when that was 300. The digest's literal rule gives the configured `ttl_s` there (3600). A read of a 5-minute entry keeps it a 5-minute entry, and reporting 3600 would show a 5-minute base as warm for an hour (no ping, an unannounced cold start). With nothing known, the configured `ttl_s` applies, as the digest says. + - M16 goes beyond the job file: `wait`'s `running[]` and `list`'s running rows say `messages`, because they expose the same counter. This changes the Phase 1 digest's `running: [{…, turns}]` shape. + - Pings read launch fields via `launch_record(base, cfg)` = `base.launch` if present, else `cfg.launch`. No base has its own record until 2.2 (M10); `fork`/`continue` still build from `cfg.launch` and are 2.2's to switch. + - `wait` ticks at its start as well as every 30 s, so every `wait` call is a tick point (short waits would otherwise never tick). + - `wait` refuses an explicit ping job id (exit 2) instead of silently routing it. +- Flagged for review (semantics I chose): + - Activity: a fork/continue job running now or ended within 600 s, or `run.last_wait_at` within 600 s. `tick --run` by hand is not activity and does not stamp `last_orchestrator_call_at`. + - "Pings since the last real call" is by job number (launch times have whole seconds); every ping status counts. + - `break_even` uses the 1 h write ratio for any TTL > 300, the 5 min one otherwise. + - Ping job: `PING_MAX_S` = 300 s wall limit, `block`/`phase` null, cost and `modelUsage` basis = the base's cumulative (like a fork), cwd = the launch cwd, `head_at_launch` null. + - The binary check for a ping runs before the lock (subprocess `--version`); a missing binary skips that base with `why: binary_missing` and records no job. A base that turns eligible between the unlocked look and the locked one waits for the next tick. + - The supervisor's tick runs in its own loop, synchronously: a ping launch holds that loop for the CIM spawn (1–3 s). The supervisor's `os.environ` is the child env (`SPIRE_FORKS_CHILD=1`), so `tick_pass` has no N3 check of its own; N3 is enforced by the `tick` command and by `wait` skipping its tick in a fork. + - A supervisor ticks first after 60 s: jobs shorter than that never tick from their supervisor (`wait` covers them). + - `last_hit` from a ping is set at finalise, not as the first event arrives (the refresh depends on the final status and the verdict). + - The TTL is inferred from every call with a `result`, failed ones included; killed/lost calls (no result) leave it. + - M8: the positive control is minimised and not activated (`SW_SHOWMINNOACTIVE`, still `IsWindowVisible`) so it does not take focus. The desktop-wide diff fails the test if the user opens a console or Terminal window during the ~1 s sample window. + - In-process supervisor test prints a `ResourceWarning` for the `claude` stdout pipe that `Supervisor.run` never closes (pre-existing, harmless in the real detached supervisor). +- Not tested: a ping launched by a supervisor's own tick end to end (the supervisor's tick is tested with a recording `tick_pass`; the launch path is the same function, tested from `wait`'s tick and `tick --run`); real `claude` with `--max-turns 1` through the helper (P0.6 used it by hand); POSIX. +- Left for later: `rebase`, `rebase_due`/`retrain_due` reasons, per-base launch records (2.2); `usage` (2.3). +- Expected broken: `rebase`, `usage` exit 2 "not implemented" (2.2, 2.3). +- Commits: `fff43a0` (code and tests), then this note's commit.