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

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

View File

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

View File

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

View File

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

View File

@@ -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 }
}
/**