diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index e0fe1cd6c2..f424b8af09 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/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) diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 45c1d8dd6b..8d8f032514 100644 --- a/docs/core-data-structures/persistence.md +++ b/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 } } ``` diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 23e5d60526..22f16ba2dd 100644 --- a/docs/rfc/README.md +++ b/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 diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md new file mode 100644 index 0000000000..66f9ff6f52 --- /dev/null +++ b/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). diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md index acb29e401d..2aece77949 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/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. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index e5393ed0aa..ab95ea5aac 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/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) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 6192396cab..074e84d78a 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/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() }) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 1f2984a148..940a5c9db1 100644 --- a/packages/core/agent/src/index.ts +++ b/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 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index cef91c110c..b547a8dba9 100644 --- a/packages/core/session/src/index.ts +++ b/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) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 2302b5b94d..13bcd2a8c6 100644 --- a/packages/core/session/src/types.ts +++ b/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 } } /** diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 32258c6a37..63cf899e45 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/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 } : {}, } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index cef61cb071..e62fcce592 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/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, ) } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 8238cba30c..04ad77e9e2 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/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 } : {}, } } diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 2bc9c59643..f3ac840f67 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/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) }) }) diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 431d02b4cb..00f87dbae3 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/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) diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 96cdd55141..56441a656f 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/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() }) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index d2840881af..4b8d2d4c99 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/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, diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index b804c8ebd9..78f46e705e 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/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, }) } diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index a0c6268eff..2dd8357cc7 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/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')