DSH / Atlas
2026-06-14implementedarchitecture

Session persistence as an abstract service over the existing `SessionEvent`

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

Sessions lived only in memory. The example `session-jsonl.ts` plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered `session/event` and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume,

English

Problem

Sessions lived only in memory. The example session-jsonl.ts plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered session/event and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume, durable forking, and host-side session browsing were all impossible.

The event-sourced model makes the append-only log the single source of truth and derives LLM history from it. Persistence had to stay faithful to that: persist the existing SessionEvent directly, with no parallel "persisted message" type that the log is converted to and from. The backend also had to be swappable — a file store now, a database store later — behind one interface.

Decision

Persistence is a capability seam with an abstract Service Definition (capability seams, the dsh-shell template), not loop or core logic:

  1. Interface (dsh-session-persistence, ctx.sessionPersistence) — an abstract SessionPersistence service: locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots. Its persisted unit IS the existing SessionEvent ({ type, seq, time, data }), reused verbatim — no conversion type.
  2. Implementation (dsh-session-persistence-jsonl) — an append-only logical JSONL log per session: a SessionHeader line followed by storage records that losslessly represent the contiguous SessionEvent stream. Eligible assistant/chunk delta runs use packed rows by default; checksummed Zstandard frames are the default physical encoding, with raw lines configurable.

Key durable, contested choices:

  • The canonical durable log persists every SessionEvent losslessly, including assistant/chunk. JSONL storage may encode a consecutive delta run as one packed row, but logical readers reconstruct the exact event boundaries, sequence numbers, and timestamps. deriveMessages() skips chunks, and a chunk-filtered rollout (Codex's policy.rs) is tempting — but seq = log.length and validation of events[i].seq === i require a contiguous logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log.
  • Append-only; a crashed turn is closed, never truncated. Flushed events are never rewritten. The semantic checkpoint policy drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, cold inspection preserves its contiguous, parseable events and adds risk-classified error results for unanswered assistant calls, a missing step/end, and turn/end with { kind: 'interrupted' } to the in-memory logical view. prepare or load commits those closers before returning a recoverable view; the synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded during committed repair; a parse error or sequence gap at or before the last real turn/end is corruption and makes the session unloadable.
  • File backend canonical, DB backend a proven drop-in. SessionEvent maps 1:1 onto a row (session_id, seq, type, time, data)append is INSERT (in a transaction asserting the contiguous-seq contract), and reads use SELECT … ORDER BY seq. dsh-session-persistence-sqlite is exactly this: a SessionPersistence subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same runPersistenceContract suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, logical interrupted-turn closure, single committed repair, contiguous-seq), expressed once over file bytes and once over rows. Its database carries a dedicated application id and monotonic schema version. A pristine file creates all tables and stamps both header values in one transaction; an unversioned file with any user-defined schema object or application identity, a foreign current-version identity, and every non-current version reject before journal-mode mutation.
  • Metadata is out-of-log. Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a SessionHeader owned by dsh-session and attached to a Session via a new readonly session.header — never in SessionEventMap, never reaching deriveMessages(). createdAt is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, JSONL validates the decoded header, and SQLite stores it in a strict INTEGER column. The alternative (a merge-extensible session/meta event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header boundary is the cleaner cost. (The header was originally split into an immutable SessionHeader plus a mutable SessionSummary whose union was SessionMeta; the mutable summary was later removed as dead state — see Drop the mutable session summary.)
  • ctx.agents.create() and ctx.agents.resume() are async factories; resume additionally crosses the persistence boundary. ctx.agents.resume({ resumeSessionId }) obtains the exact unpublished Session through ctx.sessionPersistence.prepare(), publishes it under the persisted id, and continues its projections. The Session preparation decision owns reuse between history inspection and resume. The agent-loop does NOT hard-inject sessionPersistence (that would pend non-persistent demos forever); resume rejects with a clear error when it is absent.

Alternatives considered

Each key choice above records its rejected alternative where the choice is stated: a chunk-filtered canonical log (Codex's policy.rs shape) — breaks the contiguous-seq contract; truncating a crashed turn — silently destroys a long autonomous run's real work; an in-log session/meta event as line 0 — metadata is not replayable state; finite fractional createdAt values — have no producer and diverge from integer Unix-millisecond storage and query columns; adopting a non-pristine unversioned SQLite file — can overwrite unrelated objects or identity; hard-injecting sessionPersistence into the loop — would pend non-persistent demos forever.

Format versioning: the header carries a version; cold reads reject any non-current version. The pre-release session format stays pinned at SESSION_FORMAT_VERSION = 0 and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it (pre-identity message recovery). Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there.

Consequences

Two new packages and the metadata contract in dsh-session (session.header, the create(id?, options?) signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log, with the backend swappable behind one interface. The reusable runPersistenceContract suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, logical-recovery, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every assistant/chunk survives exactly even when JSONL packs several into one storage row. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open.

中文

问题

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

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

决策

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

  1. 接口dsh-session-persistencectx.sessionPersistence):一个抽象的 SessionPersistence 服务,提供 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots。其持久化单元就是现有的 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 验证要求连续的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
  • 仅追加;崩溃的轮次被关闭,而非截断。 已刷写的事件永不被重写。语义检查点策略会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 step/end,以及带 { kind: 'interrupted' }turn/endprepareload 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 turn/end 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
  • 文件后端为规范实现,数据库后端为经过验证的直接替换。 SessionEvent 1:1 映射到一行 (session_id, seq, type, time, data)append 是 INSERT(在一个断言连续 seq 约定的事务中),读取使用 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 边界是更清晰的取舍。(header 最初被拆分为不可变的 SessionHeader 加可变的 SessionSummary,二者的联合类型为 SessionMeta;可变 summary 后来因属于死状态而被移除——见 移除可变会话摘要。)
  • ctx.agents.create()ctx.agents.resume() 是异步工厂;恢复还跨越持久化边界。 ctx.agents.resume({ resumeSessionId }) 通过 ctx.sessionPersistence.prepare() 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。Session 准备阶段决策定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 sessionPersistence(那样会让非持久化的演示永远挂起);当它不存在时,resume 会以明确的错误拒绝。

曾考虑的替代方案

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

格式版本控制:header 携带一个 version;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 SESSION_FORMAT_VERSION = 0,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级(消息标识机制引入前的消息恢复)。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。

后果

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