Files
reliquary/docs/workpad/plans/2026-10-16-16-headless-forks-phase2-contract.md

65 lines
12 KiB
Markdown
Raw Permalink 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.
# Phase 2 contract digest — `spire:forks` bookkeeping
**Written by**: the Athanor, `claude-opus-5-5[1m]`, 2026-10-16 16:45 +0200, at Phase 2 intake.
**What this is**: the brief the Phase 2 block owners (2.1, 2.2, 2.3) build against. It pins points the build plan, the plan and the design leave open, and folds in the Phase 1 review items the coordinator assigned to Phase 2. Precedence: build plan `plans/2026-10-16-02-headless-forks-build.md` (§4, §6 Phase 2) > coordinator decisions (below) > plan `plans/2026-10-16-01-headless-forks.md` §7 Phase 2 > design `designs/2026-10-16-01-headless-forks.md` §5–§6 > this digest's own choices. Where this digest conflicts with a higher source, follow the higher source and say so in your handoff note.
Inputs to read besides those: the Phase 1 digest `plans/2026-10-16-12-headless-forks-phase1-contract.md` (still in force where not changed here), the Phase 1 review `reports/2026-10-16-16-headless-forks-phase1-review.md` (M6–M16, the P0.6 ping note), the re-check `reports/2026-10-16-17-headless-forks-phase1-recheck.md` (R1), the run log `reports/2026-10-16-08-headless-forks-run.md` (block notes and the fix pass), and the code `plugins/spire/skills/forks/`.
## Coordinator decisions (2026-10-16, Phase 2 brief)
- **R1**: a kill between the registry write and the ledger append can leave a finished job with no ledger line. `usage` reconciles the ledger against `results/` before aggregating: any result without a ledger line gets one appended, idempotently (dedupe on job id, under the lock). Cost is never under-counted.
- **M6**: the runtime fingerprint also compares sorted `skills`, and identifies plugins without a `version` by the last segment of their `path`. Both become drift reasons.
- **M7**: the fingerprint hash covers the mcp config **content** (`{"strict": true, "servers": {}}`), not its per-run path. The path stays in the config, outside the hash.
- **M8**: widen the unit visible-window check: diff the visible `ConsoleWindowClass` / `CASCADIA_HOSTING_WINDOW_CLASS` windows before and during the job as well as the tree-pid check, add a positive control (a deliberately visible spawn that the check must detect, closed at once), and skip (not fail) when no desktop is present.
- **M10**: follow plan §4.3. `train` accepts launch-field overrides (the same flags as `init`); the base records the launch fields it was trained with and their fingerprint hash. Every later launch on that base (`fork`, `continue`, `rebase`, pings) builds its command line from **the base's record**, not from the run config.
- **M11**: a result's `note` is set only when the note file's mtime is at or after this job's start.
- **M13**: Git Bash rooted paths that are not drive forms (`/tmp/x`, `/usr/…`): convert with `cygpath -w` when it is on PATH; if it is not, refuse with exit 2 and a message. Never silently map them onto the cwd's drive.
- **M16**: rename the job-file progress counter `turns` to `messages`; the result and ledger keep `turns` = `result.num_turns`.
- Keep a test for "an error after the requested model billed": the call billed the requested model, then the result is `is_error`; it must read `failed` with an error reason (`is_error` or the API-error reason), never `model_refused`. The Phase 1 fix pass changed the stub's `error` directive to bill nothing, so add a directive for the billed case.
## Blocks
| Block | Owner | Scope |
|---|---|---|
| 2.1 Warmth and keep-warm | fresh, `opus` | Warmth inference, pings, `tick`, `ttl_downgrade`, `cold_start` rule (C5), ping dedupe (C3d); plus M8, M16 and the billed-error test. |
| 2.2 Rebase and re-train flags | fresh, `opus` | `rebase`, `templates/rebase.md`, `rebase_due`, `retrain_due` and their reasons, `binary_missing`; plus M6, M7, M10, M11, M13. |
| 2.3 Usage rendering | fresh, `sonnet` | `usage --block/--phase --format json|md`; plus R1. |
| 2.4 Phase tests, smoke | the Athanor; fallout to a fresh `opus` | G-unit, live smoke of plan §7 "Phase 2 tests and smoke". |
## 2.1 Warmth and keep-warm (design §5)
- **TTL inference** per base, from its calls (base and all its forks, continues and pings): `3600` if the most recent call wrote `ephemeral_1h` tokens, or read cache and the previous known TTL was 3600; `300` if it wrote only `ephemeral_5m`; else the configured `ttl_s` (3600 default). A call that wrote only `ephemeral_5m` sets flag `ttl_downgrade` on its result.
- **last_hit**: unchanged from the Phase 1 fix (I3): train end; a fork's first assistant event; a ping's first assistant event. Never a continue.
- **warm_until** = `last_hit + ttl`; **margin** = `max(120, ttl/10)` unless `init --warm-margin-s` sets it (a tunable threshold like the others; default unset = the formula). State: warm / expiring / cold as design §5.
- **Ping** = `--resume <base session> --fork-session --session-id <new>`, prompt `Reply OK`, `--max-turns 1`, the base's launch record, kind `ping`, its own job id, ledger line, result. Pings do not count toward `parallel_cap` and never return through `wait`'s `done` as blocks do: they go to `wait`'s `pings: [...]` list (each ping result returned once). They are N3-refused under `SPIRE_FORKS_CHILD` like any launch.
- **Ping rule**: base `active` **and** state `expiring` **and** the run had a running fork/continue job or a `wait` call in the last 10 min **and** pings since the base's last real (non-ping) call < `break_even` (`floor((write − read) / read)` with ratios 1h write 2.0, 5m write 1.25, read 0.1 → 19 at 1 h, 11 at 5 min) **and** `pings` is `on` in the config **and** no ping for this base is in flight.
- **Ping dedupe (C3d)**: the decision and `ping_in_flight` (with the ping job id) are written on the base under the registry lock; other tickers skip a base that is in flight; the flag clears when the ping finishes (any status).
- **Ping verdict**: a ping's cache verdict is computed like a fork's, but a `partial` ping still refreshes `last_hit` (P0.6: a 50-min ping re-created ~7.5k of the shared system prefix and still kept the base warm; on a small base that reads `partial`). Only a `miss` (cache_read 0) or a failed ping leaves `last_hit` alone and adds flag `ping_missed` to the ping's result.
- **Tick**: one pass of the ping rule over all bases of the run. Runs inside every supervisor every 60 s and inside `wait` every 30 s; `tick --run R` runs one pass by hand (N3-refused in a fork). A ping is launched through the same CIM hidden spawn as any job.
- **cold_start** (C5, already in Phase 1): keep; verify against the inferred TTL now.
- **`init --pings on|off`** default `on` (P0.6 passed).
- **M16**, **M8**, billed-error test: as above.
- Tests (clock-injected): warm/expiring/cold boundaries at 1 h and 5 min (and with `--warm-margin-s`); ping eligibility incl. no ping without activity in the last 10 min, the cap at 19 and 11, `pings off`, in-flight dedupe across two tickers; TTL inference from 1h-only, 5m-only and read-only calls; `ttl_downgrade`; a stubbed ping end to end through `wait`'s `pings` list; partial ping refreshes, missed ping does not.
## 2.2 Rebase and re-train flags (design §5, build plan 2.2)
- **`rebase --run R --base B --as NEW --brief FILE [--phase N]`**: a fork of B whose prompt is `templates/rebase.md` (read the listed notes and diffs, do not edit code, update the trunk notes if the brief names a path, reply READY) + delta header + brief. On `ok`, its session becomes base `NEW` (`status: active`, `parent: B`, `lineage_rebases` = B's + 1, `base_commit` = HEAD at the end, context tokens from its last assistant message, its launch record = B's, unless a binary drift made the rebase run under the new binary: then the new binary is recorded); B becomes `superseded`. On failure B stays `active` and `NEW` is not created. `rebase` counts toward the cap and is N3-refused.
- **rebase_due** reasons (on `fork`'s launch return, and in `list` per base): `phase_boundary` (the fork's `--phase` is greater than the highest phase of any fork from this base, or than the base's own phase), `commits:<n>` (commits since `base_commit` > `rebase_commits`), `lines:<n>` (`git --no-optional-locks diff --shortstat <base_commit>` insertions + deletions > `rebase_lines`), `delta_tokens:<n>` (chars / 4 of the delta header plus the notes listed in the brief > `rebase_delta_tokens`), `binary_drift` (stored binary version/size/mtime differ from the current file), `runtime_drift:<fields>` (the last call on this base reported `fingerprint: drift:<runtime fields>`, e.g. `skills`, `plugins` — M6).
- **retrain_due** reasons: `defining:<fields>` — a fingerprint-defining field of the base (model, cwd, tools, permission mode, agent name or agent file sha, preamble sha) differs from what a launch would use now. "Now" means: the preamble and agent file re-hashed from disk; and for fields the base did **not** override at `train`, the run config. `init` may therefore change config values after bases exist (this replaces Phase 1's refusal): bases that did not override a changed field report `retrain_due`. Also `max_base_tokens:<n>` (the base's context tokens > `max_base_tokens`; measured from the transcript's last assistant line when the file exists, else from the stored context — P0.7), `rebases:<n>` (`lineage_rebases` ≥ `max_rebases`), `binary_missing` (the stored binary path is gone; launches refuse with exit 2 and this reason).
- The flags are advisory: `fork` still launches when they are true (except `binary_missing`). The Athanor obeys them.
- **M6, M7, M10, M11, M13**: as above.
- Tests: each reason triggered separately (and only it), `fork`'s launch return lists the right `reasons[]`; `rebase` end to end with the stub (new base active, old superseded, fork from the new base gets `hit` against the new context); `train` with overrides (recorded per base; a fork of it uses the base's record); `init` changing the model after a base exists → that base `retrain_due: defining:model`; M7 hash stable across two runs; M11; M13 with and without `cygpath`.
## 2.3 Usage rendering (design §6, build plan §4 "Usage md columns")
- **`usage --run R [--block ID | --phase N] [--format json|md]`** (default json). N3: allowed in a fork. Before aggregating: R1 reconciliation (under the lock).
- json: totals (`input`, `output`, `cache_creation` with 1h/5m, `cache_read`, `cost_usd` = sum of `cost_delta_usd`, calls) per kind (`train`, `rebase`, `fork`, `continue`, `ping`) and overall; `cold_starts` count; `cache_hit_ratio` = hits / (fork + continue + rebase calls with a verdict); `rows` = one per ledger line in scope.
- md, `--block`: exactly one markdown table with the fixed header `| Block | Owner (model id from modelUsage) | Session | Commit | in | out | cache write | cache read | cost Δ |`, one row per call of that block (fork and its continues). Owner = `models_used` joined; Session = short session id (first 8); Commit = commits between the job's `head_at_launch` and its end HEAD (the supervisor must record `head_at_end`; add it if missing) for a sequential fork, `—` for a parallel one; numbers with thousands separators; cost Δ as `$0.0000`. Null usage (killed/lost) renders `—`.
- md, `--phase`: the per-kind totals table (`| Kind | Calls | in | out | cache write | cache read | cost |` plus a total row), then one line each for cold starts, cache-hit ratio and the priced total.
- stdout for `--format md` is exactly one markdown fragment (the plan §4.1 exception to the JSON rule); errors still JSON with exit 2.
- Tests: md against a fixed ledger fixture (exact text), json totals, R1 reconciliation (a result with no ledger line gets exactly one line, a second `usage` adds none).
## Not in Phase 2
`SKILL.md`, the Athanor and implementation text, references, version bump (Phase 3). M12, M14, M15 and the post-exit-sweep / continue-frame parts of M16 are Phase 3 text.