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

12 KiB
Raw Permalink Blame History

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
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.