Zero 架构 · 第十五课

Fork 与 Child:一个会话怎么生出另一个会话

第七课把会话确立为一条 事件溯源的 events.jsonl 日志。这一课看它怎么繁殖 —— 一个会话变成两个。 Zero 有两条截然不同的路径,新手最容易把它们混为一谈,但结构上南辕北辙: Fork(/fork,store.go:390) 和 CreateChild(specialist 子会话,lineage.go:9)。

对齐 mission:一个现代 coding agent 有两种「再开一个会话」的需求。① 我想从某个时刻岔开试另一条路,又不弄脏原来的对话 —— 这是 fork。 ② 我想派一个 specialist 子 agent 去干一个 sub-任务(并行探索、swarm 成员),干完把结果带回来 —— 这是 child。 一条是复制历史后分岔,一条是挂一条全新的 sub-会话上来。看清这个区别,就看懂了 agent 的会话拓扑。

一句话对照

Fork(/fork)Child(specialist)
意图从某时刻岔开重走派子 agent 干 sub-任务
父历史整段复制进新会话不复制,子会话从空起
SessionKindfork :416child :31
链接事件只在 fork 自己写一条 :450父、子各写一条 :50,:53
关系导航岔开后基本独立进 Lineage/Tree 树

Fork:把历史复制一份,然后各走各的

Fork 读出父会话的全部事件,逐条 AppendEvent 进新会话 store.go:430 —— fork 拿到一份完整的对话历史副本,再从那里继续。 两个考究之处:

最后在 fork 自己的日志里写一条 EventSessionFork store.go:450,记下「从父会话哪个事件、第几号序列岔开」。fork 之后,两条时间线基本独立演进。

Child:不抄历史,而是在父子两端各钉一个书签

CreateChild 反过来 —— 子会话继承父的对话,它带着自己的 prompt 从头开始干活(它是个 specialist)。 它要解决的是另一个问题:子会话怎么「挂」到父会话上,让两边都知道彼此?答案优雅得只用了事件溯源本身:

// 同一条 link 事件,写进父、也写进子
store.AppendEvent(parent.SessionID, {Type: EventSessionChild, Payload: payload})  // lineage.go:50
store.AppendEvent(child.SessionID,  {Type: EventSessionChild, Payload: payload})  // lineage.go:53
为什么写两遍 —— 双向书签,而不是一张关系表 既然会话就是一条追加日志(第七课),那「把子挂到父上」就不需要另建一张关系数据库表: 只要把同一条链接事件追加到两条日志里就行 —— 父的日志里出现「我生了个子(childSessionId=…)」,子的日志里出现「我爸是谁(parentSessionId=…)」。 于是 resume 父会话时,TUI 顺着读父日志,一见 EventSessionChild 就渲染出一张 specialist 卡片;而子会话独立打开时,也知道自己的出身。 链接是数据,不是 schema。payload 里还带 rootSessionId/spawnedFromSequence lineage.go:204,把整棵树串起来。

拓扑:向上是链,向下是树

有了 ParentSessionIDRootSessionID lineage.go:38,导航是两个方向:

两者共用第七课的地基 Fork 和 Child 都只是在 store.Create + 事件日志之上各加一点动作:Fork 回放父事件 + 拷 blob;Child 追加一条双向链接事件。 没有第二套存储模型 —— 会话的「繁殖」完全长在第七课那条追加日志上。这就是事件溯源的复利:一个原语(append event)同时支撑了持久化、/rewind、fork、和子会话挂载。

动手回忆

Fork 与 Child 在「父会话历史」上的根本区别是什么?

Fork 复制父会话事件时,为什么唯独跳过 usage 用量事件?

子会话是怎么「挂」到父会话上、让两边都知道彼此的?

接下来该读的一手源码

less +390 ../zero/internal/sessions/store.go     # Fork:回放父事件 + 跳过 usage + 拷 blob + EventSessionFork
less +435 ../zero/internal/sessions/store.go     # 为什么不复制 usage:避免重复计费
less +9   ../zero/internal/sessions/lineage.go   # CreateChild:子会话 + 双写链接事件
less +204 ../zero/internal/sessions/lineage.go   # childLinkPayload:parent/child/root + spawnedFrom
less +127 ../zero/internal/sessions/lineage.go   # Tree:一次扫描建索引(O(n) vs 旧的 O(节点×总数))

读的顺序:先把 Fork 的循环看完 —— 注意它 copy 事件、跳过 usage、拷 blob。再读 CreateChild 那两行几乎一样的 AppendEvent(一条进父、一条进子)—— 这一处「同一事件写两遍」就是「子会话挂到父上」的全部机制。 最后看 Tree 的单次扫描注释,体会「事件溯源里,关系也是事件,不是表」。

我是你的老师 —— 随时问我。 适合现在追问: 「specialist 子会话跑起来后,进度(工具数/token)怎么实时回流到父会话的那张卡片(specialist_start/specialist_stop 事件 + TUI specialistTracker)?」、 「exec_session.goModeFork 是怎么从 CLI 一路接到 store.Fork 的?」, 或者换个大块:「压缩恢复的触发判定 —— compactor.recover 怎么认出上下文超限、又怎么保证只压一次?」