📝 Headless forks Phase 2: contract digest and run log intake
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
# 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.
|
||||
@@ -248,3 +248,13 @@ Gate after 1.1 (Athanor, 12:29): G-unit `Ran 52 tests … OK`, exit 0; G-where e
|
||||
- **M15** (C8 admission is the union of three files; untrusted dirs untested): SKILL.md records "permissive at init, enforced at runtime" (Phase 3); the runtime warning check backstops it.
|
||||
- **M16** (two meanings of `turns`; post-exit sweep breadth; `continue` brief without frame): rename the job-file field in Phase 2; the other two are Phase 3 text.
|
||||
- Status: fix pass done; re-check of I1–I3 by a fresh reviewer is Igor's call.
|
||||
- Igor, 2026-10-16: re-check clean (`docs/workpad/reports/2026-10-16-17-headless-forks-phase1-recheck.md`, `60fdea0`); the fixer's residual flags accepted as Minor; Phase 1 closed. Phase 2 folds in R1 (new), M6, M7, M8, M10 (decision: follow plan §4.3, `train` takes overrides), M11, M13, M16 and a billed-error test.
|
||||
|
||||
## Phase 2 — Bookkeeping
|
||||
|
||||
Intake 2026-10-16 16:41 +0200, Athanor `claude-opus-5-5[1m]`. Start commit `60fdea0`. Contract digest for the block owners: `docs/workpad/plans/2026-10-16-16-headless-forks-phase2-contract.md` (Athanor-written; pins the ping, rebase, retrain and usage semantics left open, and the folded items).
|
||||
|
||||
Deviation from the build plan's block table: M8, M16 and the billed-error test go to 2.1; M6, M7, M10, M11, M13 to 2.2; R1 to 2.3 (each lands in the block that already touches that code). `init` may change config after bases exist (Phase 1 refused it) because, with M10, that is the input path for `retrain_due: defining:<fields>`. `init --warm-margin-s` is added as a tunable so the smoke can reach `expiring` without a 54-minute wait.
|
||||
|
||||
| Block | Owner (model id) | Commit | Tokens (if known) |
|
||||
|---|---|---|---|
|
||||
|
||||
Reference in New Issue
Block a user