第五部分 · 会话、身份与扩展

第 14 章:会话持久化与投影

日志怎么落盘、崩溃怎么修复、列表怎么投影——三个插件各管一段

Session 本身是纯内存的

第 4 章的 Session 类是纯内存的事件源——它不碰磁盘。持久化由一组旁路插件完成:PersistenceCoordinator 订阅 session/created/session/event/session/flush/session/disposed,把事件批量写盘。这个分工是刻意的:第 14 章的主角不是「Session 会存盘」,而是「存盘这件事本身被拆成了几个可替换的插件」。

会话持久化家族(packages/session/):

职责
session-persistence持久化服务定义与编排:append/load/inspect/prepareSessionWriteBehind(200ms 批量窗口)
session-persistence-jsonl默认后端:每会话一个 session.jsonl(.zstd),header 行 + 事件行
session-persistence-sqliteSQLite 后端:PRAGMA user_version + application_id 双重版本身份
session-checkpoint-policy选择持久化屏障点(request/tool-dispatch/next-step),与后端解耦
session-projection / -cache事件投影注册表 + checkpoint 读阶梯
session-stats / -telemetry / -title*统计、遥测与标题生成

write-behind:200ms 窗口与屏障

写路径(session-persistence/src/coordinator.ts + write-behind.ts):Session.append 广播 session/event → coordinator 把事件交给 SessionWriteBehind → 200ms 窗口内批量落盘(session/flush 是即时静默屏障)。「持久化与 checkpoint 调度是两个独立的 Cordis 插件」——后端只保证批量写入与 flush 屏障,checkpoint-policy 决定何时请求 flush(request 前、tool-dispatch 后、next-step 前)。不装 checkpoint-policy 也能跑,只是崩溃可能丢窗口内事件——文档明说这是可接受的部署取舍。

JSONL 后端:临时文件、fsync、link 发布

默认后端每会话一个 session.jsonl.zstd 文件(压缩可关,此时是 .jsonl):header 行 + 事件行,每行一个事件;事件先经第 4 章的 chunk 打包(连续同 kind 同 block 的 delta 流打成一条 text-chunks 等行,信封开销 ~56 倍时才有收益)。首次物化是教科书级的原子发布:

  private async materializePosix(
    // ...
  ) {
    // Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the
    // target already exists — a concurrent materialize cannot clobber each
    // other. rename() would silently overwrite.
    await link(tmp, finalPath)
    // link() succeeded — the log is published. fsync the directory so the new
    // entry is crash-durable.
    // ...
  }

「用 link() 而非 rename() 发布」——EEXIST 即并发冲突,绝不静默覆盖。后续追加是 open('a') + fsync(Windows 走 write-through 命名空间操作,因为 Windows 不暴露父目录 fsync 契约)。

磁盘布局与路径安全值得一提:root/<projectKey(cwd)>/<encodeSegment(id)>/session.jsonl(.zstd),首行是 {type: 'session', version, id, createdAt, cwd?…} 头。SessionId 是未校验的 branded string,必须编码后才能进路径——encodeSegment 把危险字符转成 ~XXXX 十六进制(.~002E..~002E~002E),保证无路径穿越、无碰撞。

恢复路径值得细看——崩溃修复是纯函数

  /** Decode complete frames and retain complete JSONL records from a torn final frame. */
  // scanZstdFrames 返回 { frames, tornStart }:EOF 落在最后一个 frame 内即 torn
  // → 截断到 committedBytes,从 torn 尾部解压出完整事件(recoveredEvents),
  // 再 append 合成 closers 平衡未闭合的 turn/step/tool-call
  if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo)
  const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers]
  if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents)

三步:截断 torn 尾 → 恢复完整事件 → 追加合成 closers(第 4 章的 interruptedTurnClosers)。「恢复路径与主路径同构」——修文件与写文件走同一套 append 机制。

投影:eager 驱动的纯函数单位

持久化解决「日志在磁盘」,投影解决「UI 与查询需要派生视图」。SessionProjectionRegistry 让插件注册「投影单位」(projection unit)——每个单位是 apply(state, event) → state 的纯函数,被逐事件驱动。第 13 章的 todo 投影、goal 的 goal 投影、第 11 章子代理的枚举三档缓存都建在这上面。规则是「whole-value 事件」——投影单位只消费整值快照事件(todo/writeplan/mode),不解析增量。会话列表 = listSnapshots()(只读 header 行,随会话数扩展而不随日志大小)+ title/stats 投影。

标题:latest-wins 的日志事件

会话标题是 session/title log-only 事件(latest-wins,第 4 章的事件词表)。生成链:fallback 确定性生成(从首条消息截取)→ LLM provider 走辅助调用purpose: 'session-title',第 9 章 DeepSeek adapter 里讲过——短标题预算会强制 thinking disabled)并记录 session/title-llm-request 事件 → 用户重命名钉住标题(用户意图优先,模型不再覆盖)。

storage:命名后端与双重版本身份

ctx.storagepackages/storage/storage)是更通用的存储 hub:ctx.storage.<domain> 按域名解析到命名后端(json/sqlite),表单惰性解析——域名插件未挂载时读取抛 form-not-mounted,装配失败 loud 而非静默延迟。SQLite 后端用 PRAGMA user_versionSTORAGE_SQLITE_SCHEMA_VERSION = 1)+ application_id0x44534850)双重身份标识 schema(防止把别的应用的库当自己的),版本不符拒绝、无迁移,wal 默认,文件 0600/目录 0700。会话持久化的 SQLite 后端同理(SCHEMA_VERSION = 15events 表 1:1,崩溃语义用「last-turn/end cut」与 JSONL 对齐)。Harness home = $DSH_HOME(默认 ~/.dsh)——所有持久化产物都锚定在这里。

查询:给模型一只翻旧账的手

会话列表与投影服务 UI;模型要检索旧会话则走查询家族(packages/session-query/)。查询引擎(Service Definition)+ session-query-sqlite(FTS5 全文索引,SESSION_QUERY_SQLITE_SCHEMA_VERSION = 8)+ 五个模型工具:session_searchsession_event_searchsession_tracesession_event_tracesession_event_read——索引读源是持久化的 readFrom(第 14 章写路径的另一侧),workspace 授权。同一个「日志是唯一真相」的主题:查询不是另一份存储,是同一份日志的倒排投影。另有 session-log-export(Web 界面的 /export 下载,ZIP 导出)。

与前作对照

《Pi Agent 源码解析》里,会话持久化是核心内的 JSONL 会话格式;在《Prime Agent 源码解析》里,AgentSession 用 400KB 的心脏把编排与持久化绑在一起。dsh 把「持久化」拆成了 write-behind、checkpoint-policy、JSONL/SQLite 后端、投影、标题五个可替换插件——连「什么时候保证落盘」都成了部署决策。前作把持久化做成核心职责,dsh 把持久化做成接缝。

实践应用

  1. 写盘与 checkpoint 解耦:后端只管批量与屏障,策略插件决定何时要屏障——「崩溃最多丢多少」成为可配置的部署取舍。
  2. link() 发布胜过 rename():原子发布用「存在即冲突」的 link 而非「静默覆盖」的 rename——并发物化不可能互相踩踏。
  3. 崩溃修复是纯函数:torn 尾截断 + 完整事件恢复 + 合成 closers——修复与主路径同构,可测试性极强。
  4. 投影是纯函数单位apply(state, event) 逐事件驱动、只吃整值快照——UI 视图与查询全是日志的确定性函数。

总结

这一章拆完了会话持久化与投影:write-behind 批量落盘、JSONL 的原子发布与崩溃修复、投影注册表、标题生成与 storage hub。会话现在能活过进程重启了——但它还缺「人味」:设置、凭证与匿名身份。下一章看 dsh 怎么处理这三件与「用户」相关的事。