Volume 2 · Chapter 20
持久化与恢复:日志如何落盘、崩溃如何修复
06 页讲了「进日志 = 已提交」,磁盘写入是异步的。这一章看异步的另一端:谁把日志写到磁盘、写什么格式、重启后如何恢复、崩溃遗留的未闭合 turn 如何修复。
示例本次示例:我们的 turn 1 如何落到磁盘
# 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 即提交,落盘是异步跟随)
# 崩溃:内存日志到 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
§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 页的四个对外事件一一对应:
- 追加写(firehose):jsonl 后端订阅
session/event,把每个事件规范序列化追加到<sessionId>.jsonl。事件在内存里是深冻结的、无损 JSON 校验过的——后端可以逐字写(06 页 append 的「快照」就是为这一刻准备的)。 - 检查点(flush):
session/flush是 awaited 的并行持久化检查点;checkpoint-policy 决定「按请求」触发它——loop 不在 turn 边界 flush(06 页 types.ts 里 turn/end 的 JSDoc 原话)。 - 恢复(seed):重启时读 JSONL → 构造 seed 事件数组 → 走 06 页构造器同一条校验链(无损 JSON、seq 从 0 连续、surface 校验)。格式版本上:旧格式(v0–v3)日志走
session-format的迁移链升到当前 v4,再交给同一条校验;只有「比本 build 更新」的日志被直接拒绝——sessionFormatVersionRefusal(session-persistence/src/errors.ts:133)给两种情形写了两句不同的话:更新的日志提示「升级 harness」,过旧且无升级路径的提示「本 build 不带升级路径」。 - 修复(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关键代码
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 }
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[]>
jsonl 后端不写 static inject——它靠基类注册:SessionPersistence 的构造器里做 super(ctx, 'sessionPersistence')(session-persistence/src/index.ts:139-141),子类只 super(ctx)。它通过 session/event 事件与 session/flush 工作,不需要知道 loop 的存在。换后端 = 换这个 provider(sqlite 只读查询引擎走另一条路,见 §5)。
格式版本守卫:后端启动时先比对「格式目录的当前版本」与 Session 包声明的 SESSION_FORMAT_VERSION(0.1.7 起 = 4)。两者不等就直接拒绝启动——宁可起不来,也不写出一个自己读不回的日志。注意这只管「写」:读旧日志时格式目录会先按迁移链升级,见 §3 第 3 条。
抽象契约在 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(随机访问)——两个后端各司其职。