Zero 架构 · 第七课

会话持久化与 /rewind:把「磁带」落盘,再倒带

第一课讲过循环里那条只追加的 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 解释的载荷
}

事件类型是一个开放枚举:用户消息、工具调用、工具结果、权限决定、用量、压缩标记、 checkpointrewind 标记…… store.go:27 这正是第一课那条内存磁带的持久化版本:状态 = 对事件日志的一次 fold/resume 不过是「读回 events.jsonl、重放成对话」。

谁是真相 注释点明:事件日志才是排序的唯一真相,元数据里的 EventCount 只是缓存 (store.go:584)。崩溃恢复时,以日志实际内容为准 —— 这是事件溯源系统的铁律:派生状态可以重算,日志不可篡改。

核心洞察二:改文件之前,先拍一张「之前」

第二课讲过每个改文件的工具都走 executeToolCall 的关卡。这里补上一环:在真正动手改 之前,先给要改的文件拍一张 before 快照 —— CaptureToolCheckpoint checkpoint.go:63。 它做两件事:

  1. 把每个目标文件的当前内容存成一个 blob,文件名就是内容的 sha256 —— 即 内容寻址(content-addressed)checkpoint.go:55
  2. 追加一条 EventSessionCheckpoint 事件,里面索引这些 blob(哪个路径 → 哪个 hash)。 checkpoint.go:31

每个 CheckpointFile 还记了两个特殊位:Absent(文件之前不存在 → 回滚时应 删除它)、Skipped(超过 5 MiB 上限,没存 → 回滚时无法恢复,如实标记而非假装成功)。 checkpoint.go:20 capture 是尽力而为的:读不了的文件记为 skipped,绝不因此让工具调用失败。

内容寻址的两个红利天然去重:同样的内容存两次 = 同一个 blob,磁盘不翻倍。② 但也带来一个 孤儿窗口:一个 blob 在「写完」到「被某个事件引用」之间,是可被回收误删的。 所以 capture 全程持一把会话锁,把「写 blob」和「追加引用它的事件」圈在一起, 不给并发的 pruneOrphanBlobs 留缝 (checkpoint.go:70)。

倒带:不存「目标态」,而是从「之后发生的事」反推

/rewindtargetSeq 的精妙之处:它并不需要保存过目标点的完整快照。 它靠目标点之后每一次改动的 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 子会话怎么挂上来?」