Răsfoiți Sursa

Merge pull request #100 from deepseek-ai/worktree-subagent-fork-replay

Subagent seam (follow-up): persist the seed boundary so fork-child replay routes correctly
Tianyi Cui 3 luni în urmă
părinte
comite
ddb240f3b1

+ 1 - 1
docs/cordis-catalog/events-and-services.md

@@ -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)
 

+ 20 - 4
docs/core-data-structures/persistence.md

@@ -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 }
 }
 ```
 

+ 1 - 0
docs/rfc/README.md

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

+ 47 - 0
docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md

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

+ 1 - 1
docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md

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

+ 3 - 0
packages/core/agent-loop/src/index.ts

@@ -219,6 +219,9 @@ export class AgentLoop extends Service implements AgentFactory {
         createdAt: meta.createdAt,
         ...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
         ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
+        // Reconstruct the seed boundary from the persisted header, NOT from
+        // `events.length` (the resume seeds the WHOLE stored log).
+        ...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
       },
     })
     return this.startOwned(options.agentId, options.agentOptions ?? {}, session)

+ 13 - 6
packages/core/agent-loop/tests/resume.spec.ts

@@ -94,9 +94,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     await ctx2.fiber.dispose()
   })
 
-  it('resume of a forked session preserves the parentSession lineage in the header', async () => {
-    // Lifecycle 1: persist a FORKED session (carries parentSession in its
-    // header) by creating it with a complete-turn seed — the write path
+  it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
+    // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
+    // in its header) by creating it with a complete-turn seed — the write path
     // materializes the fork (header + seed) on disk.
     const seed: SessionEvent[] = [
       { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
@@ -104,12 +104,18 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     ]
     const adapter1 = new MockAdapter([textResponse('a')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
-    const forked = ctx1.sessions.create(SessionId('forked-sess'), { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } })
+    const forked = ctx1.sessions.create(SessionId('forked-sess'), {
+      seed,
+      meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length },
+    })
     await ctx1.parallel('session/flush', forked)
     await ctx1.fiber.dispose()
 
-    // Lifecycle 2: resume it; the parentSession header survives the round-trip
-    // (exercises resume's parentSession-present branch).
+    // Lifecycle 2: resume it; the parentSession + seedLength header survives the
+    // round-trip (exercises resume's parentSession- and seedLength-present
+    // branches). seedLength must come from the PERSISTED header, not from the
+    // resume seed length (which is the whole stored log, not the original
+    // boundary).
     const adapter2 = new MockAdapter([textResponse('b')])
     const ctx2 = new Context()
     await ctx2.plugin(LlmService)
@@ -123,6 +129,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent
     expect(a2.session.header.parentSession).toBe('parent-sess')
     expect(a2.session.header.cwd).toBe('/w')
+    expect(a2.session.header.seedLength).toBe(seed.length)
     await ctx2.fiber.dispose()
   })
 

+ 4 - 3
packages/core/agent/src/index.ts

@@ -30,13 +30,14 @@ export interface CreateAgentOptions {
   /** The live session's id (NOT derived from agentId). */
   sessionId: SessionId
   /**
-   * Session creation metadata: validated absolute `cwd` and `parentSession`
-   * fork lineage. Mirrors the `cwd`/`parentSession` fields of
+   * Session creation metadata: validated absolute `cwd`, `parentSession`
+   * fork lineage, and the `seedLength` seed boundary. Mirrors the
+   * `cwd`/`parentSession`/`seedLength` fields of
    * {@link CreateSessionOptions.meta} in dsh-session (the internal-only
    * `createdAt`, used when reconstructing a persisted session, is deliberately
    * excluded — a factory caller never sets it).
    */
-  meta?: { cwd?: string; parentSession?: SessionId }
+  meta?: { cwd?: string; parentSession?: SessionId; seedLength?: number }
   /**
    * Seed events to reconstruct the child session's log from (the fork lineage
    * primitive). When present, the factory creates the session with this event

+ 1 - 0
packages/core/session/src/index.ts

@@ -289,6 +289,7 @@ export class SessionStore extends Service {
       createdAt: options?.meta?.createdAt ?? Date.now(),
       ...cwd !== undefined ? { cwd } : {},
       ...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {},
+      ...options?.meta?.seedLength !== undefined ? { seedLength: options.meta.seedLength } : {},
     }
     return new Session(sessionId, options?.seed, header)
   }

+ 19 - 3
packages/core/session/src/types.ts

@@ -50,6 +50,16 @@ export 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
 }
 
 /**
@@ -63,10 +73,16 @@ export 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 }
 }
 
 /**

+ 3 - 0
packages/session-persistence/session-persistence-jsonl/src/format.ts

@@ -24,6 +24,7 @@ export interface HeaderLine {
   createdAt: number
   cwd?: string
   parentSession?: SessionId
+  seedLength?: number
 }
 
 /** Build the header line object from a {@link SessionHeader}. */
@@ -35,6 +36,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine {
     createdAt: header.createdAt,
     ...header.cwd !== undefined ? { cwd: header.cwd } : {},
     ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {},
+    ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {},
   }
 }
 
@@ -46,6 +48,7 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader {
     createdAt: line.createdAt,
     ...line.cwd !== undefined ? { cwd: line.cwd } : {},
     ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {},
+    ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {},
   }
 }
 

+ 5 - 3
packages/session-persistence/session-persistence-sqlite/src/index.ts

@@ -224,19 +224,21 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
    */
   private writeRow(meta: SessionHeader): void {
     this.db.prepare(`
-      INSERT INTO sessions (id, version, created_at, cwd, parent_session)
-      VALUES (?, ?, ?, ?, ?)
+      INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length)
+      VALUES (?, ?, ?, ?, ?, ?)
       ON CONFLICT(id) DO UPDATE SET
         version = excluded.version,
         created_at = excluded.created_at,
         cwd = excluded.cwd,
-        parent_session = excluded.parent_session
+        parent_session = excluded.parent_session,
+        seed_length = excluded.seed_length
     `).run(
       meta.id,
       meta.version,
       meta.createdAt,
       meta.cwd ?? null,
       meta.parentSession ?? null,
+      meta.seedLength ?? null,
     )
   }
 }

+ 7 - 4
packages/session-persistence/session-persistence-sqlite/src/schema.ts

@@ -15,7 +15,7 @@ import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-se
  * layout; orthogonal to a session's own `version` (which versions the EVENT
  * vocabulary, stored per session in the `sessions` row).
  */
-export const SCHEMA_VERSION = 2
+export const SCHEMA_VERSION = 3
 
 /**
  * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
@@ -30,6 +30,7 @@ export interface SessionRow {
   created_at: number
   cwd: string | null
   parent_session: string | null
+  seed_length: number | null
 }
 
 /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
@@ -51,8 +52,8 @@ export interface EventRow {
  * current {@link SCHEMA_VERSION}; an existing database whose version is NOT the
  * current one (written by a different, incompatible build — older or newer) is
  * REJECTED rather than opened against a layout this build does not understand.
- * There are no migrations: v1 had a different `sessions` layout and is not
- * upgraded in place.
+ * There are no migrations: an earlier layout (v1's different `sessions` shape,
+ * v2 without the `seed_length` column) is not upgraded in place — it is rejected.
  */
 export function openDatabase(path: string): DatabaseSync {
   const db = new DatabaseSync(path)
@@ -76,7 +77,8 @@ export function openDatabase(path: string): DatabaseSync {
       version        INTEGER NOT NULL,
       created_at     INTEGER NOT NULL,
       cwd            TEXT,
-      parent_session TEXT
+      parent_session TEXT,
+      seed_length    INTEGER
     ) STRICT
   `)
   db.exec(`
@@ -100,6 +102,7 @@ export function rowToMeta(row: SessionRow): SessionHeader {
     createdAt: row.created_at,
     ...row.cwd !== null ? { cwd: row.cwd } : {},
     ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
+    ...row.seed_length !== null ? { seedLength: row.seed_length } : {},
   }
 }
 

+ 1 - 1
packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts

@@ -315,7 +315,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
   })
 
   it('exposes the schema version constant', () => {
-    expect(SCHEMA_VERSION).toBe(2)
+    expect(SCHEMA_VERSION).toBe(3)
   })
 })
 

+ 20 - 0
packages/session-persistence/session-persistence/tests/coordinator-contract.ts

@@ -123,6 +123,26 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       }
     })
 
+    it('round-trips the seed boundary (seedLength) through persistence', async () => {
+      // A forked child records how many leading events were inherited via the
+      // seed; the boundary must survive a reload (so a resume/replay can tell the
+      // inherited prefix from the child's own events). Both backends carry it on
+      // the header — JSONL on the header line, SQLite in the seed_length column.
+      const fix = await makeFixture()
+      const { ctx, fiber } = await freshCtx(fix)
+      try {
+        const session = ctx.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } })
+        send(session, oneTurnLog())
+        await ctx.parallel('session/flush', session)
+
+        const loaded = await ctx.sessionPersistence.load(SessionId('forked-child'))
+        expect(loaded.meta.seedLength).toBe(3)
+      } finally {
+        await fiber.dispose()
+        await fix.cleanup()
+      }
+    })
+
     it('snapshot-on-buffer: mutating an event after session/event does not corrupt the persisted copy', async () => {
       const fix = await makeFixture()
       const { ctx, fiber } = await freshCtx(fix)

+ 4 - 0
packages/subagent/subagent-fork/tests/subagent-fork.spec.ts

@@ -106,6 +106,10 @@ describe('dsh-subagent-fork', () => {
     expect(seededUser).toBeDefined()
     // Lineage stamped.
     expect(child.session.header.parentSession).toBe(parent.session.header.id)
+    // The seed boundary is recorded on the header (= the seeded prefix length),
+    // so a reload / replay harness can tell the inherited prefix from the
+    // child's own events.
+    expect(child.session.header.seedLength).toBe(parentPrefixLen)
     await run.dispose()
   })
 

+ 3 - 0
packages/subagent/subagent-inprocess/src/index.ts

@@ -122,6 +122,9 @@ export function startInProcessRun(
     meta: {
       ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
       parentSession: parentHeader.id,
+      // Record the seed boundary so a reload (and a replay harness) can tell the
+      // inherited prefix from the child's OWN events. 0 for a fresh spawn.
+      ...seedLength > 0 ? { seedLength } : {},
     },
     ...options.seed !== undefined ? { seed: options.seed } : {},
     agentOptions,

+ 18 - 8
packages/support/llm-replay/src/index.ts

@@ -140,18 +140,21 @@ export function parseSessionLog(text: string): SessionEvent[] {
 
 /**
  * Read the identifying facts off a session log's header line (line 0): the
- * recorded session `id` (diagnostics) and `createdAt` (the deterministic
- * ordering key that binds a recorded script to a live session — see
- * {@link SessionScript}). A header missing either field falls back to a stable
- * default (`''` / `0`) rather than throwing: a no-model fixture is header-only
- * and still orders fine as the single (primary) script.
+ * recorded session `id` (diagnostics), `createdAt` (the deterministic ordering
+ * key that binds a recorded script to a live session — see
+ * {@link SessionScript}), and `seedLength` (the seed boundary — how many leading
+ * events were INHERITED via a fork seed rather than produced by this session's
+ * own model calls; absent ⇒ 0). A header missing a field falls back to a stable
+ * default (`''` / `0` / `0`) rather than throwing: a no-model fixture is
+ * header-only and still orders fine as the single (primary) script.
  */
-export function parseSessionHeader(text: string): { id: string; createdAt: number } {
+export function parseSessionHeader(text: string): { id: string; createdAt: number; seedLength: number } {
   const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? '{}'
-  const parsed = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown }
+  const parsed = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; seedLength?: unknown }
   return {
     id: typeof parsed.id === 'string' ? parsed.id : '',
     createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : 0,
+    seedLength: typeof parsed.seedLength === 'number' ? parsed.seedLength : 0,
   }
 }
 
@@ -257,10 +260,17 @@ export function loadSessionScripts(config: ReplayConfig): SessionScript[] {
     }
     const text = readFileSync(childFile, 'utf8')
     const header = parseSessionHeader(text)
+    // Derive the child's script from its OWN events only — events AT OR AFTER
+    // the seed boundary. A FORK child's log begins with the seeded parent prefix
+    // (the parent's events, including its `assistant/chunk`s); replaying those as
+    // the child's model calls would feed the child the PARENT's recorded
+    // responses. `seedLength` is 0 for a fresh (spawn) child, so this is a no-op
+    // there.
+    const ownEvents = parseSessionLog(text).slice(header.seedLength)
     children.push({
       recordedId: header.id,
       createdAt: header.createdAt,
-      entries: deriveReplayScript(parseSessionLog(text)),
+      entries: deriveReplayScript(ownEvents),
       primary: false,
     })
   }

+ 38 - 6
packages/support/llm-replay/tests/llm-replay.spec.ts

@@ -35,12 +35,13 @@ const TEXT_CHUNKS: StreamChunk[] = [
 ]
 
 /** Build a minimal session-JSONL string: a header line + the given events. */
-function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt?: number }): string {
+function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt?: number; seedLength?: number }): string {
   const headerLine = JSON.stringify({
     type: 'session',
     version: 0,
     id: header?.id ?? 's1',
     createdAt: header?.createdAt ?? 0,
+    ...header?.seedLength !== undefined ? { seedLength: header.seedLength } : {},
   })
   return [headerLine, ...events.map(e => JSON.stringify(e))].join('\n') + '\n'
 }
@@ -370,17 +371,22 @@ describe('installLlmReplay (through the real waterfall)', () => {
 })
 
 describe('parseSessionHeader', () => {
-  it('reads id and createdAt off the header line', () => {
+  it('reads id, createdAt, and seedLength off the header line', () => {
     expect(parseSessionHeader(sessionJsonl([], { id: 'abc', createdAt: 42 })))
-      .toEqual({ id: 'abc', createdAt: 42 })
+      .toEqual({ id: 'abc', createdAt: 42, seedLength: 0 })
   })
 
-  it('falls back to id="" / createdAt=0 when the header lacks them', () => {
-    expect(parseSessionHeader('{"type":"session","version":0}\n')).toEqual({ id: '', createdAt: 0 })
+  it('reads a non-zero seedLength (a fork child header)', () => {
+    expect(parseSessionHeader('{"type":"session","version":0,"id":"child","createdAt":7,"seedLength":4}\n'))
+      .toEqual({ id: 'child', createdAt: 7, seedLength: 4 })
+  })
+
+  it('falls back to id="" / createdAt=0 / seedLength=0 when the header lacks them', () => {
+    expect(parseSessionHeader('{"type":"session","version":0}\n')).toEqual({ id: '', createdAt: 0, seedLength: 0 })
   })
 
   it('falls back on an empty buffer (no header line)', () => {
-    expect(parseSessionHeader('')).toEqual({ id: '', createdAt: 0 })
+    expect(parseSessionHeader('')).toEqual({ id: '', createdAt: 0, seedLength: 0 })
   })
 })
 
@@ -420,6 +426,32 @@ describe('loadSessionScripts', () => {
       .toThrow(/child fixture not found/)
   })
 
+  it('derives a FORK child script from its OWN events only (skips the seeded parent prefix)', () => {
+    // A fork child's log begins with the seeded parent prefix — the parent's
+    // events, INCLUDING its assistant/chunk events. Deriving the child script
+    // from the whole log would replay the PARENT's recorded responses as the
+    // child's model calls. With seedLength recorded, the child script must
+    // contain only the child's OWN chunks (those after the boundary).
+    const parentChunk: StreamChunk = { type: 'text-delta', index: 0, text: 'PARENT-RESPONSE' }
+    const childChunks: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'CHILD-RESPONSE' }, { type: 'finish', reason: { kind: 'stop' } }]
+    const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS])
+    // The child fixture: 2 seeded parent events (a chunk + its finish) then the
+    // child's own turn. seedLength = 2 marks where the inherited prefix ends.
+    const childEvents: SessionEvent[] = [
+      chunkEvent(0, 1, 1, parentChunk),
+      chunkEvent(1, 1, 1, { type: 'finish', reason: { kind: 'stop' } }),
+      chunkEvent(2, 2, 1, childChunks[0]!),
+      chunkEvent(3, 2, 1, childChunks[1]!),
+    ]
+    const childPath = join(dir, 'session.1.jsonl')
+    writeFileSync(childPath, sessionJsonl(childEvents, { id: 'child', createdAt: 200, seedLength: 2 }), 'utf8')
+
+    const scripts = loadSessionScripts({ file: f, childFiles: [childPath] })
+    // The child script is ONLY the child's own model call — the parent's seeded
+    // chunk is gone.
+    expect(scripts[1]?.entries).toEqual([{ kind: 'chunks', chunks: childChunks }])
+  })
+
   it('uses the override for the primary and still derives children', () => {
     writeFileSync(file, sessionJsonl([], { id: 'p', createdAt: 1 }), 'utf8')
     const overrideFile = join(dir, 'replay.override.json')