Record fork and mixed spawn+fork snapshot scenarios
记录 fork 与混合 spawn+fork 快照场景
The [seed-boundary Agent Note](2026-06-22-fork-child-replay-seed-boundary.md) made fork-child replay route correctly: `dsh-llm-replay` derives a child's script from the events at or after its persisted `seedLength` boundary, so a fork child's inherited parent prefix is not replayed as the child's own model calls. But it shipped with **no recorded fork scenario** — the slice was exercised only by `llm-replay`'s unit t
English
Problem
The seed-boundary Agent Note made fork-child replay route correctly: dsh-llm-replay derives a child's script from the events at or after its persisted seedLength boundary, so a fork child's inherited parent prefix is not replayed as the child's own model calls. But it shipped with no recorded fork scenario — the slice was exercised only by llm-replay's unit tests (a synthetic child fixture) and a persistence round-trip test. The full-transcript snapshot tier, the one net that boots the real acp-agent and replays an end-to-end nested transcript, had only spawn children (subagent-spawn, subagent-multi). A fork-routing regression that left the unit tests green would still have escaped the tier built to catch transcript regressions.
The snapshot infrastructure to express a fork scenario was already in place — both in-process backends are wired into cordis.yml / cordis.snapshot.yml as two model-facing tools (subagent → spawn, subagent_fork → fork), the harness harvests every child log, and replay forwards per-child fixtures keyed by seedLength. What was missing was a recorded scenario that drives a fork child through it.
Decision
Record two scenarios against the real API, both replayed keyless in the default gate:
subagent-fork— the parent completes a turn that establishes a fact, then delegates one subtask viasubagent_fork. The fork child inherits the conversation (its log carries a non-zeroseedLength), so it can answer from the parent's context. This is the focused regression: the child fixture'sseedLengthis the boundary the replay slice depends on, recorded from a real fork rather than hand-synthesized.subagent-mixed— the parent completes a turn, then delegates once viasubagent(a fresh spawn child,seedLength0) and once viasubagent_fork(a fork child, non-zeroseedLength) in one transcript. This is the mixed spawn+fork scenario the seed-boundary and per-session-replay Agent Notes both named as a future addition: one transcript exercises both transports and both branches of the slice (seedLength0 = no-op,seedLength > 0= trim the inherited prefix), with the two children ordered spawn-then-fork bycreatedAt.
Why a completed turn-1 is required
The fork backend seeds the child with the parent's balanced completed-turn prefix. A parent that forks on its very first turn has no completed turn to inherit, so the seed is empty (≡ a fresh spawn, seedLength 0) — which would NOT exercise the slice. Both scenarios therefore use a two-prompt input: the first prompt completes a turn (establishing a codeword the child is later asked to recall), the second delegates the fork. The recalled codeword in the child's transcript is incidental to the model's behavior; the load-bearing artifact is the child fixture's recorded seedLength, which the replay slice consumes.
Consequences
- The fork-routing slice is now guarded at the full-transcript tier, not just by unit tests. Removing the
slice(seedLength)(replaying the whole child log) turns both new scenarios red — the fork child receives the parent's recorded chunks instead of its own — proving the guard bites (verified red→green when the scenarios landed). subagent-mixedis the first snapshot scenario to drive two different subagent backends in one transcript, exercising the per-session replay keying across a spawn and a fork child simultaneously.- Out-of-process (ACP) subagent replay remains a different shape (each child is its own process with its own replay) and is still tracked as
TODO(acp-subagent-replay)— these scenarios are in-process only. - Re-recording (
pnpm run test:snapshot:record) regenerates all four fork/spawn fixtures from the live API; the two new scenarios self-skip without a key like every recorded scenario.
中文
问题
seed 边界 Agent Note(agent 决策记录)让 fork 子项重放能够正确路由:dsh-llm-replay 根据持久化 seedLength 边界处及其后的事件派生子项脚本,因此 fork 子项继承的父前缀不会作为子项自身的模型调用重放。但落地时没有记录式 fork 场景——slice 只由 llm-replay 单元测试(合成子项 fixture(测试前置数据))和持久化往返测试覆盖。完整 transcript(文本记录)快照层——会启动真实 acp-agent 并重放端到端嵌套 transcript 的那张网——只有 spawn 子项(subagent-spawn、subagent-multi)。如果 fork 路由回归没有让单元测试变红,它仍会逃过专为捕获 transcript 回归而构建的这一层。
表达 fork 场景所需的快照基础设施已经就位:两个进程内后端都在 cordis.yml / cordis.snapshot.yml 中以两个面向模型的工具接入(subagent → spawn、subagent_fork → fork),harness 会收集每个子会话的日志,回放按 seedLength 为键转发各子会话的 fixture。缺少的是一个已记录的场景来驱动 fork 子会话走完这条路径。
决策
针对真实 API 记录两个场景,均在默认门禁中以无密钥方式回放:
subagent-fork:父会话完成一个轮次以建立一个事实,然后通过subagent_fork委派一个子任务。fork 子会话继承对话(其日志携带非零seedLength),因此可以从父会话的上下文中作答。这是聚焦的回归守卫:子会话 fixture 的seedLength就是回放切片所依赖的边界,来自真实 fork 的记录而非手工合成。subagent-mixed——父项完成一个轮次,随后在同一 transcript 中通过subagent委托一次(全新 spawn 子项,seedLength为 0),再通过subagent_fork委托一次(fork 子项,seedLength非零)。这是 seed 边界与逐会话重放 Agent Note 都点名作为未来新增项的 spawn+fork 混合场景:一份 transcript 覆盖两种传输方式和 slice 的两个分支(seedLength为 0 = 无操作,seedLength > 0= 裁剪继承前缀),两个子项按createdAt排列为先 spawn、后 fork。
为什么需要一个已完成的第一轮次
fork 后端使用父项的已配平完整轮次前缀为子项提供 seed。父项若在第一个轮次就执行 fork,没有已完成轮次可供继承,因此 seed 为空(≡ 全新 spawn,seedLength 为 0)——这不会覆盖 slice。因此,两个场景都使用双提示词输入:第一个提示词完成一个轮次(建立稍后要求子项回忆的 codeword),第二个提示词委托 fork。子项 transcript 中回忆出的 codeword 只是模型行为的附带结果;承载关键约束的产物是子项 fixture 中记录、由重放 slice 消费的 seedLength。
后果
- fork 路由切片现在由全 transcript 层守卫,而不仅仅是单元测试。移除
slice(seedLength)(回放整个子会话日志)会让两个新场景变红——fork 子会话收到的是父会话记录的分片而非自己的——证明守卫确实生效(场景落地时已验证红→绿)。 subagent-mixed是第一个在同一个 transcript 中驱动两种不同 subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的逐会话回放键控。- 进程外(ACP(Agent Client Protocol))subagent 回放形态不同(每个子会话是独立进程、有自己的回放),仍以
TODO(acp-subagent-replay)跟踪——本文场景仅限进程内。 - 重新录制(
pnpm run test:snapshot:record)会从真实 API 重新生成全部四个 fork/spawn fixture;两个新场景在无密钥时自动跳过,与所有已录制场景一致。