A fork subagent seeds its child session with a prefix of the parent's log, and that seed becomes the child's persisted log — so a fork child's .jsonl begins with the PARENT's events, including the parent's assistant/chunk events. The snapshot replay harness derived a child's script from its whole log, which would replay the parent's recorded responses as the child's model calls. Spawn-only scenarios never hit it, but a fork snapshot would mis-route silently. Record the seed boundary and skip the inherited prefix at replay: - SessionHeader gains an optional `seedLength` (how many leading events were inherited via a seed), threaded through CreateSessionOptions/CreateAgentOptions meta and stamped by the fork backend (= seeded-prefix length; absent for spawn). It is EXPLICIT, never inferred from seed.length: a resume seeds the whole stored log, so the resume path passes the persisted boundary back. - Both persistence backends round-trip it: JSONL header line, SQLite seed_length column. The SQLite table change bumps SCHEMA_VERSION 2->3; per the pre-release stance the backend rejects an older user_version on open with NO migration. - llm-replay's parseSessionHeader reads seedLength and loadSessionScripts derives a child script from events AFTER the boundary. seedLength is 0 for spawn, so spawn replay is byte-for-byte unchanged. Closes the routing-correctness gap the per-session snapshot replay RFC under- stated; a recorded fork scenario remains a future addition but now derives correctly. RFC: docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md. Regression coverage: a fork child fixture whose seeded prefix carries a parent chunk (derived script must exclude it, proven red without the slice); a seedLength persistence round-trip through the shared coordinator contract (both backends); the fork backend stamping it; resume preserving it from the persisted header.
5.6 KiB
RFC: Persist the seed boundary so fork-child replay routes correctly
Status: implemented
Problem
The per-session snapshot replay RFC made the snapshot tier express a nested-agent shape: a parent plus one recorded log per in-process subagent, each replayed as its own script keyed by calling session. It noted (§ Scope, final bullet) that a fork snapshot was "a trivial future addition, not a gap in the keying." That was wrong about a fork child specifically — not the keying, but the script derivation.
A subagent script is derived from a recorded session log by deriveReplayScript: it groups the log's assistant/chunk events by (turn, step) into one replay entry per stream() call. This is correct for a spawn child, whose log contains only its own model calls.
A fork child is different. The fork backend seeds the child session with a balanced completed-turn prefix of the parent's log (dsh-subagent-inprocess), and that seed becomes the child session's persisted log (Session's constructor copies the seed into this.log). So a fork child's .jsonl begins with the parent's events — including the parent's assistant/chunk events — and only then carries the child's own turn.
Deriving the child script from the whole fork-child log therefore replays the parent's recorded responses as the child's model calls: the live fork child's first stream() would receive the parent's first recorded chunk sequence instead of its own. The recorded scenarios are all spawn today, so this never fired — but a fork snapshot would have mis-routed silently, exactly the class of bug the snapshot tier exists to catch.
Decision
Record where a session's inherited prefix ends, persist it, and have the replay harness derive a child's script from its own events only.
1. seedLength on the session header
SessionHeader gains an optional seedLength: number — how many leading events were inherited via a seed rather than produced by this session. The fork backend stamps it (= the seeded-prefix length) when it creates the child; a fresh spawn leaves it absent (≡ 0). It is threaded through CreateSessionOptions.meta (and CreateAgentOptions.meta), set in SessionStore.prepare.
seedLength is explicit, never inferred from seed.length. A reconstruction (resume/load) seeds the session with its WHOLE stored log, so seed.length there is the full length, not the original boundary — the resume path passes the persisted seedLength back from the loaded header instead. (Same shape as createdAt, which is also explicitly preserved on reconstruction rather than re-defaulted to now.)
2. Both persistence backends round-trip it
- JSONL: a
seedLengthfield on the header line (toHeaderLine/fromHeaderLine). - SQLite: a
seed_lengthcolumn on thesessionstable.
The SQLite change is a breaking table-layout change, so SCHEMA_VERSION bumps 2 → 3. Per the repo's pre-release stance (§ "Pre-release stance" in AGENTS.md) the backend rejects a non-current user_version on open rather than migrating it — there is no persisted user data to preserve, so no migration code is written (the existing reject-not-migrate path at openDatabase already enforces this; v1 and now v2 are both rejected).
3. Replay derives a child script after the boundary
dsh-llm-replay's parseSessionHeader now also reads seedLength (absent ⇒ 0), and loadSessionScripts derives a child's entries from parseSessionLog(text).slice(seedLength) — the events at or after the boundary, i.e. the child's own model calls. For a spawn child seedLength is 0 and this is a no-op, so spawn scenarios are byte-for-byte unchanged.
This closes the routing correctness gap; an actual fork scenario (a recorded subagent-multi-style fixture with a fork child) is still a future addition, but it can now be recorded and replayed correctly rather than mis-routing.
Alternatives considered
- Derive the boundary heuristically in
llm-replay(the seeded prefix is contiguous parent events ending at the lastturn/endbefore the child's firstuser/message). Rejected: a brittle heuristic in the test harness that re-derives a fact the producer already knows. Persisting the boundary at its source (the fork backend) is the "explicit > implicit at package seams" rule applied across the persistence boundary — the reader of a child fixture never has to reconstruct where the inheritance ended. - Pin the format version instead of bumping (the
SESSION_FORMAT_VERSION = 0"unstable" stance the event log uses). Rejected for the SQLite table layout:SCHEMA_VERSIONis the monotonic bump-and-reject knob (a small enumerable set of revisions worth telling apart), distinct from the event-vocabularyversion. Adding a column is precisely the breaking table change it versions, so it bumps.
Consequences
- A new persisted header field across core + both backends; the core-data-structures catalog (
persistence.md) is updated in the same change (itsSessionHeader/CreateSessionOptionstype-equivblocks). - Existing SQLite databases at schema v2 are rejected on open (no user data pre-release).
- Spawn replay is unchanged (
seedLength0). Fork replay now routes a child to its own script; covered by a regression inllm-replay's tests (a child fixture whose seeded prefix carries a parent chunk — the derived child script must exclude it, proven red without the slice) and a persistence round-trip test (both backends, via the shared coordinator contract).