deepseek-harness/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
Turtle bec6805d6a refactor(session-persistence)!: handle-based seam with a lifecycle-owned write path
The persistence seam is now create/open/stat/list returning per-session
SessionHandles (read/append/flush/close); every log read and write flows
through the owning handle. The seam package exports only the service and
handle contracts, consumer-visible errors, and pure durable-data
validation helpers; each backend owns its complete storage runtime, and
the shared contract suites pin equivalent observable behavior. The
backend routes published sessions' live events by id into the active
write handle; agent-loop only acquires, seeds, and closes the handle.
Resume appends interruptedTurnClosers through its write handle;
session-query owns the revision-keyed cold cache. Legacy-only surfaces
are removed in the same swap: locate/readRaw/supportsRawArtifacts, the
legacy event-shape read migration, zstd torn-frame salvage,
DSH_SESSION_JSONL, and hook transcript_path population; a torn final
zstd frame is discarded whole; the session-list cold blank probe returns
on stat metadata (eventCount derived from the last physical row,
sizeBytes). The WebUI ZIP export serializes the logical log from a read
handle, so both backends export identically.

Refs #3245
2026-09-01 23:19:02 +08:00

7.6 KiB
Raw Blame History

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

Status: implemented

English | 中文

问题

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

事件溯源模型将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 SessionEvent,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——并由同一接口封装。

决策

持久化是一个具有抽象 Service Definition 的能力 seam(能力 seam,dsh-shell 模板),而非循环或核心逻辑:

  1. 接口(dsh-session-persistence,ctx.sessionPersistence):一个抽象的 SessionPersistence 服务,提供 create/open/stat/list/export,其中 create/open 返回逐会话的 SessionHandle,句柄承载 read/append/flush/close(基于句柄的 seam)。其持久化单元就是现有的 SessionEvent({ type, seq, time, data }),原样复用,无转换类型。
  2. 实现(dsh-session-persistence-jsonl):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 SessionHeader,随后是无损表示连续 SessionEvent 流的存储记录。符合条件的 assistant/chunk 增量连续段默认使用打包行;带校验和的 Zstandard 帧是默认物理编码,也可通过配置使用原始行。

长期有效、存在争议的关键选择:

  • 规范的持久日志无损保留每个 SessionEvent,包括 assistant/chunk。 JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。deriveMessages() 跳过分片,而过滤分片的方案(Codex 的 policy.rs)很有吸引力,但 seq = log.length 以及 events[i].seq === i 验证要求连续的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
  • 仅追加;崩溃的轮次被关闭,而非截断。 已刷写的事件永不被重写。语义检查点策略会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,持久化会原样返回其连续、可解析的事件;配平是读方的职责——resume 会为未应答的 assistant 调用计算按风险分类的错误结果、补一个缺失的 step/end,以及带 { kind: 'interrupted' } 的 turn/end(interruptedTurnClosers),并通过其写句柄追加它们,而只读观察方仅在内存中添加同样的收尾事件。合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有撕裂的最终 append 中不完整的碎片会被丢弃——从中恢复的完整记录由写路径在第一次新 append 之前持久重写;已提交前缀中的解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
  • 文件后端为规范实现,服务保持可扩展。 dsh-session-persistence-jsonl 是唯一 first-party provider,并通过 runPersistenceContract;抽象服务继续供仓库外 provider 使用。JSONL-only 持久化决策负责 first-party 数据库 provider 的删除及其明确 compatibility cut。
  • 元数据在日志之外。 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 dsh-session 拥有的 SessionHeader 中,并通过新的只读属性 session.header 附加到 Session 上——永远不进入 SessionEventMap,永远不到达 deriveMessages()。createdAt 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header。替代方案(一个可合并扩展的 session/meta 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 SessionHeader 加可变的 SessionSummary,二者的联合类型为 SessionMeta;可变 summary 后来因属于死状态而被移除——见 移除可变会话摘要。)
  • ctx.agents.create() 和 ctx.agents.resume() 是异步工厂;恢复还跨越持久化边界。 ctx.agents.resume({ resumeSessionId }) 打开该会话的写句柄,读取已存储的日志,并以持久化 id 发布准备好的 Session,继续其投影。Session 准备阶段决策定义未发布 Session 的所有权窗口。agent loop(智能体循环)不会硬注入 sessionPersistence(那样会让非持久化的演示永远挂起);当它不存在时,resume 会以明确的错误拒绝。

曾考虑的替代方案

上述每个关键选择都在陈述处记录了被否决的替代方案:过滤分片的规范日志(Codex 的 policy.rs 形式)破坏连续 seq 约定;截断崩溃的轮次会静默销毁长时间自主运行中的真实工作;日志内 session/meta 事件作为第 0 行——元数据不是可回放状态;有限的非整数 createdAt 值没有生产方,且与整数 Unix 毫秒存储不一致;将 sessionPersistence 硬注入循环会让非持久化的演示永远挂起。

格式版本控制:header 携带一个 version;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 SESSION_FORMAT_VERSION = 0,不作兼容承诺:读取只校验当前 v0 记录,已废弃的同版本形态会以 fail-closed 方式拒绝(导出与预发布精简)。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。

后果

Service Definition、JSONL provider 与 dsh-session 中的元数据约定(session.header,create(id?, options?) 签名)带来持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问。可复用的 runPersistenceContract 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束该 provider 与未来实现。持久化完整的逻辑日志还确定了事件保真度:即使 JSONL 将多个 assistant/chunk 打包到一条存储行中,每个事件也会精确保留。