前置:第 05 课 | 预计时长:60 分钟 | 动手环节:必须完成
你将学会
- 持久化如何从核心解耦(PersistenceCoordinator 的四个订阅点);
- 两个可换后端(JSONL / SQLite)与「版本方向感知拒绝」;
- 投影与查询两条独立机制的区别;
- plan / goal / todo 为什么没有状态机——「日志事件 + 纯折叠函数」模式。
问题引入
第 05 课说内存日志是「上半身」。这一课看下半身:重启之后会话怎么回来?状态(计划、目标、待办)存在哪? dsh 给出的统一答案有点反直觉:状态就是日志的纯函数。没有独立的状态机,恢复即重放。
正文
6.1 持久化是订阅插件,不是核心功能
对应 packages/session/session-persistence/src/coordinator.ts:1196-1207:
// PersistenceCoordinator 挂四个监听(注意:它不是 backend)
on('session/created', s => initFor(s))
on('session/event', (s, e) => initFor(s).writes.enqueue(copy(e))) // 入队,不阻塞生产者
on('session/flush', s => flush(s)) // 显式排空到静止
on('session/disposed', s => retire(s))
要点:
- 解耦点:核心
Session只发session/event;写盘节奏、格式、介质全是协调器的事。 - 批写窗口:第一个待写事件启动有限批写窗口,
session/flush排空到静止。 - 语义检查点:
session-checkpoint-policy在llm/stream瀑布里派发前flush(session)——请求发出前日志必须落盘,失败才有完整的重放基础。
6.2 两个后端与版本拒绝
后端实现抽象 SessionPersistence(locate / create / append / load / readFrom / list / listSnapshots):
- JSONL(默认):
<root>/<project>/<encoded-id>/session.jsonl[.zstd],Zstandard 帧 + 校验和;chunk 事件打包成text-chunks行(约省 60%);发布用link()+unlink()(link遇 EEXIST 即失败——防半写文件被看到)。 - SQLite:单库
node:sqlite,schema 19。
版本策略(coordinator.ts:77-81):
assertVersion(version) {
if (version > SESSION_FORMAT_VERSION) throw '由更新的 harness 写入——升级后打开'
if (version < 0) throw '低于支持的 v0,本构建无升级路径'
}
SESSION_FORMAT_VERSION = 0,无迁移承诺(预发布姿态:基础正确性 > 兼容面)。后端对过新/过旧格式方向感知拒绝(区别于损坏);未知事件类型对照 KNOWN_SESSION_EVENT_TYPES 同样拒绝重建——「不知道的事件不猜」。
6.3 投影与查询:两条独立机制
A. 实时值投影(packages/session/session-projection/):
- 域插件注册纯折叠单元
{ key, init, apply, stateVersion };注册表只订阅一次session/event,把每个已提交事件喂给所有单元(apply必须同步、状态纯 JSON)。 snapshot()给出一致切片{ asOfSeq, values };缓存写 storage 域,恢复时配合readFrom(id, fromSeq)只重折水位线之后的尾部。
B. 查询视图(packages/session-query/):
SessionCorpus合并活会话与持久层快照;SQLite 提供者建派生的 FTS5 全文索引。- 事件分类用
foldSurface()——与deriveMessages同一套 surface 转移,产出current | shadowed | log-only三态。
| 投影(A) | 查询(B) | |
|---|---|---|
| 产出 | 当前值切片 | 可检索文档集 |
| 索引 | 无(状态在内存/缓存) | FTS5 派生索引 |
| 共同点 | 都用 surface 折叠,与 deriveMessages 同源 |
6.4 日志化状态:plan / goal / todo
共同模式:状态 = SessionEventMap 声明合并出的事件 + 纯折叠函数,无活体镜像,恢复/分叉/压缩全靠重放。
// plan(plan-mode/src/index.ts:46-54, 129-138)
interface SessionEventMap { 'plan/mode': { active: boolean } } // log-only、整值、最后生效
foldPlanMode(events) { /* 遍历日志,最后一条生效 */ } // 状态 = 日志前缀的纯函数
// goal:'goal/change' 携带变更后完整快照或 clear 墓碑(整值规则)
foldGoal(events) → { goal, roundsStarted } // 续轮由 user/message.source.kind==='goal' 归因
// todo:'todo/write' 每次追加整表快照(条目刻意无 id)
exec.agent.session.append('todo/write', { todos }) // 最后写生效;投影在 turn/start 清空
三条设计规则贯穿其中:整值写(不写增量 diff)、最后生效(折叠即找最后一条)、只在提交点发布(遵守「模型可见 ⟺ 已记录」)。标题同理:session/title 是 log-only 事件,foldSessionTitle(events) = findLast(...)(session-title/src/index.ts:191-193);遥测则是日志的一对一镜像(session-telemetry/src/coordinator.ts:91-95),默认禁用。
6.5 storage / spill:日志之外的两块
- storage:管「非会话日志的一切」(设置、投影缓存等)。三层:枢纽
ctx.storage(只挂载/路由)→ 后端StorageBackend(storage-json/storage-sqlite,版本不符即拒、无迁移)→ 数据形态ctx.storageDomain(defineDomain声明;写顺序「先落盘→再改内存→后发事件」)。 - spill:工具超大输出溢出到磁盘。
ctx.spillStore.saveText()产生SpillRef{ locator, bytes, retrievalHint };spill-policy在tools/post-execute把超maxInlineBytes的结果换成头尾预览 + 引用(第 09 课会再见)。
动手环节
- 读快照文件头:打开一个录制会话的 JSONL 首行,找出格式版本与事件词汇;故意把版本改成
99,用test:snapshot跑一次,观察「方向感知拒绝」的报错文案。 - 折叠练习:手写
foldPlanMode(10 行以内),对第 04 课的快照跑出「plan 模式是否激活」;再对session/title写foldSessionTitle。 - 找事件:在快照里各找一个
approval/asked、todo/write、session/title事件,确认它们都是 log-only(没有 surfaceOp)。
自检清单
- 核心为什么不知道 JSONL/SQLite 的存在?解耦点是哪个事件?
- 请求发出前发生了哪个持久化动作?为什么它是「语义检查点」?
- 投影和查询都能「读会话」,它们的分工与共同点是什么?
- 用「日志化状态」模式实现一个「用户偏好静音」功能,需要哪几步?(声明事件 → 折叠函数 → 提交点写入)
常见误解
- 「恢复 = 加载一个状态文件」——恢复 = 重放日志(或只重折水位线之后的尾部)。
- 「todo 条目有稳定 id,可以增量更新」——刻意无 id,每次整表快照,最后写生效。
- 「旧格式会自动升级」——不会。方向感知拒绝,不做迁移(预发布姿态)。
延伸阅读
- 维基:《session-persistence》
- 仓库:
docs/persistence-catalog.md(生成的持久化目录清单)、docs/subsystems/storage.md、docs/subsystems/spill.md - 下一课:第 07 课 · Profile 与 Bundle:组合即配置