# Agent Note: 会话持久化作为基于现有 `SessionEvent` 的抽象服务

Status: implemented

[English](2026-06-14-session-persistence.md) | 中文

## 问题

会话此前仅存在于内存中。示例插件 `session-jsonl.ts`（在两个示例中逐字节重复）是只写的遥测：它缓冲 `session/event` 并追加 JSON 行，没有读取/回放路径，没有崩溃安全性（无 fsync、无原子写入、fire-and-forget 的 dispose 排空），没有列表功能，也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent（智能体）中，因此持久恢复、持久 fork 以及宿主侧的会话浏览都无法实现。

[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源，并从中派生 LLM（大语言模型）历史。持久化必须忠实于这一设计：直接持久化现有的 `SessionEvent`，不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储，以后用数据库存储——统一在一个接口之后。

## 决策

持久化是一个抽象的**能力 seam**（[能力 seam](2026-06-13-capability-seams.md)，`dsh-bash` 模板），而非循环或核心逻辑：

1. **接口**（`dsh-session-persistence`，`ctx.sessionPersistence`）：一个抽象的 `SessionPersistence` 服务，提供 `create`/`append`/`load`/`list`。其持久化单元就是现有的 `SessionEvent`（`{ type, seq, time, data }`），原样复用，无转换类型。
2. **实现**（`dsh-session-persistence-jsonl`）：每个会话一个仅追加的逻辑 JSONL 日志：先是一行 `SessionHeader`，随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行；[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.md)是默认物理编码，也可通过配置使用原始行。

以下关键选择记录于此，因为它们是持久性的、有争议的、且出人意料的：

- **规范的持久日志无损保留每个 `SessionEvent`，包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行，但 `load` 会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片，而过滤分片的方案（Codex 的 `policy.rs`）很有吸引力，但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求*连续*的逻辑日志；过滤掉分片会留下空洞，同时破坏契约和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现（带有自己的重新编号），但它不是规范日志。
- **仅追加；崩溃的轮次被关闭，而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用，并在步骤结束后排空完整的响应/结果批次；循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作，`load` 保留其连续、可解析的事件，并为未应答的 assistant 调用追加按风险分类的错误结果、补一个缺失的 `step/end`，以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的提供方 transcript（文本记录）仍然有效。只有不完整的最后一条记录会被丢弃；在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙，属于数据损坏，会使该会话不可加载。
- **文件后端为规范实现，数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`：`append` 是 INSERT（在一个断言连续 seq 契约的事务中），`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此：一个 `SessionPersistence` 子类，接口无变化（opencode 在 SQLite/WAL 上运行的正是这个形状），且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端（惰性物化、加载时关闭中断轮次、连续 seq），一次表达在文件字节上，一次表达在数据库行上。其数据库拥有专用的 application id 与单调递增的 schema 版本。系统会在一个事务中为全新文件创建所有表并写入这两个 header 值；未版本化文件若带有任何用户定义的 schema 对象或应用标识、当前版本文件若带有外部应用标识，以及任何非当前版本文件，都会在修改日志模式之前被拒绝。
- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点，不是可回放的对话状态，因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中，并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`，永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数：运行时创建和持久化注册会拒绝小数值，JSONL 会验证解码后的 header，SQLite 则将其存入严格的 `INTEGER` 列。替代方案（一个可合并扩展的 `session/meta` 事件作为日志第 0 行）被否决：日志内事件会随 seed/fork 的会话免费携带，但元数据不是可回放状态，因此显式的日志外 header seam 是更干净的代价。（header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`，二者的联合类型为 `SessionMeta`；可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。）
- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂；恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`，用加载的事件重建活跃会话（使 `lastTurnNumber`/`deriveMessages` 得以延续），并以原样恢复的 id 注册新 agent。agent loop（智能体循环）不会硬注入 `sessionPersistence`（那样会让非持久化的演示永远挂起）；当它不存在时，`resume` 会以明确的错误拒绝。

## 曾考虑的替代方案

上述每个关键选择都在陈述处记录了被否决的替代方案：**过滤分片的规范日志**（Codex 的 `policy.rs` 形式）破坏连续 seq 契约；**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作；**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态；**有限的非整数 `createdAt` 值**没有生产方，且与整数 Unix 毫秒存储及查询列不一致；**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识；**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。

格式版本控制：header 携带一个 `version`；`load` 拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`，不承诺广泛兼容；当持久化用户数据确有需要时，协调器可以负责显式且范围受限的导入升级（[消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)）。坦率地说：仅追加 + 刷写对部分尾部写入是健壮的（加载时容忍），但对行写入中途的无 fsync 断电不健壮；数据库/WAL 后端是后续更强的选项。

## 后果

新增两个包（package），以及 `dsh-session` 中的元数据 seam（`session.header`，`create(id?, options?)` 签名）。收益：持久恢复/fork、读取/回放路径、崩溃容忍，以及基于现有事件溯源日志的宿主侧会话访问，后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、整数元数据与可序列化语义约束每个后端。持久化完整的逻辑日志还确定了事件保真度：即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中，每个事件也会精确保留。SQLite 初始化要么提交完整的自有 schema 与 header 标识，要么不留下任何会使下次打开受阻的部分 schema。
