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 expands each durable assistant/message or assistant/attempt settlement 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). A fork child's .jsonl therefore begins with the parent's events — including its Assistant settlements — 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. All recorded scenarios use spawn, 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. Lineage metadata and an exact body-owned cut
SessionHeader.isSeeded records whether a Session has inherited lineage without exposing a body coordinate to header-only readers. The exact leading-event count is the separately branded SessionLogOffset inheritedEventCount; a fork supplies both isSeeded: true and the copied-prefix length, while a fresh spawn supplies an unseeded header and cut zero. The cut travels through CreateSessionOptions, CreateAgentOptions, persistence inspection, and restored Session state.
inheritedEventCount 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 decoded cut beside the logical header instead.
2. JSONL round-trips it
The v2 JSONL header carries isSeeded, while session/end-seed { inherited: true } marks the exact cut in the body; decoding uses the last tagged marker. The frozen v0 header keeps optional numeric seedLength for byte compatibility, and its codec translates that historical field into the same logical pair.
3. Replay derives a child script after the boundary
dsh-llm-replay parses the selected generation through the static format catalog and retains its decoded inheritedEventCount. loadSessionScripts derives a child's entries from fixture.events.slice(inheritedEventCount) — the events at or after the boundary, which are the child's own model calls. For a spawn child the cut is 0 and this is a no-op.
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.
Consequences
- The lineage bit spans logical Session metadata while the exact cut spans only body-bearing core, persistence, query, and replay values; the v0 physical header remains unchanged.
- Spawn replay is unchanged (cut 0). Fork replay routes a child to its own script; covered by a regression in
llm-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 JSONL persistence round trip through the shared coordinator contract.
中文
问题
逐会话快照回放 Agent Note使快照层能够表达嵌套 agent(智能体)形状:一个父项加上每个进程内 subagent 的一份记录日志,每份日志都按调用会话作为键,以独立脚本回放。它曾指出(§ 范围,最后一个项目符号),fork 快照「只是未来很容易添加的一项,并非键控缺口」。这一判断对 fork 子会话而言是错误的——问题不在键控,而在脚本派生。
subagent 脚本由 deriveReplayScript 从已录制 Session log 推导:它把每个持久 assistant/message 或 assistant/attempt settlement 展开为每次 stream() 调用对应的一条 replay entry。对 spawn 子 Session 而言这是正确的,因为其 log 只包含自身模型调用。
fork 子 Session 不同。fork backend 用父 log 的一段平衡已完成 turn 前缀(dsh-subagent-in-process-driver)播种子 Session,该 seed 会成为子 Session 持久化的 log(Session 构造函数把 seed 复制进 this.log)。因此 fork 子 Session 的 .jsonl 以父 Session event 开头——包括其 Assistant settlement——之后才是子 Session 自己的 turn。
从 fork 子会话的完整日志推导脚本,会把父会话的已录制响应当作子会话的模型调用来回放:实际运行的 fork 子会话第一次调用 stream() 时,会收到父会话的第一段分片序列而非自身的。所有已录制场景都使用 spawn,所以这从未触发——但 fork 快照会静默地错误路由,恰好属于快照层存在的意义所要捕获的那类 bug。
决策
记录会话继承前缀的结束位置,将其持久化,并让回放 harness 仅从子会话自身的事件推导脚本。
1. 谱系 metadata 与正文拥有的精确 cut
SessionHeader.isSeeded 记录 Session 是否具有继承谱系,而不向仅 header 的 reader 暴露正文坐标。精确的前导事件数量是单独品牌化为 SessionLogOffset 的 inheritedEventCount;fork 同时提供 isSeeded: true 与复制前缀的长度,全新的 spawn 则提供 unseeded header 与零 cut。该 cut 经 CreateSessionOptions、CreateAgentOptions、持久化 inspection 与恢复后的 Session 状态传递。
inheritedEventCount 是显式的,绝不从 seed.length 推断。恢复/加载时用会话的完整已存储日志作为 seed,此时 seed.length 是全长而非原始边界——恢复路径改为在 logical header 之外传递解码后的 cut。
2. JSONL 完整往返
v2 JSONL header 携带 isSeeded,而 session/end-seed { inherited: true } 会在正文中标记精确 cut;解码使用最后一个 tagged marker。冻结的 v0 header 为保持字节兼容而继续携带可选数值 seedLength,其 codec 会把该历史字段转换为相同的逻辑值对。
3. 回放从边界之后推导子会话脚本
dsh-llm-replay 通过静态格式 catalog 解析选定 generation,并保留解码后的 inheritedEventCount。loadSessionScripts 从 fixture.events.slice(inheritedEventCount) 推导子 Session entry——即 boundary 及之后的 event,也就是子 Session 自己的模型调用。对 spawn 子 Session 而言 cut 为 0,因此此操作为空操作。
这弥补了路由正确性的缺口,两个已录制的 fork 场景对其进行端到端验证——见记录 fork 与混合 spawn+fork 快照场景。
曾考虑的替代方案
- 在
llm-replay中启发式推导边界(播种前缀是连续的父事件,止于子会话第一条user/message之前的最后一个turn/end)。否决:在测试 harness 中用脆弱的启发式重新推导一个生产者已经知道的事实。在源头(fork 后端)持久化边界,是「在包边界处显式优于隐式」这条规则跨越持久化边界的应用——子会话 fixture(测试前置数据)的读取者永远不需要重建继承在哪里结束。
后果
- 谱系 bit 横跨 logical Session metadata,精确 cut 则只横跨含正文的 core、持久化、query 与 replay 值;v0 物理 header 保持不变。
- spawn 回放不变(cut 为 0)。fork 回放将子会话路由到自身的脚本;由
llm-replay测试中的一个回归用例覆盖(一个子会话 fixture,其播种前缀包含父会话的分片——推导出的子会话脚本必须排除它,不做 slice 时该用例会失败),以及通过共享 coordinator 约定执行的 JSONL 持久化往返测试。