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:
- Interface (
dsh-session-persistence,ctx.sessionPersistence) — an abstractSessionPersistenceservice:create/open/stat/list/flush, withcreate/openreturning per-sessionSessionHandles that carryread/append/flush/close(handle-based seam). Its persisted unit IS the existingSessionEvent({ type, seq, time, data }), reused verbatim — no conversion type. - Implementation (
dsh-session-persistence-jsonl) — an append-only logical JSONL log per session: aSessionHeaderline followed by storage records that losslessly represent the contiguousSessionEventstream. 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
SessionEventlosslessly. Oneassistant/messageorassistant/attemptembeds 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 becauseseq = log.lengthandevents[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, andturn/endwith{ 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-jsonlis the sole first-party provider and passesrunPersistenceContract; 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
SessionHeaderowned bydsh-sessionand attached to aSessionvia a new readonlysession.header— never inSessionEventMap, never reachingderiveMessages().createdAtis 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-extensiblesession/metaevent 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 immutableSessionHeaderplus a mutableSessionSummarywhose union wasSessionMeta; the mutable summary was later removed as dead state — see Drop the mutable session summary.) ctx.agents.create()andctx.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-injectsessionPersistence(that would pend non-persistent demos forever);resumerejects 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 模板),而非循环或核心逻辑:
- 接口(
dsh-session-persistence,ctx.sessionPersistence):一个抽象的SessionPersistence服务,提供create/open/stat/list/flush,其中create/open返回逐会话的SessionHandle,句柄承载read/append/flush/close(基于句柄的 seam)。其持久化单元就是现有的SessionEvent({ type, seq, time, data }),原样复用,无转换类型。 - 实现(
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。