📝 Headless forks: block 2.2 handoff note
This commit is contained in:
@@ -311,3 +311,53 @@ Gate after 2.1 (Athanor, 16:59): G-unit exit 0, `Ran 102 tests in 84.544s`, OK;
|
||||
- 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.
|
||||
|
||||
### Block 2.2 — Rebase and re-train flags
|
||||
|
||||
- Model: `claude-opus-5-5[1m]`
|
||||
- Start / end: Fri Oct 16 17:01:30 WEDT 2026 / Fri Oct 16 17:23:20 WEDT 2026 (`date`; the end is the code commit, this note followed)
|
||||
- Done (`forks.py`, `templates/rebase.md` (new), `tests/test_forks.py`):
|
||||
- **`rebase --run R --base B --as NEW --brief FILE [--phase N] [--max-minutes] [--max-budget-usd]`**: a fork of B (`--resume <B session> --fork-session --session-id <new>`, B's launch record), prompt `templates/rebase.md` (absorb the listed notes and diffs, do not change code or commit, update the trunk notes if the brief names a path, reply `READY`) + delta header + brief. Returns `{job, base: NEW, parent: B, session_id}`. On `ok` the finaliser creates NEW (`active`, `parent`, `lineage_rebases` = B's + 1, `base_commit` = HEAD at the end, `context_tokens` from the last assistant message, `launch` = B's record with the binary it ran under, `fingerprint`, `overrides` inherited, `phase` = `--phase`, `last_hit` = the call's last assistant event, `runtime_fingerprint` of the call, cost cumulative, `rebase_job`) and sets B `superseded` (`superseded_by`). Any other status: B stays `active`, NEW is never created. Counts toward `parallel_cap` (`CAPPED_KINDS`), N3-refused, refuses launch-field overrides, a non-active B, an existing (non-failed) NEW, a NEW a running rebase will create, a B already being rebased; `train` also refuses a name a running rebase will create. Cache verdict and `cold_start` as a fork's (against B's context); its first event refreshes B's `last_hit`.
|
||||
- **`rebase_due` reasons** (`due_flags`): `phase_boundary`, `commits:<n>` (`git rev-list --count <base_commit>..HEAD` > `rebase_commits`), `lines:<n>` (`git diff --shortstat <base_commit>` insertions + deletions > `rebase_lines`), `delta_tokens:<n>` ((delta header + listed notes) chars // 4 > `rebase_delta_tokens`), `binary_drift`, `runtime_drift:<fields>`. **`retrain_due` reasons**: `defining:<fields>`, `max_base_tokens:<n>`, `rebases:<n>` (≥ `max_rebases`), `binary_missing`. On `fork`'s launch return (fixed shape, `reasons` = rebase reasons then retrain reasons) and per active base in `list`. `fork`, `continue` and `rebase` refuse `binary_missing` with exit 2 (the refusal carries `reason`, `retrain_due: true`, `reasons: ["binary_missing"]`), `train` refuses with `reason: binary_missing` when its binary is gone, and a tick skips the base (`why: binary_missing`, as in 2.1).
|
||||
- **`init` after bases exist** is allowed (replaces Phase 1's refusal): the config is replaced, the output gains `bases`; a base without its own record (from before 2.2) first gets the old config as its record. This is the input path of `defining:`.
|
||||
- **M10**: `train` takes `--model --effort --permission-mode --tools --allowed-tools --disallowed-tools --agent --cwd --binary` as overrides, validated by the same `build_launch` as `init` (alias refusal, plan refusal, C8 admission, agent file, binary). The base stores `launch` (its record), `fingerprint` (hash of it) and `overrides` (sorted field names given). `fork`, `continue`, `rebase` and pings build their command line, cwd, delta header and `model_requested` from `launch_record(base)`.
|
||||
- **M6**: the runtime fingerprint adds sorted `skills`; a plugin without a version is `name@path:<last path segment>` (`name@` with no path). A reference without `skills` (a pre-M6 base) is not compared on it. Drift shows as `runtime_drift:<fields>`.
|
||||
- **M7**: `fingerprint_hash` hashes `fingerprint_view(launch)`: the mcp entry becomes its content `{"strict": true, "servers": {}}`; the path stays in the config. Same launch → same hash in any run.
|
||||
- **M11**: entries record `created_ts` (precise); a result's `note` is set only when the note's mtime ≥ `created_ts` − 2 s (`NOTE_MTIME_SLACK_S`, for coarse Windows file times).
|
||||
- **M13**: a `/`-rooted path that is not a drive form (`/tmp/x`, `/usr/...`) goes through `cygpath -w` (`find_cygpath()`, patchable); no cygpath or a failing one → exit 2 naming the path. Native `\x` keeps its Windows meaning; `//server/...` is untouched.
|
||||
- Stub: the init event carries a versionless `skill-creator` plugin and `skills`; directives `nover=<segment>`, `skill=<name>`.
|
||||
- Tests: 10 new (112 total). `PathTests`: M13 without cygpath (refusals, drive forms unaffected, `init --run /tmp/...` exit 2 with nothing on the cwd's drive) and with a fake cygpath (called with `-w`, drive forms skip it, failing cygpath refused, the real cygpath on this host does not give `G:\tmp\x`). `InitTests`: M7 (two runs, different mcp paths, equal hash; same hash from a second process; content counts). `RuleTests`: M6 (versionless path segment, skills, order-insensitive, pre-M6 reference), M11 boundaries. `LaunchTests`: every reason alone through `fork`'s launch return (clones of a trained base, spawn stubbed for the fork launches; `runtime_drift` from a real stub fork; `max_base_tokens` from the stored context and from a synthetic transcript with a 64-byte tail window; `binary_missing` refusal) and the base-level ones in `list`; `train` overrides recorded and used by fork and continue, `init` model change → `defining:model` only on the base that did not override it, override validation; M11 end to end (old note, note written during the job, continue); `rebase` end to end (phase boundary → rebase → new base active / old superseded → fork of the new base `hit` against the new context → failed rebase leaves the base active, nothing created → refusals, N3 → binary drift recorded by the next rebase); rebase in the cap, blocking a second rebase and a `train` of its name, killed → base active, nothing created.
|
||||
- Gates:
|
||||
- G-unit `uv run plugins/spire/skills/forks/tests/test_forks.py` → exit 0, `Ran 112 tests in 119.326s` / `OK` (17:18); final run exit 0, `Ran 112 tests in 120.043s` / `OK` (17:21:09–17:23:10), after the `phase_boundary` change below. No test skipped on this host.
|
||||
- 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: []`, `spawn: cim-hidden`.
|
||||
- Leftover processes: none (`Get-CimInstance Win32_Process` by command line: the tests' temp prefix, `_supervise`, `poscontrol`; only the sweep's own shell matched); also the suite's `tearDownModule`.
|
||||
- No live `claude` calls (stub only).
|
||||
- Existing tests changed (none loosened; each is a specified change):
|
||||
- `InitTests.test_changed_values_after_base_refused` → `test_changed_values_after_base_allowed`: the digest lets `init` change config after a base exists, so the exit-2 / "base exists" / bytes-unchanged assertions became: exit 0, `changed`, `launch.effort` in `changed_fields`, `bases == ["B1"]`, config updated, the record-less base got the old config as its record. The "identical values → unchanged" check stays (now also bytes-equal).
|
||||
- `PathTests.test_windows_forms`: the case `/tmp/x → G:\tmp\x` removed (M13 forbids it); the two new M13 tests cover that input.
|
||||
- `CliTests.test_phase2_not_implemented`: `rebase` removed (implemented); `test_commands_need_init` gains `rebase`.
|
||||
- `LaunchTests.test_train_fork_continue_end_to_end`: the note `notes/1.1.md` is now written right after the fork launches (the fork gets `sleep=1`), because M11 counts only notes written during the job; its assertions are unchanged.
|
||||
- `LaunchTests.test_refusals`: the launch-field override loop also covers `rebase`.
|
||||
- Deviations from plan (digest):
|
||||
- The new base always records the binary the rebase ran under (current version/size/mtime). Without drift that equals B's record, so this is the digest's rule written once.
|
||||
- `rebase` returns train's shape plus `parent`.
|
||||
- None against the build plan.
|
||||
- Flagged for review (semantics I chose):
|
||||
- `phase_boundary`: the literal "or" — the fork's phase > the highest phase of the base's forks, **or** > the base's own phase; a side that is unknown does not count; integer phases only. So forks of phase 2 from a base of phase 1 keep reporting it until a rebase. Fork jobs of any status count; continues and rebases do not. A rebased base's phase is `rebase --phase`, else `null` (not inherited).
|
||||
- `delta_tokens`: "notes listed in the brief" = every `*.md` token in the brief that resolves to an existing file (absolute; else under the base's cwd; else under the state dir), each once; the brief itself is not counted. `list` (no brief) counts the delta header only.
|
||||
- `lines` is the working tree against `base_commit` (uncommitted changes count), as `git diff --shortstat <base_commit>` gives.
|
||||
- `runtime_drift`: "the last call on this base" = the highest job number with this base that reported an init event, any kind but `train` (forks, continues, pings, rebases); calls without an init event (killed early, spawn failed) are skipped. Stored per job as `runtime_drift` (null when no init).
|
||||
- `defining:` field names: `model, cwd, tools, permission_mode, agent, append_system_prompt` (the preamble is reported as `append_system_prompt`, as the existing launch drift names it), comma-joined in one reason. Effort, allowed/disallowed tools and binary are not defining (the digest's list). The agent is re-hashed from `<cwd a launch would use>/.claude/agents/<name>.md`; a missing file differs.
|
||||
- `max_base_tokens`: transcript `<~/.claude/projects>/<cwd with non-alphanumerics → '-'>/<session>.jsonl` (`CLAUDE_PROJECTS_DIR` overridable), read from the tail (256 KiB, growing ×4); stored context when absent. Only tested with a synthetic transcript.
|
||||
- `list` computes the flags for `active` bases only (others `false` / `[]`); per active base 4 git calls, plus one `--version` per distinct binary path.
|
||||
- `train`: a field not given comes from the config at train time; the whole record is rebuilt and re-validated even without overrides (C8 admission runs again; the binary's current version/size/mtime are recorded). `--tools` alone does not re-derive `allowed_tools`. There is no way to clear the config's agent on `train`. Train results no longer report `drift:binary` against the init config (the base records the binary it trained with).
|
||||
- A rebase that ends `ok` after its NEW name was taken by another base (only possible through a failed-name `train`) creates nothing, leaves B active and notes `rebase_conflict` on the job entry. Untested.
|
||||
- A rebase's first event refreshes B's `last_hit` also when the rebase then fails (it read B's prefix). NEW's `training_cost_usd` is the rebase's cost delta; its forks' cost basis is the rebase's cumulative total.
|
||||
- Rebase jobs are not "activity" for the ping rule (2.1's rule unchanged: fork/continue running or ended in 10 min, or a `wait`).
|
||||
- M11's 2 s slack: a note written up to 2 s before the job was created still counts.
|
||||
- M7: the hash view uses the constant empty server set (the helper always writes `mcp-empty.json` itself). Registries from before 2.2 get a new `fingerprint` value on the next changed `init`.
|
||||
- M13: paths in a brief that would need cygpath and cannot be converted are skipped by `delta_tokens`, not refused.
|
||||
- Not tested: the `rebase_conflict` path; `defining:` for `agent`, `append_system_prompt`, `cwd`, `tools`, `permission_mode` (only `model` is tested end to end; the comparison code is shared); `train --cwd`/`--agent`/`--binary` overrides; a base from before 2.2 in `list`/`fork`; a real transcript; `rebase` with a real `claude`; POSIX.
|
||||
- Left for later: `usage` (2.3).
|
||||
- Expected broken: `usage` exits 2 "not implemented" (2.3).
|
||||
- Commits: `5fda2e2` (code, template and tests), then this note's commit.
|
||||
|
||||
Reference in New Issue
Block a user