Persist the seed boundary so fork-child replay routes correctly
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.
This commit is contained in:
@@ -332,7 +332,7 @@ list(): Agent[]
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/index.ts:116`](../../packages/core/agent/src/index.ts)
|
||||
Source: [`packages/core/agent/src/index.ts:117`](../../packages/core/agent/src/index.ts)
|
||||
|
||||
### `ctx.bash` — `BashExecutor` (abstract seam)
|
||||
|
||||
|
||||
@@ -34,12 +34,22 @@ interface SessionHeader {
|
||||
cwd?: string
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
parentSession?: SessionId
|
||||
/**
|
||||
* How many leading events were INHERITED via a seed rather than produced by
|
||||
* this session — the seed boundary. Set when a fork seeds a child with a
|
||||
* prefix of the parent's log (= the seeded prefix length); absent/0 means the
|
||||
* session produced all its own events. Persisted so a reload reconstructs the
|
||||
* boundary instead of re-deriving it from the full stored log, and so a replay
|
||||
* harness can skip the inherited prefix when deriving the child's OWN script
|
||||
* (the seeded events are the parent's, not this child's model calls).
|
||||
*/
|
||||
seedLength?: number
|
||||
}
|
||||
```
|
||||
|
||||
## `CreateSessionOptions` — seeding and metadata
|
||||
|
||||
Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, and — only when reconstructing a persisted session — the original `createdAt` to preserve it.
|
||||
Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, and — only when reconstructing a persisted session — the original `createdAt` to preserve it.
|
||||
|
||||
```ts type-equiv
|
||||
interface CreateSessionOptions {
|
||||
@@ -48,10 +58,16 @@ interface CreateSessionOptions {
|
||||
/**
|
||||
* Creation metadata. The store fills in `version`/`id` and defaults
|
||||
* `createdAt` to now; the caller supplies the storage-level fields (validated
|
||||
* absolute `cwd`, `parentSession` lineage, and — when reconstructing a
|
||||
* persisted session — the original `createdAt` to preserve it).
|
||||
* absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and
|
||||
* — when reconstructing a persisted session — the original `createdAt` to
|
||||
* preserve it).
|
||||
*
|
||||
* `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction
|
||||
* (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full
|
||||
* length, not the original boundary — the caller must pass the persisted
|
||||
* boundary back. A fresh fork passes its actual seeded-prefix length.
|
||||
*/
|
||||
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number }
|
||||
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| [Real-API e2e in CI against the external DeepSeek API](implemented/testing/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 |
|
||||
| [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 |
|
||||
| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 |
|
||||
| [Persist the seed boundary so fork-child replay routes correctly](implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) | 2026-06-22 |
|
||||
|
||||
## Rejected
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# RFC: Persist the seed boundary so fork-child replay routes correctly
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The [per-session snapshot replay RFC](2026-06-22-subagent-snapshot-replay.md) 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`](../../../../packages/support/llm-replay): 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`](../../../../packages/subagent/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 `seedLength` field on the header line (`toHeaderLine`/`fromHeaderLine`).
|
||||
- **SQLite**: a `seed_length` column on the `sessions` table.
|
||||
|
||||
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 last `turn/end` before the child's first `user/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_VERSION` is the monotonic bump-and-reject knob (a small enumerable set of revisions worth telling apart), distinct from the event-vocabulary `version`. 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 (its `SessionHeader` / `CreateSessionOptions` `type-equiv` blocks).
|
||||
- Existing SQLite databases at schema v2 are rejected on open (no user data pre-release).
|
||||
- Spawn replay is unchanged (`seedLength` 0). Fork replay now routes a child to its own script; covered by a regression in `llm-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).
|
||||
@@ -50,5 +50,5 @@ Both replay keyless in the default gate.
|
||||
|
||||
- The `TODO(subagent-snapshots)` deferral is resolved: nested-agent transcripts are now a first-class snapshot shape.
|
||||
- `GenerateOptions.sessionId` is a small, honest core-seam addition useful beyond replay (telemetry, request routing).
|
||||
- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The fork backend is loaded in the example and exercised by PR2's unit tests; a mixed spawn+fork snapshot would need a second tool instance bound to `fork` (pure config) and is a trivial future addition, not a gap in the keying — the keying routes by session, not by backend.
|
||||
- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The keying routes by session, not by backend, so it is already correct for fork. The script *derivation* was not: a fork child's log begins with the seeded parent prefix (the parent's `assistant/chunk` events), so deriving its script from the whole log would replay the parent's responses as the child's. That correctness gap is closed by persisting a seed boundary — see [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md). A recorded mixed spawn+fork *scenario* (a second tool instance bound to `fork`, pure config) remains a future addition, but a fork child now derives correctly.
|
||||
- Out-of-process (ACP) subagents are a different replay shape entirely (each child is its own PROCESS with its own replay), tracked as `TODO(acp-subagent-replay)` in the PR3 plan.
|
||||
|
||||
Reference in New Issue
Block a user