持久化治理:版本、fork 边界与拒绝解读
日志格式怎么演进,分叉边界怎么定,读不懂的数据宁可拒绝。
先玩再讲。上方是磁盘上的一份会话日志:一行 header 加一串事件。下面两个加载器同时读它:左边是 DSH 的「拒绝解读」式,读不懂就报错;右边是很多系统的惯用做法,「best-effort 跳过」式,读不懂就跳过接着读。三个场景各有一份问题日志,点播放,看同一份数据在两种加载器手里各是什么下场。
coordinator.ts 第 79 与 1064 行的源码模板。真实 DSH 没有右边这个 best-effort 加载器,它是用来对照的反面教材。背景一句话:DSH 的会话日志是唯一真源,恢复、分叉、回放全从它派生(见 上一课的不变量)。真源要活得比任何一个版本的程序都久,所以格式演进不是小事:今天写的日志,明年的 harness 要能读;反过来,新版本写的日志落到老版本手里,老版本得知道自己读不了。
DSH 的版本方案朴素到只有一个数字:SESSION_FORMAT_VERSION,当前是 0,定义在 packages/core/session/src/types.ts 第 56 行。没有 1.2.3 这种大小版本。设计笔记的理由是:某一步升级能不能自动转换,由那一步的升级器写不写得出来决定,两级编号等于提前承诺了一件设计时根本不知道的事。
升不升版本的标准很明确:当且仅当老版本运行时无法在语义上完全正确地处理新日志时,才必须升。「解析不报错」不算数,能读完但重建出错误的会话,这就是读错了。拿不准就升,因为一个近似恒等的升级器几乎零成本,漏升一次却会让老版本静默读坏数据。
打开一份存储的日志时,先比版本号,三种结果对应三种完全不同的处理:
最值得咂摸的是「拒绝,并说明方向」这一格。早先的 assertVersion 对任何版本不匹配都抛同一条含糊的错误,改动之后报错分方向:日志比你新,明说「由更新的 harness 写入,请升级」,并附上原始日志文件的路径;日志比你旧但升级链断了,就说「本构建没有它的升级路径」。用户看到的永远是「该升级了」,绝不是「文件损坏」。数据明明没坏,报损坏是冤枉它。
往旧读的方向还有个细节。旧日志被新版本打开,升级器链只在内存里逐级转换,看一眼不落盘;只有用户真的继续这个会话,转换结果才原子替换写回磁盘,原文件留备份。设计笔记否决过「查看时自动迁移落盘」:打开即改写等于把读操作变成破坏性写操作,转换器有 bug 会在浏览时损坏日志。
版本号管结构变更,管不了词汇增长:事件的种类由挂了哪些插件决定,一个整数描述不了它。DSH 的方案是逐事件标记。读取器遇到不认识的事件类型,默认整个会话拒绝恢复,除非那条事件的信封上带着写入方声明的 ignorable: true。已知词汇清单 KNOWN_SESSION_EVENT_TYPES 不是手写的,由脚本从全仓库所有事件声明合并生成,共 44 个类型,连同 946 行的持久化事件目录 docs/persistence-catalog.zh.md 一起,有专门的校验脚本保证不过期。
为什么默认必需、忘写标记宁可拒绝过头?设计笔记把这笔账算得很清楚。忘写 ignorable 的后果是一个本可恢复的会话被拒绝打开,用户不爽,是体验问题;反过来默认可忽略,同样的疏忽会静默恢复出一个内容残缺的会话,模型接着在错误的历史上继续工作,是安全事故。演示的场景 A 就是后者的现场:跳过一条装着用户消息的未知事件,恢复出来的对话里助手在回答一个不存在的问题。两种失败不对称,防线自然偏向吵闹的那边。
版本是一个单调整数不分大小版本。能不能自动升级由那一步的升级器存在与否表达,编号方案不提前承诺。当前 SESSION_FORMAT_VERSION = 0。
未知事件默认必需读取器拒绝解读含未知类型的日志,除非事件带 ignorable: true。方向性拒绝优于 best-effort parse,静默跳过就是读错。
fork 边界写两份header 的 seedLength 是持久的血统边界;日志里的 session/end-seed 事件给只拿到存储字节的读者用。seed.length 两个都替代不了。
分叉一个会话,就是把源会话到某个稳定位置为止的事件深拷贝一份,当作子会话的种子。麻烦在于边界:子会话日志的前半段是继承来的种子,后半段才是自己写的,两段在字节层面长得一模一样。哪里是分界线?
直觉答案是数一数构造时种子有几条,也就是 seed.length。这个答案错得很隐蔽:恢复的会话拿完整存储日志当构造种子,seed.length 算出的边界会随着每次重新打开往后跑;header 里的 seedLength 才一直保留着最初 fork 时的值。所以 DSH 把边界写了两份:第一份在 header,fork() 创建子会话时把 parentSession 和 seedLength 写进创建元数据;第二份在日志里,带种子的会话把 session/end-seed 事件作为自己的第一次实时写入追加在种子之后,专门服务只拿得到存储字节的消费方。
这条边界事件解决的问题很具体。种子历史里可能有一个没配对的 compaction/start,它到底是「上个生命周期崩在压缩中途」还是「此刻正在压缩」?光看字节分不出来。有了 session/end-seed,在它之前的未配对开启标记一律属于已结束的生命周期。类型定义的 JSDoc 里还有一句狠话:Session 的构造函数是唯一合法写入方,插件擅自追加一条,等于把它之前的所有实时工作静默归类成种子历史。
顺带一句大日志的恢复成本。恢复一个 130 万事件、62 MiB 压缩数据的会话,DSH 全程不物化整份明文,这轮优化把恢复准入从约 600ms 压到 263ms(Agent Note 2026-08-05)。校验和冻结一项没省:持久存储属于运行时边界,防线本身不动。
「按方向区分」在源码里是一个五行的小函数 sessionFormatVersionRefusal:版本号比自己大,文案是「由更新的 harness 写入,请升级 harness 打开」;比自己小又没有升级路径,文案是「本构建没有它的升级路径」。这个函数被协调器的加载检查和各存储后端共用,后端在解码任何结构之前就先用它拒绝外来版本,保证用户看到的永远是「请升级」,绝不是「损坏」。演示左侧那条红色报错就是它的原文。
出处:packages/session/session-persistence/src/coordinator.ts 第 77 至 81 行,核对日期 2026-08-13。
「未知事件默认拒绝」的守卫更短,整个就一个循环:类型在清单里,或者写入方标了 ignorable,放行;否则抛出,报错里带上事件类型、seq 位置和「大概率来自更新的 harness」的方向提示:
private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {
for (const event of events) {
if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue
throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`)
}
}
deepseek-harness-master,核对文件 packages/session/session-persistence/src/coordinator.ts,核对日期 2026-08-13。代码块保留源码原文。Grok Build
会话摘要读取走的是标准 serde 反序列化路径。persistence.rs 第 700 到 725 行的 resume 预读循环里,读不出或解析不了的 summary.json 直接 continue 跳过,不报错不留痕。
会话相关的 serde 结构体没有一处标 deny_unknown_fields,默认静默丢弃未知字段:新版本加的字段被旧版本读一遍再写回,就没了。这是快速迭代产品的常见取舍,只是它把格式演进的正确性交给了「新老版本别混用」这个假设。
Claude Code
会话以 .jsonl 存在 ~/.claude 下,支持 resume 和查看。书稿材料(claude-code-sourcemap 的 study 章节)覆盖了启动、上下文管理和可观测性,但没有出现会话日志格式版本协商或未知记录拒绝机制的还原代码。
基于已公开证据,读到不认识的数据时的行为未知。闭源产品可以靠「客户端总是最新版」兜底;DSH 是开源基建,各版本会长期共存,兜底假设不成立,所以把拒绝规则写进了读取器。
给你的插件事件选一个默认值
你写了个 DSH 插件,往会话日志里追加自定义事件 myplugin/audit,记录每次工具调用的审计信息。推演两种情况:不标 ignorable,用户把日志拷到一台没装你插件的同版本 harness 上打开,会发生什么?(提示:KNOWN_SESSION_EVENT_TYPES 由仓库内声明生成,仓库外插件的事件按构造就在清单之外。)标了 ignorable: true 又会怎样,你的审计信息在重建中去了哪里?两种选择各适合什么样的事件,用「丢了它会不会改变日志其余部分的解读」这把尺子量一量。
seedLength 加日志里的 session/end-seed,seed.length 谁也替代不了。