Zero 架构 · 第七课
第一课讲过循环里那条只追加的 messages 磁带 —— 但它活在内存里,进程一退就没了。
这一课讲 Zero 怎么把它落盘(于是有了 /resume),又怎么让它能倒带
到任意历史点、连同工作区文件一起回滚(于是有了 /rewind)。
两个特性,一个底座:事件溯源(event sourcing)。
对齐 mission:一个真实的 coding agent 会改用户的文件。既然会改,就得能撤销 —— 不只是撤销对话,还要撤销磁盘上的副作用。这一课是「agent 如何对自己的破坏负责」。
一次会话的真身是磁盘上的 events.jsonl(EventsFile,
store.go:21)—— 每行一个 JSON 事件,
只追加、永不改写。每个事件形状统一:
store.go:168
type Event struct {
ID string // 全局 id
SessionID string
Sequence int // ← 单调递增,排序的唯一真相
Type EventType // message / tool_call / tool_result / session_checkpoint / …
CreatedAt string
Payload json.RawMessage // 按 Type 解释的载荷
}
事件类型是一个开放枚举:用户消息、工具调用、工具结果、权限决定、用量、压缩标记、
checkpoint、rewind 标记……
store.go:27
这正是第一课那条内存磁带的持久化版本:状态 = 对事件日志的一次 fold。
/resume 不过是「读回 events.jsonl、重放成对话」。
EventCount 只是缓存
(store.go:584)。崩溃恢复时,以日志实际内容为准 ——
这是事件溯源系统的铁律:派生状态可以重算,日志不可篡改。
第二课讲过每个改文件的工具都走 executeToolCall 的关卡。这里补上一环:在真正动手改
之前,先给要改的文件拍一张 before 快照 —— CaptureToolCheckpoint
checkpoint.go:63。
它做两件事:
sha256 —— 即
内容寻址(content-addressed)。
checkpoint.go:55EventSessionCheckpoint 事件,里面索引这些 blob(哪个路径 → 哪个 hash)。
checkpoint.go:31
每个 CheckpointFile 还记了两个特殊位:Absent(文件之前不存在 → 回滚时应
删除它)、Skipped(超过 5 MiB 上限,没存 → 回滚时无法恢复,如实标记而非假装成功)。
checkpoint.go:20
capture 是尽力而为的:读不了的文件记为 skipped,绝不因此让工具调用失败。
pruneOrphanBlobs 留缝
(checkpoint.go:70)。
/rewind 到 targetSeq 的精妙之处:它并不需要保存过目标点的完整快照。
它靠目标点之后每一次改动的 before 快照反推出目标态 ——
RestoreToSequence
rewind.go:29:
取出 targetSeq 之后的所有 checkpoint,
按「离目标最近的先处理」遍历,
每个路径只认第一次见到的(=最接近目标的)那张 before 快照,
更新的快照全部忽略。
道理:一个文件在目标点之后可能被改了 5 次,产生 5 张 before 快照。离目标最近那一次的 before, 正是目标点时刻的内容。所以「按最接近目标优先、每路径短路取第一张」就还原出了目标态。 rewind.go:55
ApplyRewind 把整个倒带做成一把锁下的四步原子操作
rewind.go:235:
| 步 | 动作 |
|---|---|
| ① | 还原工作区文件到目标态(应用 before 快照,原子写:临时文件 + rename) |
| ② | 截断事件日志,只留 Sequence ≤ targetSeq(原子重写 jsonl) |
| ③ | 回收现在没人引用的孤儿 blob(pruneOrphanBlobs) |
| ④ | 追加一条 EventSessionRewind 标记(倒带本身也是一个事件) |
四步只锁一次,用的都是 *Locked 内部变体,防止并发写在子步骤间插进来
(也因此不能重复加这把不可重入的锁,否则自死锁)。
rewind.go:239
注意第 ④ 步:连「倒带」这件事本身都被记成一个事件 —— 日志永远只追加,连回滚也不例外。
resolveWithinWorkspace 会紧贴着 mutation重新解析并确认路径落在
工作区内 —— 纵深防御:即便某个 checkpoint 事件被篡改(../ 路径穿越)或工作区内有指向
外部的符号链接,也绝不越界写删。代码坦诚地标注了残留的 symlink-swap TOCTOU 窗口及其低风险前提。
rewind.go:80
损坏的 checkpoint 载荷是硬错误而非跳过 —— 跳过会让更新的快照错误地胜出,还原出比
用户要求更晚的状态。
还记得 cancelRun 里那个 flushRunIDs 吗(model.go:4042)?现在它有意义了:
一次被取消的 run,它在每次改文件前拍的 checkpoint blob 已经落盘;把它的 runID 记下来、
保证其最终事件仍被排干写入,/rewind 才引用得到那些 blob,而不至于让它们变成磁盘上的
孤儿。取消的善后、持久化的完整、倒带的可用,是同一根链条。
一次 Zero 会话在磁盘上的「真相之源」是什么?
checkpoint 的 blob 用什么当文件名,带来什么红利?
倒带到 targetSeq 时,某文件的目标态是怎么还原的?
less +168 ../zero/internal/sessions/store.go # Event 结构 + 事件类型枚举(:27)
less +574 ../zero/internal/sessions/store.go # appendEventLocked:日志是排序真相
less +63 ../zero/internal/sessions/checkpoint.go # CaptureToolCheckpoint:拍 before 快照
less +29 ../zero/internal/sessions/rewind.go # RestoreToSequence:最近优先反推
less +235 ../zero/internal/sessions/rewind.go # ApplyRewind:一把锁下的四步原子倒带
读的顺序:先认 Event 那六个字段和一长串 EventType —— 会话的一切都是这些事件的序列。
再看 CaptureToolCheckpoint 怎么「存 blob + 追加索引事件」。最后重点读 ApplyRewind
那四步,以及 RestoreToSequence 里「closest-to-target wins」的短路循环 —— 整套时间旅行的
智慧就在这两处。
replay.go 怎么把事件日志重放成 messages 磁带的(/resume 的另一半)?」、
「会话锁是进程内 mutex + 文件锁(filelock_unix.go)两层,分别防什么?」,
或者「EventSessionFork / EventSessionChild 是干嘛的 —— 会话怎么分叉、specialist 子会话怎么挂上来?」