Persist the seed boundary so fork-child replay routes correctly
持久化 seed 边界以确保 fork 子会话回放正确路由
The [per-session snapshot replay Agent Note](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
English
Problem
The per-session snapshot replay Agent Note 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: 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-in-process-driver), 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
seedLengthfield on the header line (toHeaderLine/fromHeaderLine). - SQLite: a
seed_lengthcolumn on thesessionstable.
The SQLite layout containing seed_length, source_event_seqs, and surface_op is schema version 4. Earlier version 3 layouts were ambiguous, so every non-current user_version is rejected without migration under the pre-release policy.
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, and two recorded fork scenarios exercise it end to end — see Record fork and mixed spawn+fork snapshot scenarios.
Alternatives considered
- Derive the boundary heuristically in
llm-replay(the seeded prefix is contiguous parent events ending at the lastturn/endbefore the child's firstuser/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 boundaries" 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_VERSIONis the monotonic bump-and-reject knob (a small enumerable set of revisions worth telling apart), distinct from the event-vocabularyversion. 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 subsystems catalog (
persistence.md) is updated in the same change (itsSessionHeader/CreateSessionOptionstype-equivblocks). - Existing SQLite databases at schema v2 are rejected on open (no user data pre-release).
- Spawn replay is unchanged (
seedLength0). Fork replay now routes a child to its own script; covered by a regression inllm-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).
中文
问题
逐会话快照回放 Agent Note使快照层能够表达嵌套 agent(智能体)形状:一个父项加上每个进程内 subagent 的一份记录日志,每份日志都按调用会话作为键,以独立脚本回放。它曾指出(§ 范围,最后一个项目符号),fork 快照「只是未来很容易添加的一项,并非键控缺口」。这一判断对 fork 子会话而言是错误的——问题不在键控,而在脚本派生。
subagent 脚本由 deriveReplayScript 从已录制的会话日志推导:它按 (turn, step) 对日志中的 assistant/chunk 事件分组,每次 stream() 调用对应一条回放条目。对 spawn 子会话而言这是正确的,因为其日志只包含自身的模型调用。
fork 子会话不同。fork 后端用父日志的一段平衡的已完成轮次前缀(dsh-subagent-in-process-driver)来播种子会话,而该 seed 会成为子会话持久化的 log(Session 构造函数将 seed 复制进 this.log)。因此 fork 子会话的 .jsonl 以父会话的事件开头——包括父会话的 assistant/chunk 事件——之后才是子会话自身的轮次。
从 fork 子会话的完整日志推导脚本,会把父会话的已录制响应当作子会话的模型调用来回放:实际运行的 fork 子会话第一次调用 stream() 时,会收到父会话的第一段分片序列而非自身的。当时已录制的场景全部是 spawn,所以这从未触发——但 fork 快照会静默地错误路由,恰好属于快照层存在的意义所要捕获的那类 bug。
决策
记录会话继承前缀的结束位置,将其持久化,并让回放 harness 仅从子会话自身的事件推导脚本。
1. 会话头部的 seedLength
SessionHeader 新增可选字段 seedLength: number——表示有多少前导事件是通过 seed 继承而来、而非本会话产生的。fork 后端在创建子会话时设置它(= 播种前缀的长度);全新的 spawn 子会话不设置(等同于 0)。它通过 CreateSessionOptions.meta(及 CreateAgentOptions.meta)传递,在 SessionStore.prepare 中设置。
seedLength 是显式的,绝不从 seed.length 推断。恢复/加载时用会话的完整已存储日志作为 seed,此时 seed.length 是全长而非原始边界——恢复路径改为从加载的 header 中取回持久化的 seedLength。(做法与 createdAt 相同:恢复时显式保留,而非重新默认为当前时间。)
2. 两个持久化后端均完整往返
- JSONL:header 行上的
seedLength字段(toHeaderLine/fromHeaderLine)。 - SQLite:
sessions表上的seed_length列。
包含 seed_length、source_event_seqs 和 surface_op 的 SQLite 布局为 schema version 4。更早的 version 3 布局存在歧义,因此在预发布策略下,所有非当前 user_version 均直接拒绝,不做迁移。
3. 回放从边界之后推导子会话脚本
dsh-llm-replay 的 parseSessionHeader 现在也读取 seedLength(缺失则为 0),loadSessionScripts 从 parseSessionLog(text).slice(seedLength) 推导子会话条目——即边界及之后的事件,也就是子会话自身的模型调用。对 spawn 子会话而言 seedLength 为 0,此操作是空操作,spawn 场景逐字节不变。
这弥补了路由正确性的缺口,两个已录制的 fork 场景对其进行端到端验证——见记录 fork 与混合 spawn+fork 快照场景。
曾考虑的替代方案
- 在
llm-replay中启发式推导边界(播种前缀是连续的父事件,止于子会话第一条user/message之前的最后一个turn/end)。否决:在测试 harness 中用脆弱的启发式重新推导一个生产者已经知道的事实。在源头(fork 后端)持久化边界,是「在包边界处显式优于隐式」这条规则跨越持久化边界的应用——子会话 fixture(测试前置数据)的读取者永远不需要重建继承在哪里结束。 - 固定格式版本而不递增(事件日志使用的
SESSION_FORMAT_VERSION = 0「不稳定」姿态)。对 SQLite 表布局否决:SCHEMA_VERSION是单调递增并拒绝旧版的旋钮(数量不多、可枚举且值得区分的一组修订),与事件词汇的version不同。新增列正是它所版本化的那种破坏性表变更,因此需要递增。
后果
- core 与两个后端新增一个持久化 header 字段;子系统目录(
persistence.md)在同一变更中更新(其SessionHeader/CreateSessionOptions的type-equiv块)。 - 既有的 schema v2 SQLite 数据库在打开时被拒绝(预发布阶段无用户数据)。
- spawn 回放不变(
seedLength为 0)。fork 回放现在将子会话路由到自身的脚本;由llm-replay测试中的一个回归用例覆盖(一个子会话 fixture,其播种前缀包含父会话的分片——推导出的子会话脚本必须排除它,不做 slice 时该用例会失败)以及一个持久化往返测试(两个后端,通过共享的 coordinator 约定)。