DSH / Atlas
2026-06-14implementedarchitecturearchived 2026-09-30

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: create/open/stat/list/flush, with create/open returning per-session SessionHandles that carry read/append/flush/close (handle-based seam). 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. The current format writes one event per row; frozen v0 and v1 readers retain their historical packed-delta representation. Checksummed Zstandard frames are the default physical encoding, with raw lines configurable.

Key durable, contested choices:

  • The canonical durable log persists every current SessionEvent losslessly. One assistant/message or assistant/attempt embeds the exact timed provider stream for an attempt; deriveMessages() projects only the surface message. Dropping embedded stream members is tempting, but it loses replay, timing, usage, partial-failure, and diagnostic facts. Removing a complete event likewise requires dense renumbering because seq = log.length and events[i].seq === i; the v1-to-v2 migration performs that rewrite explicitly rather than filtering 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, persistence returns its contiguous, parseable events unmodified; the reader owns balancing — resume computes risk-classified error results for unanswered assistant calls, a missing step/end, and turn/end with { kind: 'interrupted' } (interruptedTurnClosers) and appends them through its write handle, while read-only observers add the same closers in memory. The synthetic results keep resumed provider transcripts valid. Only the incomplete fragment of a torn final append is discarded — complete records recovered from it are durably rewritten by the write path before its first new append; a parse error or sequence gap in the committed prefix is corruption and makes the session unloadable.
  • The file backend is canonical while the service remains extensible. dsh-session-persistence-jsonl is the sole first-party provider and passes runPersistenceContract; the abstract service remains available to out-of-tree providers. The JSONL-only persistence decision owns removal of the first-party database provider and its deliberate compatibility cut.
  • 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, and JSONL validates the decoded header. 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 }) opens the session's write handle, reads the stored log, and publishes the prepared Session under the persisted id, continuing its projections. The Session preparation decision owns the unpublished-Session ownership window. 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 stream-filtered canonical log — loses attempt evidence, while removing events without an explicit migration breaks contiguous sequence numbers; truncating a crashed turn — silently destroys a long autonomous run's real work; an in-log session/meta event as log line 0 — metadata is not replayable state; finite fractional createdAt values — have no producer and diverge from integer Unix-millisecond storage; hard-injecting sessionPersistence into the loop — would pend non-persistent demos forever.

Format versioning: the header carries a version; handles expose only the logical format selected by SESSION_FORMAT_VERSION (version authority). JSONL event-body reads compose the complete static adjacent migration chain before returning a handle; each edge owns its historical transformations. V0 remains at suffixless session.jsonl[.zstd], while positive versions use immutable lowercase session.vN.jsonl[.zstd] names (released Session migration). Current-generation append and flush are robust to partial trailing writes; a future provider or write-ahead log needs its own power-loss and recovery contract.

Consequences

The Service Definition, JSONL provider, and metadata contract in dsh-session (session.header, the create(header, options?) signature) buy durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log. The reusable runPersistenceContract suite holds the provider and future implementations 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 attempt retains its exact compact timed stream in one durable settlement.

中文

问题

会话此前仅存在于内存中。示例插件 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/flush,其中 create/open 返回逐会话的 SessionHandle,句柄承载 read/append/flush/close(基于句柄的 seam)。其持久化单元就是现有的 SessionEvent({ type, seq, time, data }),原样复用,无转换类型。
  2. 实现(dsh-session-persistence-jsonl):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 SessionHeader,随后是无损表示连续 SessionEvent 流的存储记录。当前格式每个事件写一行;冻结的 v0 与 v1 reader 保留其历史 packed-delta 表示。带校验和的 Zstandard 帧是默认物理编码,也可通过配置使用原始行。

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

  • 规范持久日志无损保留每个当前 SessionEvent。 一个 assistant/message 或 assistant/attempt 会嵌入该 attempt 的精确带时间 provider stream;deriveMessages() 只投影 surface message。删除嵌入 stream 成员看似诱人,但会丢失 replay、timing、usage、部分失败与诊断事实。移除完整事件同样需要密集重新编号,因为 seq = log.length 且 events[i].seq === i;v1 到 v2 迁移会显式执行该改写,而不是过滤规范日志。
  • 仅追加;崩溃的轮次被关闭,而非截断。 已刷写的事件永不被重写。语义检查点策略会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,持久化会原样返回其连续、可解析的事件;配平是读方的职责——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 会以明确的错误拒绝。

曾考虑的替代方案

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

格式版本控制:header 携带 version;句柄只暴露由 SESSION_FORMAT_VERSION 选定的逻辑格式(版本真源)。JSONL 的事件正文读取会在返回句柄前组合完整的静态相邻迁移链;每条迁移边拥有自身的历史转换。V0 保留无后缀的 session.jsonl[.zstd],正版本则使用不可变的小写 session.vN.jsonl[.zstd] 名称(已发布 Session 迁移)。当前 generation 的 append 与 flush 能稳健处理不完整尾部写入;未来 provider 或 WAL 必须定义自己的断电与恢复约定。

后果

Service Definition、JSONL provider 与 dsh-session 中的元数据约定(session.header,create(header, options?) 签名)带来持久恢复/fork、读取/replay 路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧 Session 访问。可复用 runPersistenceContract 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束该 provider 与未来实现。持久化完整逻辑日志也确定了事件保真度:每个 Assistant attempt 都在一个持久 settlement 中保留其精确紧凑带时间 stream。