Zero 架构 · 第十五课
第七课把会话确立为一条 事件溯源的 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-任务 |
| 父历史 | 整段复制进新会话 | 不复制,子会话从空起 |
| SessionKind | fork :416 | child :31 |
| 链接事件 | 只在 fork 自己写一条 :450 | 父、子各写一条 :50,:53 |
| 关系导航 | 岔开后基本独立 | 进 Lineage/Tree 树 |
Fork 读出父会话的全部事件,逐条 AppendEvent 进新会话
store.go:430 —— fork 拿到一份完整的对话历史副本,再从那里继续。
两个考究之处:
EventSessionCheckpoint 事件(第七课)会指向不存在的 blob,fork 上做 /rewind 就读到空、悄悄跳过文件。拷了 blob,fork 的时间旅行才完整。
最后在 fork 自己的日志里写一条 EventSessionFork
store.go:450,记下「从父会话哪个事件、第几号序列岔开」。fork 之后,两条时间线基本独立演进。
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
EventSessionChild 就渲染出一张 specialist 卡片;而子会话独立打开时,也知道自己的出身。
链接是数据,不是 schema。payload 里还带 rootSessionId/spawnedFromSequence
lineage.go:204,把整棵树串起来。
有了 ParentSessionID 和 RootSessionID
lineage.go:38,导航是两个方向:
Lineage lineage.go:99 顺 ParentSessionID 一路走到根,还带环检测(seen map),防止损坏的元数据把遍历卡死。Tree lineage.go:127 建整棵子会话树。这里有个性能修正值得一看:旧实现每个节点都调一次 ListChildren,而每次 ListChildren 又全盘扫一遍磁盘上所有会话元数据 —— O(节点数 × 总会话数)。新实现一次扫描把所有会话按 parent 建索引,再在内存里递归lineage.go:144。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_start/specialist_stop 事件 + TUI specialistTracker)?」、
「exec_session.go 里 ModeFork 是怎么从 CLI 一路接到 store.Fork 的?」,
或者换个大块:「压缩恢复的触发判定 —— compactor.recover 怎么认出上下文超限、又怎么保证只压一次?」