第 5 篇末尾留了一个问题:日志在内存里是对的,进程死了怎么办?本篇进入磁盘世界。这里的问题比"写文件"多得多:两个进程同时写一个会话会怎样;写到一半断电,文件尾部算数吗;一年后的新版本还读得动今天的格式吗;升级格式时中途崩溃,会不会留下一个半新半旧的文件。dsh 的持久化层(packages/session/session-persistence-jsonl/,抽象 seam 见 docs/subsystems/persistence.zh.md)对每一问都给出了机制层面的回答,而不是祈祷。
全篇围绕一条主线:日志不可变,进步靠新代际。源代际的文件写入后永不修改,任何进化——压缩、格式升级——都表现为"读旧代、写新代、原子切换"。在进 JSONL 后端之前,值得先花半页看清 seam(能力缝隙)的形状,因为它决定了后文所有机制的挂点。本文基于 2026 年 10 月的 master 分支(5badb15,0.2.1-alpha.1 前后),项目处于 developer preview 阶段,API 可能变动。
抽象层:一切经句柄
持久化被定义为一个抽象服务 SessionPersistence(packages/session/session-persistence/src/index.ts),暴露 create/open/stat/list 四个方法,没有平行的"持久化事件类型"——传输的就是第 5 篇那套 SessionEvent。每次 open 返回一个逐会话的 SessionHandle(src/handle.ts),承载 read/append/flush/close;一切读写经句柄流动,绝不经"按 id 寻址的服务方法"。
句柄的契约写得很细,挑三条影响设计的:写句柄读自己的成功 append(写后读一致);flush 解决后,同后端任何句柄、任何 stat 都至少能看到那个前缀(跨句柄新鲜度);读不会回退到比上次观察更短的前缀。这三条合起来,上层的批窗口与 checkpoint 策略才有可依赖的地基。仓库随产品交付 JSONL 后端,仓库外 provider 可以实现同一约定——后面会不断看到这条缝的价值:JSONL 的帧、锁、迁移全是实现细节,换掉它们不动日志模型。
磁盘布局:目录、帧与锁
先看一份日志躺在哪。JSONL 后端的根目录来自配置,base bundle 把它钉在 $DSH_HOME/sessions(packages/bundle/base/cordis.patch.yml:133 的 root: !!js dshHomePath('sessions'))。其下的布局由 packages/session/session-persistence-jsonl/src/format.ts 的路径函数决定:
- 项目层:
projectKey(cwd)把工作目录编码成单个安全路径段(format.ts:225,拒绝空串,无穿越、无碰撞),无 cwd 的会话进_no-cwd。 - 会话层:
sessionDir再拼上编码后的会话 id(format.ts:264)。每会话一个目录,留给未来的会话级产物。 - 文件层:代际 0 固定叫
session.jsonl,之后每代一个叫session.vN.jsonl(packages/session/session-format/src/filename.ts:16),默认再挂.zstd后缀。
文件内部,首行是 header JSON——{version, id, createdAt, cwd?, parentSession?, isSeeded, ...}(format.ts:80 附近),记录格式版本与会话血缘;fork 继承的事件数 inheritedEventCount 与 header 相伴存储,但不进事件流——它描述的是日志的前缀来源,属于元数据而非历史。之后是事件行。压缩不是"整个文件一个 zstd 流",而是带 checksum 的连续帧:每个批次的写入追加一个独立 frame(zstd.ts:19 打开 ZSTD_c_checksumFlag),首帧恰好一整行 header,扫描器不用解压就能按帧边界定位(zstd.ts:42)。这个选择让"逐批追加"与"断点截尾"都变得廉价——第 5 篇的批窗口落盘,在这里落成了字节。
写路径上有两个值得点名的力学细节。一是 appendLines(index.ts:1324)逐批写并 fsync,persistBatch(index.ts:856)把一批事件攒成一次落盘;二是新会话的首次实体化 materialize(index.ts:1175 附近)走"写临时文件、fsync、硬链接发布、目录 fsync"四步——从第一天起,连第一个字节都不是原地写出的。跨进程的写者互斥由 session.lock 保证(lease.ts:40):非阻塞的 flock(2)(lease.ts:34 引入系统级 flock),拿不到锁就是另一个进程持有,进程死亡内核自动释放——没有 stale 锁文件需要人工清理。句柄是单写者实体:已有活跃写句柄时第二个 open(id, 'write') 以 SessionAlreadyOwnedError 拒绝。
代际选择与读契约
打开一个会话,后端先扫描它的目录:resolveGenerationInDirectory(index.ts:1447)取文件名解析出的数字最大的规范代际;若发现"同一种代际、另一种压缩后缀"的同名文件,直接报编码不匹配,而不是猜一个。读契约有两条硬承诺:
一、永不返回撕裂尾部。当前代际走快路径:SessionLogScanner(format.ts:386)严格解码,结尾缺换行的半行、写一半的 frame 都被识别为撕裂前缀,返回安全的截尾偏移。二、同一句柄重复读不回退——读到 120 条之后,下次读至少是 120 条,允许更多,不允许变少。
历史代际不就地解释,而是走迁移管道升到当前代再读(下一节)。注意区分两种轻量场景:目录列举与会话 stat 只需 header,做 header 级的轻量迁移即可;真正打开才付出全量迁移的成本,且同一会话在一次进程内只准备一次(prepareJsonlMigration,generation.ts:1007)。还有一个自保护的边界:若磁盘上躺着比本进程更新的代际(存储版本 > 写入器版本),直接拒绝——宁可读不了,也不臆读。
相邻迁移链:一格一步,步进成链
格式演进由 createSessionFormatChain(packages/session/session-format/src/chain.ts:41)编译。它吃一份迁移列表,构造时做三件验证:每个源代际恰好一条迁移(重复即抛错),迁移名唯一,从 0 到当前版本每一格都有迁移兜底、一条不多(chain.ts:58 起的循环)。任何一条不满足,构造本身失败——链的完整性是启动条件,不是运行时才暴露的坑。
链的形态是"反向编织":N 个 stage 被编成单条流管道,事件从链头流入,逐 stage 过 transformEvent;inheritedEventCount 沿 stage 传递,每个 stage 必须预先声明出口 cut。静态目录在 packages/session/session-format-catalog/src/generated.ts:16:currentVersion: 4,v0–v4 五个 codec、四条迁移,每条迁移是独立包(session-format-v0-to-v1 … v3-to-v4),互不相依。拆开的好处很实际:v1 的语义变化只影响 v0-to-v1 一个包,新迁移的作者只需读相邻代的差异。
flowchart LR
V0["v0 文件
session.jsonl"] --> M1["v0-to-v1 迁移包"] --> V1["v1 codec"]
V1 --> M2["v1-to-v2 迁移包"] --> V2["v2 codec"]
V2 --> M3["v2-to-v3 迁移包"] --> V3["v3 codec"]
V3 --> M4["v3-to-v4 迁移包"] --> V4["v4 codec(当前写入)"]排他发布:验证先行,原子切换
迁移不是改旧文件,而是一次受控的发布。publishPreparedMigration(generation.ts:884)把五步排成一条不可逆的单程道:
sequenceDiagram
participant P as 迁移进程
participant D as 会话目录
participant W as 隔离 worker
P->>P: 解码历史代际事件
P->>D: 写 session.migration..tmp
zstd 帧 + checksum,分片写
P->>W: 全量 verify:重读、重放流、
比对 id/事件数/digest
W-->>P: staged bytes/digest 一致
P->>D: 复核源文件物理身份未变
P->>D: publishCurrentExclusive 硬链接发布
fsync 目录
P->>D: 删除临时文件 两个并发迁移撞上怎么办?目标已存在时不覆盖:publishCurrentExclusive(generation.ts:817)返回是否"我是赢家";输家去 verify 目标文件的字节前缀与 staged digest 一致,一致则接受对方的产物,不一致抛冲突错误。digest 比对让并发收敛到"最多一份正确结果"。
整条链上还钉着两颗铆钉。一是 staged 文件在 verify 前后各查一次物理身份(generation.ts:902 的字节数与 digest、generation.ts:907 的源文件 inode 比对),任何变动都以"源代际已改变"报错中止——迁移期间日志仍在追加,源变了,一切推倒重来。二是 verify 在隔离 worker 里跑(generation.ts:86):全量重读、重放 assistant 流、断言消息与流一致(第 5 篇的 BlockAssembler 校验在这里登场),主进程与日志写入不被迁移拖住。全程有一条铁律:源代际文件永不被修改。
崩溃恢复:各管一段
进程死在任意一刻,谁负责收拾?dsh 的答案是把恢复切成两层,各管一段:
持久化层只做机械截尾:truncateTornTail(index.ts:889)按扫描器给出的安全偏移把撕裂尾部切掉,让文件回到"最后一个完整 frame"。它不猜语义——不补事件、不改内容,日志已提交的前缀一个字节不动。
语义配平是读方的职责,而且按各自权限分开执行。resume 路径上,agent loop 拿写句柄读回全量日志,交给 interruptedTurnClosers(packages/core/session/src/repair.ts:53)配平:流里有工具请求但缺 tool/call 的,补一个 TOOL_NOT_STARTED 错误结果(repair.ts:15);有 call 无 result 的,补 TOOL_OUTCOME_UNKNOWN(repair.ts:18);再补 step/end 与 turn/end{reason:'interrupted'}。这些合成事件以普通批次 handle.append 追加进日志(packages/core/agent-loop/src/index.ts:855 附近),然后正常走 sessions.prepare 复活会话。冷读路径(session query 之类)没有写权限,只做内存内的同等配平,不把合成事件写回磁盘。fork 也复用同一套配平:buildForkSeed(packages/core/session/src/fork.ts:21)复制源前缀 [0…boundary],追加 session/end-seed{inherited:true} 标记切点,再以 forked 原因合成缺失的工具错误与边界事件——inheritedEventCount 只计复制的事件,合成部分不算继承。这个 end-seed 标记解决的是一个微妙的歧义:种子历史与实时工作的字节可以完全相同,没有标记,读方分不清哪里是继承的尽头。
职责切分的好处落在两点:日志本身永不篡改已提交事件(第 5 篇的 replace 遮蔽原则在磁盘上的延续),而恢复语义可以随 agent 演进——补什么、怎么补,是 agent 层的心智模型,不是存储格式的一部分。
小结
持久化层的全部设计可以压缩成三句话。一,日志不可变:进步只发生在新代际,源文件写入后即冻结。二,切换必须原子:临时文件、隔离验证、硬链接发布、digest 收敛,让任何时刻的磁盘状态要么旧、要么新,没有中间态。三,恢复各管一段:存储层管字节完整,agent 层管语义完整,写入点仍然是唯一的事实行。至此,"状态与存储"章收尾:日志如何写(第 5 篇)、如何存、如何活过崩溃(第 6 篇)都已就位。第 7 篇进入 agent loop——看运行时如何消费这些日志:轮次怎么推进、工具怎么执行、模型请求如何从事件历史中组装出来。