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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user