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:
Tianyi Cui
2026-06-22 20:55:32 +08:00
parent c4ba1bd65a
commit b3d40d427e
19 changed files with 209 additions and 40 deletions

View File

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

View File

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