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

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

View File

@@ -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 } : {},
}
}

View File

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