深入浅出 DeepSeek Harness 第四部:事件账本——Session是append-only日志 Chapter 27

旧会话打开时,版本号说了算

磁盘上的 Session 日志按代际冻结:v0 是 session.jsonl,v1 起是 session.vN.jsonl,每代配一个永不改动的物理 codec。打开旧文件时 readHeader 只读首行 version 就分类——current 直读、migration-required 编译相邻迁移链单行流式翻译、unsupported 拒绝、malformed 拒收。迁移链编译期强制 v→v+1 四边齐全,任何阶段出错都被包成"X refuses this format"响亮拒绝;写入侧永远只编码 v4。

Written in Chinese

Source version
dsh-v0.1.7-rc.2
Verified on
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,你打开一个半年前的会话。磁盘上那个文件可能叫 session.jsonl——不是 session.v4.jsonl——首行 header 里写着 "version": 0。你以为打开旧会话就是”把旧文件读进来”;其实运行时做的第一件事,是只读首行那个 version 数字,连事件体都不碰:版本是 4 就直读,版本更老就当场编译一条四段迁移链,逐行把 v0 事件翻译成 v4 逻辑事件,内存里最终只有一个 v4 artifact。磁盘上那个旧文件一个字节都不会被改写——翻译发生在内存里,旧日志永远保持它出生时的样子。更反直觉的是:如果首行 version 比当前 build 写的还新,运行时不猜、不凑,直接拒绝——“stored Session uses newer format vX; this build writes v4”。

这一章接着第 26 章的 crash 恢复:上一章讲日志损坏时怎样就地修补;这一章讲日志没坏、只是老时,版本号这套冻结编解码体系怎样决定翻译还是拒绝。

Session 格式版本体系:左侧物理世代文件 session.jsonl(v0) 到 session.v4.jsonl 各配冻结 codec;readHeader 只看首行 version 四态分类(current/migration-required/unsupported/malformed);迁移链 v0→v1 恒等升级、v1→v2 累积 assistant chunk run、v2→v3 提升 system prompt 并把 agentPreset code 改名 ptc、v3→v4 提升原生 tool-role 且必须显式传入历史 child facts;createRestore 单行流式 decodeRow,compact run 不展开直传,inherited cut 预声明 finish 对账;内存里只有 v4 逻辑 artifact,经 Session.fromRestore detached 终校验;写入侧永远只编码 v4。

版本号:一个单调整数,bump 由 writer 决定

当前逻辑格式版本就一行:export const SESSION_FORMAT_VERSION = 4。但它上面挂着一段近乎政策文件的注释,把”什么时候才允许 bump”写得非常死:版本是单调整数,没有 major/minor 之分;是否需要 bump 由 writer 发出了什么决定,绝不由”新 reader 能不能勉强接受”决定——原文是”bump exactly when an older runtime could no longer handle a new log with full semantic correctness (‘parses without error’ is not correctness — silently skipping content that shapes reconstruction is a wrong read)”。

也就是说:“能解析”不等于”读对了”。一个旧运行时如果能打开新日志、却静默跳过了某些影响重建的内容,那就是一次错误的读——这种情况必须 bump。只有四类结构变化够得上 bump 门槛:header 形状、事件信封、核心事件语义、surface 机制(surface 事件集与 surfaceOp 变体)。加一个普通事件类型不 bump——词汇增长由每个事件的 ignorable 守卫兜底。注释最后一句是工程师的直觉:“When in doubt, bump: a near-identity upgrade step is almost free, a missed bump makes older runtimes read new logs wrong silently.”

为什么这套政策要写死在源码注释里? 因为格式迁移是单边承诺:旧 reader 读新日志一旦出错,损害是静默的——用户不会报错,只是历史被悄悄改样。宁可多一条近乎恒等的迁移边(成本几乎为零),也不放过一次会让旧运行时误读的机会。这是整个版本体系的伦理底色:误读比拒绝更危险。

本节源码依据(3 处)

冻结 codec:每代一个,发布后永不动

物理层的抽象是 SessionFormatCodec:每个已发布版本一个 codec,只干两件事——decodeHeader(value) 把物理首行翻译成不依赖事件体的逻辑头;createDecoder(headerValue, recovery) 建一个逐行解码器,recovery 是一次 restore 选定的失败策略:'strict' 还是 'recoverable'。当前 build 的 codec 同时兼任编码器(SessionFormatCurrentEncoder:encodeHeader + encodeEvent)。

关键约束是”冻结”二字。看 v4 codec 的实现就懂了:它物理上复用 v2 codec 的行框定——physicalV2 把 version 字段拨成 2 交给 v2 codec 解码,再把结果 version 拨回 4;在 v2 行解码的每一步外面,只套上 v4 特有的 admission 检查(原生 tool-role 消息、retired syntax、system 消息字段、developer 载荷、fork 结果)。encoder 同理:先按 v2 框定编码,再钉上 version=4。v4 的物理行格式与 v2 完全相同,差异只在校验规则——这就是”近恒等迁移边几乎免费”的实例。

世代物理文件名codec 包该代物理格式要点
v0session.jsonlsession-format-v0-to-v1初代行格式,assistant chunk 原样成行
v1session.v1.jsonlsession-format-v0-to-v1与 v0 同框定,载荷校验收紧
v2session.v2.jsonlsession-format-v1-to-v2assistant chunk run 累积成 compact run
v3session.v3.jsonlsession-format-v2-to-v3canonical envelope + PTC 词汇
v4(当前)session.v4.jsonlsession-format-v3-to-v4行框定复用 v2,新增原生 tool-role admission

为什么 codec 必须冻结? 因为旧日志的唯一保证就是”当年写下它的那个 codec 还在”。如果 v2 codec 被后来的维护者顺手”修正”了一个校验规则,所有 v2 旧日志的可恢复性就悬空了——迁移链的可信度建立在”历史代码永不漂移”上。所以每个 codec 都挂在独立包里,发布即冻结,新功能只能加新边,不能改老边。

本节源码依据(5 处)

相邻迁移链:编译期就把”断链”掐死

迁移不是一张”任意版本到任意版本”的全图,而是一条相邻链。defineSessionFormatMigration 第一行检查就是硬规则:to !== from + 1 直接抛”must declare adjacent vX->vX+1”。然后链编译器把所有声明收进 Map:同一边重复报 duplicated、名字重复报 duplicated;再从 0 到 currentVersion 逐版清点——缺任何一边,报”Session migration vX->vX+1 is missing”;如果有人登记了一条指向 current 之后的边,报”does not lead to current v4”。

也就是说,这套系统在启动构造 catalog 的那一刻就保证:v0 到 v4 之间恰好四条边,不多不少,每条都是相邻的。运行时不需要任何”选路”逻辑——从 storedVersion 到 currentVersion 就是数组切片。

readHeader 结果条件运行时动作
currentstoredVersion === 4fast-path:codec 直解 + 当前词汇校验,不进迁移流
migration-required0 ≤ storedVersion < 4编译迁移流,逐行翻译后再过当前校验
unsupportedstoredVersion > 4,或无对应 codec拒绝,错误消息带”this build writes v4”
malformed首行连 version 都读不出拒收,reason 带原始错误

为什么只允许相邻边、不允许跳代? 因为每一代的语义变化都需要上一代的上下文才能正确翻译——v3→v4 要重写 v3 的 message source、要按 v3 的 turn/step 状态机合成 interrupted 标记,这些动作只有站在 v3 肩上才做得对。跳代迁移等于把四代的语义差异压缩进一个函数,每加一代复杂度都爆炸。相邻链让每条边只关心”我这边比上一边多懂什么”,这是可维护性的硬约束,不是审美选择。

本节源码依据(5 处)

打开旧会话:只读首行,再单行流式翻译

readHeader 的第一件事是 inspectSessionFormatVersion——它只要求首行是个 JSON 对象、version 字段是个合法非负整数,事件体一个字都不读。这意味着列出一个会话目录、判断哪些会话需要迁移,成本就是读第一行。

需要迁移时走 createRestore:它是单行流式的——解码器每解出一行物理记录,就立刻喂给编译好的迁移流,边翻译边往下走,不需要把整个文件读进内存。迁移流是一条管道:最上游是物理 codec 的行解码器,下游依次挂着 v0→v1、v1→v2、v2→v3、v3→v4 四个 stage,每个 stage 的 transformEvent 处理完再调 context 的 emitEvent/emitRun 传给下一段。compact run(比如 v2 累积的 assistant chunk run)可以被没有专属处理器的边原样直传,不必展开成事件数组——大日志的流式性能就靠这个。

整条管道还有一个对账机制:inheritedEventCount(fork 继承前缀的切分点)可以在 header 阶段预声明,也可以推迟到 EOF 才知道;无论哪种,每个 stage 的 finish() 返回值必须与预声明一致——不一致就报”changed its predeclared inherited cut”。这防的是某条迁移边偷偷改了 fork 血缘的切分位置,让 resume 边界错位。

为什么翻译要单行流式、而不是整文件读完再迁? 因为会话日志可以很大——十几万事件是常态。整文件展开翻译意味着双倍内存峰值(原文 + 译文同时在内存里),而且崩溃恢复场景下(第 26 章)损坏点之后的行本来就要边读边丢。流式让翻译的内存占用与行长成正比,而不是与文件大小成正比。

本节源码依据(5 处)

拒绝语义:任何阶段出错,都包成”这代格式我不收”

迁移管道里最关键的不是怎么翻,而是怎么败。throwUnsupportedRefusal 是统一收口:任何 stage 在 createStage、transformEvent、transformRun、finish 里抛出的任何错误,都会被包成 SessionFormatUnsupportedMigrationError,消息格式是”<edge name> refuses this format vN <subject>: <detail>”——除非错误本身已经是 UnsupportedMigrationError(直接透传)。

为什么要把所有错误统一升维成”不支持”?因为对调用方来说,“这个旧会话我翻译不了”和”这个旧会话文件坏了”的处置是一样的:都是拒收、告知用户、保留原文件。混出一个内部校验错误没有任何意义——它只会泄漏实现细节。而 SessionFormatUnsupportedMigrationError 这个子类名本身就告诉上层:这是一次政策拒绝,不是 bug。

头迁移时还有第二层保险:每条边翻译出来的新 header,version 必须恰好等于该边的 toVersion,而且必须过该边自己的 validateTargetHeader——翻错了版本号形状、或者产出了本边 writer 发不出的 header,都在边界上被拦下。

本节源码依据(4 处)

v3→v4:唯一需要”外部证据”的迁移边

四条边里,v3→v4 是最特殊的。裸的 sessionFormatV3ToV4 声明里,createStage 直接抛错:“V3 catalog migration requires explicit historical child facts, including an empty array for a parent without children”。也就是说——你不能直接拿一个 v3 会话就开始翻译,你必须先通过 createSessionFormatCatalogWithChildren(children) 把这个 parent 的历史 child 证据传进来,哪怕没有 child 也要传个空数组。

为什么?因为 v3→v4 翻译的不只是事件:它还要在流末尾补写 subagent/catalog 事件——把 parent 磁盘上那些老 child 的出生事实(childId、childCreatedAt、mode、label)合并进 parent 的翻译产物里。合并时还要对账:与 parent 日志里已有 catalog 冲突(创建时间不一致、mode/label 不一致)就报”conflicts with its parent catalog”。收集这些 child 证据的是另一个专用 catalog——historicalSessionFormatCatalog,它把自己的 currentVersion 钉在 3,只做 v0–v3 的解码收集,绝不递归地发布 v4 parent,避免父子循环翻译。

边内翻译本身的动作也值得看:v3 的 message source 要逐类重写、v3 的 tool/result 要 lift 成 v4 的原生 tool-role、未知事件类型若不是 ignorable 就直接拒绝(“format v3 contains unknown event type … at seq …”);还会观察 turn/step 状态机,发现进程在 turn 中间重启过,就合成一条 {reason: {kind: 'interrupted'}} 的 turn/end——这正是第 26 章就地修补在迁移层的对应物。

迁移边头部变化事件体动作特例
v0→v1version 拨 1恒等边,重排载荷校验无
v1→v2version 拨 2assistant chunk 累积成 compact run无
v2→v3version 拨 3,agentPreset: 'code' 改名 'ptc'canonical envelope + PTC 词汇无
v3→v4version 拨 4message source 重写、tool-role lift、未知事件拒绝、interrupted 合成、末尾补 catalog必须显式 child facts
本节源码依据(6 处)

终校验与写入侧:翻译完还要过当前词汇表

翻译出的 v4 artifact 不是直接可用——最后还要过一道 validateInstalledCurrentSessionArtifact:它把整个 artifact 交给 Session.fromRestore(..., 'detached', ...) 做一遍完整的当前词汇表校验(header、事件信封、消息投影、surface 操作、seed 切分)。detached 模式意味着只校验不入册——翻译产物一旦过不了当前规则,整个 restore 失败,不留半成品。

写入侧则永远简单:encodeCurrentHeader 先断言 header version 就是当前版,编码器产出后再断言一次——写出去的永远是 v4。运行时持久化在真正 restore 旧文件时用的是 recovery: 'recoverable' 加 validation: 'transformed' 的组合:recoverable 允许尾部损坏行被裁剪(第 26 章的策略),transformed 表示翻译产物只在翻译完成后才过当前校验、对原始迁移产物只做 codec 层校验——这给”老格式边缘 case”留了一个宽容度分层。

本节源码依据(4 处)

小结

  1. 打开旧会话 ≠ 读旧文件:磁盘文件原样不动,内存里是逐条翻译出的 v4 逻辑 artifact;翻译在内存,持久化零改动。
  2. 版本号是 writer 的单边承诺:bump 只由结构变化触发,“能解析”不算”读对了”;拿不准就 bump。
  3. 迁移链在启动时就被编译死:相邻四边缺一报错,运行时零选路;任何阶段错误统一升维成”这代格式我不收”。
  4. readHeader 是一次单行成本的分类:四态分流(current / migration-required / unsupported / malformed),事件体在分类阶段零读取。
  5. v3→v4 是唯一带外部依赖的边:裸迁移拒绝开工,必须显式注入历史 child facts——翻译不只是格式转换,还是血缘补写。

下一章看这套账本被接管之后的事:第 28 章 Resume、Fork、Compaction 三条路径怎样在 append-only 日志上完成所有权转移、分叉与上下文压缩。

只读验证

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
git -C "$repo" rev-parse HEAD   # 应输出 477b4f420553e8a52c2fbccc464d7561b239c443

# 版本号与 bump 政策
grep -n "SESSION_FORMAT_VERSION = " "$repo/packages/core/session/src/types.ts"
# 相邻强制与缺边拒绝
grep -n "must declare adjacent\|is missing\|does not lead to current" "$repo/packages/session/session-format/src/chain.ts"
# v3→v4 裸迁移拒绝
grep -n "requires explicit historical child facts" "$repo/packages/session/session-format-v3-to-v4/src/migration.ts"
# 文件命名世代
grep -n "session.v\${" "$repo/packages/session/session-format/src/filename.ts"