Volume 2 · Chapter 20

持久化与恢复:日志如何落盘、崩溃如何修复

06 页讲了「进日志 = 已提交」,磁盘写入是异步的。这一章看异步的另一端:谁把日志写到磁盘、写什么格式、重启后如何恢复、崩溃遗留的未闭合 turn 如何修复。

(卷一连接点) 06 页 Session.append → session/event 通知 → 本域:session-persistence / session-persistence-jsonl / session-query-sqlite → (回心脏) 恢复的 seed 走 06 页构造器校验

示例本次示例:我们的 turn 1 如何落到磁盘

示例轨迹 20-1 · 11 页 seq 0-16 的 JSONL 追加
# 11 页示例轨迹 11-1 的每个事件 append 时:
# jsonl 后端订阅 session/event → 每事件一行追加到 sessions/<sid>.jsonl
# 本机目录:~/.dsh/sessions/(02 页示例轨迹里的真实目录)
# 磁盘内容(节选):
{ "type":"turn/start", "seq":0, "time":..., "data":{"turn":1} }
{ "type":"step/start", "seq":1, "time":..., "data":{"turn":1,"step":1} }
{ "type":"user/message", "seq":2, "time":..., "data":{...}, "surfaceOp":"append" }
...
{ "type":"turn/end", "seq":16, "time":..., "data":{"turn":1,"reason":{"kind":"completed"}} }
# 注意:追加顺序 = seq 顺序(append 即提交,落盘是异步跟随)
来源:11 页示例轨迹 11-1 + 20 页 firehose 机制
示例轨迹 20-2 · 进程在 seq 9 时崩溃,下次启动发生什么
# 崩溃:内存日志到 seq 9(tool/result),磁盘可能只到 seq 7
# 重启 → 恢复路径读 JSONL → seed = [seq 0..7](或到 9,取决于 flush)
# → 06 页构造器校验:无损 JSON ✓、seq 连续 ✓、surface ✓
# → 日志尾部有未闭合的 turn 1/step 1 + 未落结果的 tool/call(seq 8)
# → repair.ts(177 行)合成关闭事件:
#   tool/result(TOOL_OUTCOME_UNKNOWN)
#   step/end {turn:1,step:1}
#   turn/end {turn:1, reason:{kind:'interrupted'}}   ← loop 从不发出这个 reason
# → 会话恢复为闭合状态,可以继续新 turn
来源:20 页 §3 的恢复/修复 + 06 页构造器 + repair.ts

§1挂载条目

cordis.patch.yml(dsh-base bundle)里属于本域的四行:

  • session-persistence-jsonl → @deepseek-ai/dsh-session-persistence-jsonl——JSONL 后端
  • session-query-sqlite → @deepseek-ai/dsh-session-query-sqlite——SQLite 查询索引
  • session-checkpoint-policy → @deepseek-ai/dsh-session-checkpoint-policy(83 行)——按请求持久化检查点(06 页提过:loop 不在 turn 边界 flush,checkpoint 归它)
  • attachment-local → @deepseek-ai/dsh-attachment-local——附件(图片等)本地存储

§2包文件地图

包规模角色
packages/session/session-persistence/5 文件 / 627 行Service Definition:SessionPersistence extends Service(src/index.ts:135)——抽象的存储生命周期契约(create/open/flush/stat/list、版本门在 storage-contract.ts:45)
packages/session/session-persistence-jsonl/13 文件 / 4963 行(src/,含 testing/)Provider:JSONL 后端——不写 static inject,靠基类 SessionPersistence 构造器注册(src/index.ts:245 起),把日志逐事件追加到 .jsonl 文件
packages/session/session-query-sqlite/—查询侧:SQLite 索引(会话列表、搜索)——与持久化后端解耦,是「查询」不是「存储」
packages/session/session-checkpoint-policy/1 文件 / 83 行持久化检查点策略:决定何时 session/flush
packages/attachment/attachment-local/9 文件 / 1552 行附件本地存储(图片等非文本数据走这里,不进 JSONL)

§3机制:firehose → flush → 恢复 → repair

整条链分四段,与 06 页的四个对外事件一一对应:

  1. 追加写(firehose):jsonl 后端订阅 session/event,把每个事件规范序列化追加到 <sessionId>.jsonl。事件在内存里是深冻结的、无损 JSON 校验过的——后端可以逐字写(06 页 append 的「快照」就是为这一刻准备的)。
  2. 检查点(flush):session/flush 是 awaited 的并行持久化检查点;checkpoint-policy 决定「按请求」触发它——loop 不在 turn 边界 flush(06 页 types.ts 里 turn/end 的 JSDoc 原话)。
  3. 恢复(seed):重启时读 JSONL → 构造 seed 事件数组 → 走 06 页构造器同一条校验链(无损 JSON、seq 从 0 连续、surface 校验)。格式版本上:旧格式(v0–v3)日志走 session-format 的迁移链升到当前 v4,再交给同一条校验;只有「比本 build 更新」的日志被直接拒绝——sessionFormatVersionRefusal(session-persistence/src/errors.ts:133)给两种情形写了两句不同的话:更新的日志提示「升级 harness」,过旧且无升级路径的提示「本 build 不带升级路径」。
  4. 修复(repair):packages/core/session/src/repair.ts(177 行)扫描日志尾部未闭合的 turn/step/工具调用,合成确定性的 tool/result(TOOL_NOT_STARTED/TOOL_OUTCOME_UNKNOWN)、step/end、turn/end(reason: interrupted)。interrupted 这个 reason 是持久化后端专用的 crash 标记,loop 从不发出(0.1.7 起同一条机制还多了一个 forked 生产者:fork seed 若切在源会话的开放 turn 中间,也用同一套合成事件闭合)。

§4关键代码

packages/session/session-persistence-jsonl/src/index.ts后端注册与格式版本守卫245-252, 258-278
245class JsonlSessionPersistence extends SessionPersistence {没有 static inject —— 靠基类注册
246  static Config: z<Config> = z.object({
247    root: z.string().required(),
248    compression: JsonlCompressionSchema,
249  })
252  override readonly name = 'session-persistence-jsonl'
266  private readonly coldLogMemo = new Map<SessionId, StoredLog>()0.1.7:冷读一次即缓存(按 revision 守卫)
268  private readonly migrationPreparations = new Map<SessionId, MigrationPreparation>()每个历史文件一次可 join 的迁移
270  constructor(ctx: Context, public config: Config) {
271    super(ctx)   // 基类构造器里 super(ctx, 'sessionPersistence') 才是注册点
273    if (sessionFormatCatalog.currentVersion !== SESSION_FORMAT_VERSION) {0.1.7:SESSION_FORMAT_VERSION = 4
274      throw new Error(
275        `session-persistence-jsonl: format catalog v${sessionFormatCatalog.currentVersion} `
276        + `does not match Session v${SESSION_FORMAT_VERSION}`,
277      )
278    }
packages/session/session-persistence/src/index.ts存储生命周期的抽象契约135, 139, 150, 165, 178, 194, 201
135export abstract class SessionPersistence extends Service {
139  constructor(ctx: Context) {
140    super(ctx, 'sessionPersistence')   // 这就是注册点
150  abstract create(header: SessionHeader, options?: SessionPersistenceCreateOptions): Promise<SessionHandle>
165  abstract open(id: SessionId, access: SessionAccess, options?: SessionPersistenceOpenOptions): Promise<SessionHandle>
178  abstract flush(): Promise<void>
194  abstract stat(id: SessionId, options?: SessionPersistenceStatOptions): Promise<SessionPersistenceSnapshot | undefined>
201  abstract list(options?: SessionPersistenceListOptions): Promise<readonly SessionPersistenceSnapshot[]>
245, 271

jsonl 后端不写 static inject——它靠基类注册:SessionPersistence 的构造器里做 super(ctx, 'sessionPersistence')(session-persistence/src/index.ts:139-141),子类只 super(ctx)。它通过 session/event 事件与 session/flush 工作,不需要知道 loop 的存在。换后端 = 换这个 provider(sqlite 只读查询引擎走另一条路,见 §5)。

273-278

格式版本守卫:后端启动时先比对「格式目录的当前版本」与 Session 包声明的 SESSION_FORMAT_VERSION(0.1.7 起 = 4)。两者不等就直接拒绝启动——宁可起不来,也不写出一个自己读不回的日志。注意这只管「写」:读旧日志时格式目录会先按迁移链升级,见 §3 第 3 条。

135-201

抽象契约在 session-persistence 包:create / open / flush / stat / list。这是 seam 的 Service Definition——jsonl 是 Provider 之一。0.1.7 起同一个包里还多了 storage-contract.ts:所有后端共享的一套存储校验(版本门 assertVersion、事件词汇表白名单、追加批次、连续性)——「每个后端拒绝同样的输入」被收进一个地方。

§5易错点

恢复必须走与 append 完全相同的校验链(06 页构造器)——一个能存不能恢复的日志比不存更糟。所以 seed 的校验强度与热路径 append 一致,失败在入口拦截而不是延迟到后续读取。

「查询」与「存储」解耦:session-query-sqlite 是索引/搜索用的查询引擎(30 页),不是持久化后端。持久化靠 JSONL(追加、无损),查询靠 SQLite(随机访问)——两个后端各司其职。