146 篇 RFC 译文按 v4 基线(#348)重出:v4 模板+术语表、金标 few-shot、三段协议、切换行后处理;全量机械核对零异常(一处 task id 术语违规已修)。三篇超长 RFC(code-mode 已入,web-seam/ agent-scope/sandbox/cds-core 仍在长文档通道产出)随后补。
6.6 KiB
RFC:会话持久化作为基于现有 SessionEvent 的抽象服务
Status: implemented
English | 中文
问题
会话此前仅存在于内存中。示例插件 session-jsonl.ts(在两个示例中逐字节重复)是只写的遥测:它缓冲 session/event 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、fire-and-forget 的 dispose 排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复("继续昨天的任务")、持久 fork 以及 ACP(Agent Client Protocol)的 session/load 方法(ACP 支持)都无法实现。
事件溯源模型将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 SessionEvent,不引入需要来回转换的并行"持久化消息"类型。后端也必须可替换——当前用文件存储,以后用数据库存储——统一在一个接口之后。
决策
持久化是一个抽象的能力 seam(能力 seam,dsh-bash 模板),而非循环或核心逻辑:
- 接口(
dsh-session-persistence,ctx.sessionPersistence):一个抽象的SessionPersistence服务,提供create/append/load/list。其持久化单元就是现有的SessionEvent({ type, seq, time, data }),原样复用,无转换类型。 - 实现(
dsh-session-persistence-jsonl):每个会话一个仅追加的 JSONL 日志(一行SessionHeader,之后每行一个SessionEvent,逐字节保留,包括assistant/chunk)。
以下关键选择记录于此,因为它们是持久性的、有争议的、且出人意料的:
- 规范的持久日志逐字节保留每个
SessionEvent,包括assistant/chunk。deriveMessages()跳过 chunk,而过滤 chunk 的方案(Codex 的policy.rs)很有吸引力,但seq = log.length以及加载验证events[i].seq === i要求日志是连续的;过滤掉 chunk 会留下空洞,同时破坏契约和恢复功能。基于 chunk 过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 - 仅追加;崩溃的轮次被关闭,而非截断。 已刷写到
turn/end的事件永不被重写,且循环仅在轮次结束时刷写。由于一个被中断的轮次可能包含大量有效工作,load保留其连续、可解析的事件,并为未应答的工具调用追加错误结果、补一个缺失的step/end,以及带{ kind: 'interrupted' }的turn/end。合成的结果保证恢复后的 provider transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实turn/end处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 - 文件后端为规范实现,数据库后端为经过验证的直接替换。
SessionEvent1: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),一次表达在文件字节上,一次表达在数据库行上。 - 元数据在日志之外。 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在
dsh-session拥有的SessionHeader中,并通过新的只读属性session.header附加到Session上——永远不进入SessionEventMap,永远不到达deriveMessages()。替代方案(一个可合并扩展的session/meta事件作为日志第 0 行)被否决:日志内事件会随 seed/fork 的会话免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的SessionHeader加可变的SessionSummary,二者的联合类型为SessionMeta;可变 summary 后来因属于死状态而被移除——见 移除可变会话摘要。) ctx.agents.create()和ctx.agents.resume()是异步工厂;resume 还跨越持久化边界。ctx.agents.resume({ resumeSessionId })等待ctx.sessionPersistence.load,用加载的事件重建活跃会话(使lastTurnNumber/deriveMessages得以延续),并在恢复的 id 上启动一个新 agent(不是${agentId}-session)。agent loop(智能体循环)不会硬注入sessionPersistence(那样会让非持久化的演示永远挂起);当sessionPersistence不存在时,resume以明确的错误拒绝。
曾考虑的替代方案
上述每个关键选择都在陈述处记录了被否决的替代方案:过滤 chunk 的规范日志(Codex 的 policy.rs 形式)破坏连续 seq 契约;截断崩溃的轮次会静默销毁长时间自主运行中的真实工作;日志内 session/meta 事件作为第 0 行——元数据不是可回放状态;将 sessionPersistence 硬注入循环会让非持久化的演示永远挂起。
格式版本控制:header 携带一个 version;load 拒绝任何非当前版本(不做迁移——预发布阶段的会话格式固定为 SESSION_FORMAT_VERSION = 0 并吸收形状变动,遵循 AGENTS.md 的预发布立场)。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。
后果
新增两个包(package),以及 dsh-session 中的元数据 seam(session.header,create(id?, options?) 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP session/load(ACP 支持)所需的基础——全部基于现有的事件溯源日志,后端在一个接口之后可替换。可复用的 runPersistenceContract 测试套件以相同的仅追加、连续 seq、惰性物化与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:assistant/chunk 保持逐字节不变。