DSH / Atlas
2026-08-27implementedarchitecture

Handle-based session persistence

基于句柄的会话持久化

The previous persistence seam owned far more than storage. A shared coordinator subscribed to `session/created`/`session/event`/`session/flush`/`session/disposed` and adopted any published session (ownerless claims, HMR re-seeding, stored-prefix adoption); a bounded prepared-Session LRU with exclusive reservations served resume and read-only observation from one cache; committing crash repair lived inside `load`/`pre

English

Problem

The previous persistence seam owned far more than storage. A shared coordinator subscribed to session/created/session/event/session/flush/session/disposed and adopted any published session (ownerless claims, HMR re-seeding, stored-prefix adoption); a bounded prepared-Session LRU with exclusive reservations served resume and read-only observation from one cache; committing crash repair lived inside load/prepare; and optimistic revision read/check/read loops stood in for ownership, so a continuous external writer could livelock a read and nothing excluded a second writer. The service surface (twelve methods) mixed storage with Session construction and lifecycle. Cross-process write ownership — the next step — has no honest home in that shape: ownership belongs to an explicit per-session channel with an owner, not to a global listener.

Decision

The seam is five service methods returning or serving per-session handles. create(header) stores a new session and returns its owned write handle; open(id, 'read' | 'write') opens an existing one; stat(id)/list() observe snapshots (header, opaque revision, optional eventCount/sizeBytes hints — the JSONL backend supplies sizeBytes) without reading logs; service-level flush() is one backend-wide durability barrier that drains and flushes every active write handle, aggregating per-session failures without abandoning the sweep. The seam carries no raw-artifact export: the WebUI ZIP download serializes the logical log (header line + events) from a read handle in dsh-session-log-export, so every backend exports identically and the JSONL-only 501 path is gone. A SessionHandle carries read(offset?, length?) (validated contiguous prefix slices, never a torn tail, monotonic per handle), append (contiguous; persistence is best-effort on resolution, and the shipped JSONL backend happens to persist each batch immediately), flush (the durability barrier, which also materializes an empty session), and idempotent uncancellable close. One handle type serves both accesses — a mutation on a read handle is a runtime SessionReadOnlyError, the deliberate convention of this codebase's other seams rather than a typed split. Single-writer ownership is enforced in-process by a registry (SessionAlreadyOwnedError); the durable cross-process lease is the planned next layer on the same shape.

The agent lifecycle owns handle acquisition; the backend owns the event-driven flow. agent-loop — the sole production publication point for sessions — acquires the handle before publication (create for fresh sessions, appending any constructor seed through it; open(id, 'write') for resume) and closes it in the same memoized teardown that drains the loop. Because persistence already enforces one active write handle per session id, the backend installs the session listeners once and routes by id: session/event into the owning handle's bounded write-behind window (an internal scheduling policy, not configuration), session/flush as the durability and error-observation barrier, session/disposed as final drain and close. Nothing about the write path crosses the package boundary — no writer component, no batching configuration, no drain registry. Root-fiber disposal runs every fiber's disposers concurrently, so close() itself drains the routed buffer through the still-open storage; backend teardown's close sweep keeps application shutdown lossless regardless of which fiber unwinds first. The drain guarantees only buffered already-emitted events; a root dispose mid-turn still loses the turn's unemitted remainder by design — the next resume's interruptedTurnClosers repairs that tail durably. Sessions published outside the lifecycle no longer persist implicitly; nothing in production does that.

Semantic crash repair moved out of persistence. Resume reads the physically valid log through its write handle, computes interruptedTurnClosers, and appends them (plus the constructor's session/end-seed marker) through the same handle as ordinary batches — repair is not a special storage entry point. Read-only observers (session-query) balance an interrupted cold log in memory only, and own their cold-Session cache keyed by stat().revision; the persistence-side prepared cache and revision convergence loops are deleted.

Visibility and freshness are explicit. A created session is observable in-process from create; physical materialization may be deferred (a pure optimization) until the first append or flush, other processes see only materialized sessions, and a crash before materialization means the session never existed. Once an append or flush resolves, reads started afterwards on the same backend instance observe at least that prefix — the guarantee message-feedback's durable-target check rides on.

Revision simplifies to a per-instance change token. Equal tokens may be treated as an unchanged log; ownership churn never changes one. JSONL derives a best-effort token and sizeBytes from one fs.stat; a backend whose medium can count events cheaply may supply the eventCount hint instead. Session listing remains a zero-body-read metadata/cache operation: it trusts a cached nonblank projection, keeps a cached blank value only when the current cache identity matches, and otherwise reports blank: false as the visible unknown fallback. No cold blank probe or probe-limit configuration remains.

Alternatives considered

Typed read/write handle classes (or overloads). Rejected as the seam style: this codebase's seams prefer one access-tagged type with runtime refusal, and the split would double every consumer-facing type for one compile-time check.

Keeping the coordinator's adoption/HMR write path beside handles. Rejected: adoption exists to guess ownership after the fact; with the lifecycle handing the handle over explicitly, a reloaded backend that cannot serve old handles fails the writer loudly instead of silently re-claiming logs, and a session with no handle is a composition bug surfaced by absent persistence rather than masked by adoption.

A service-level append(id, events) beside handles. Rejected: an id-addressed write path bypasses ownership; every write flows through the owning handle so the future lease check has exactly one door.

Persistence-owned batching configuration. Rejected: the batching window is internal write-path scheduling, not a deployment-varying choice, so it is a provider constant and no configuration knob exists anywhere.

Consequences

Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one explicit acquisition point, and dispose provably releases write ownership (reopening for write succeeds after teardown). The costs: a backend plugin reload under live sessions invalidates their handles — writes fail loudly until the sessions restart, where adoption previously re-attached silently; ctx.sessions.create + flush in a test persists nothing without a handle (tests seed through create/append/close); resume re-reads a cold log only when no immediately preceding observation parsed the same artifact — a bounded provider-local memo (session id + stat revision, invalidated by every local mutation) serves the observe-then-promote and authorize-then-resume handoffs without restoring the deleted borrow/reservation lifecycle, and the session-query reader's own prepared cache remains the pin-capable layer above it (a later consolidation may fold one into the other); and an empty created session is invisible to other processes until an explicit flush (ACP forces one for its resumable-empty-session promise). Handle ownership does not change the serialized Session representation.

Related

中文

问题

先前的持久化 seam 承担的远不止存储。一个共享协调器订阅 session/created/session/event/session/flush/session/disposed,并接管任何已发布的会话(无主认领、HMR 重新播种、已存储前缀接管);一个带独占预留的有界已准备 Session LRU 用同一个缓存服务恢复与只读观察;提交式崩溃修复内嵌在 load/prepare 中;乐观的 revision 读取/复核/再读取循环充当所有权的替身,因此持续的外部写入方可能使读取活锁,而且没有任何机制排除第二个写入方。服务表面(十二个方法)把存储与 Session 构造和生命周期混在一起。跨进程写所有权——下一步——在那种形态里没有诚实的归宿:所有权属于一条有明确持有者的逐会话通道,而不属于一个全局监听器。

决策

该 seam 是五个返回或供给逐会话句柄的服务方法。create(header) 存储一个新会话并返回其持有的写句柄;open(id, 'read' | 'write') 打开一个已有会话;stat(id)/list() 观察快照(header、不透明 revision、可选的 eventCount/sizeBytes 提示——JSONL 后端提供 sizeBytes),而不读取日志;服务级 flush() 是一道后端范围的持久性屏障,排空并 flush 每一个活跃写句柄,逐会话聚合失败而不中途放弃清扫。该 seam 不承载原始工件导出:WebUI 的 ZIP 下载在 dsh-session-log-export 中从读句柄序列化逻辑日志(header 行 + 事件),因此每个后端的导出完全一致,仅 JSONL 可用的 501 路径也随之消失。SessionHandle 承载 read(offset?, length?)(经过验证的连续前缀切片,绝不返回撕裂尾部,逐句柄单调)、append(连续;完成时的持久化是尽力而为的,交付的 JSONL 后端恰好会立即持久化每个批次)、flush(持久性屏障,同时把空会话实体化)以及幂等且不可取消的 close。一种句柄类型同时服务两种访问——在读句柄上执行修改是运行时的 SessionReadOnlyError,这是本代码库其他 seam 的既定惯例,而非类型层面的拆分。单写者所有权由注册表在进程内强制(SessionAlreadyOwnedError);持久的跨进程租约是计划在同一形态上叠加的下一层。

**agent 生命周期负责获取句柄;后端负责事件驱动的流程。**agent-loop——会话在生产环境中唯一的发布点——在发布之前获取句柄(新建会话用 create,并通过它追加构造 seed;恢复用 open(id, 'write')),并在与排空循环相同的记忆化 teardown 中关闭它。由于持久化已保证每个会话 id 只有一个活跃写句柄,后端一次性安装会话监听器并按 id 路由:session/event 进入持有句柄的有界 write-behind 窗口(内部调度策略,而非配置),session/flush 作为持久性与错误观察屏障,session/disposed 作为最终排空并关闭。写路径没有任何部分跨越包边界——没有写入器组件,没有批处理配置,没有排空注册表。根 fiber 的 dispose 会并发运行每个 fiber 的 disposer,因此 close() 本身会经由仍然打开的存储排空已路由的缓冲;后端 teardown 的关闭清扫使应用关闭无论哪个 fiber 先解退都不丢数据。该排空只保证已发出并缓冲的事件;turn 中途的根 dispose 仍会按设计丢失该 turn 尚未发出的剩余部分——下一次恢复的 interruptedTurnClosers 会持久地修复这段尾部。在生命周期之外发布的会话不再隐式持久化;生产环境中没有任何地方那样做。

**语义崩溃修复移出了持久化。**恢复通过其写句柄读取物理上有效的日志,计算 interruptedTurnClosers,并把它们(连同构造器的 session/end-seed 标记)作为普通批次通过同一句柄追加——修复不是特殊的存储入口。只读观察方(session-query)仅在内存中配平被中断的冷日志,并拥有以 stat().revision 为键的冷 Session 缓存;持久化侧的已准备缓存与 revision 收敛循环被删除。

**可见性与新鲜度是显式的。**已创建的会话自 create 起即可在进程内被观察到;物理实体化(纯粹的优化)可以推迟到第一次 append 或 flush,其他进程只能看到已实体化的会话,实体化之前崩溃意味着该会话从未存在。一旦某次 append 或 flush 完成,其后在同一后端实例上开始的读取至少能观察到该前缀——这正是 message-feedback 持久目标检查所依赖的保证。

**revision 简化为逐实例变更令牌。**令牌相等可视为日志未变;所有权变动绝不会改变令牌。JSONL 通过一次 fs.stat 派生尽力而为的令牌与 sizeBytes;存储介质能够廉价统计事件数的后端可以改为提供 eventCount 提示。Session listing 保持零正文读取的 metadata/cache 操作:它信任缓存的非空 projection,只在当前 cache identity 匹配时保留缓存的空值,否则用 blank: false 表示可见的未知回退。冷空白探测及其探测限制配置均不再存在。

考虑过的替代方案

**类型化的读/写句柄类(或重载)。**不作为该 seam 的风格采纳:本代码库的 seam 偏好带访问标记的单一类型加运行时拒绝,而拆分会为一个编译期检查让每个面向消费方的类型翻倍。

**在句柄旁保留协调器的接管/HMR 写路径。**不采纳:接管的存在是为了事后猜测所有权;当生命周期显式移交句柄后,无法服务旧句柄的重载后端会向写入器响亮地失败,而不是静默地重新认领日志,而没有句柄的会话是一个由持久化缺席暴露、而非被接管掩盖的组合缺陷。

**在句柄旁提供服务级 append(id, events)。**不采纳:按 id 寻址的写路径绕过所有权;每次写入都流经持有句柄,使未来的租约检查恰好只有一扇门。

**由持久化持有批处理配置。**不采纳:批处理窗口是写路径内部的调度策略,而非随部署变化的选择,因此它是 provider 常量,任何地方都不存在配置旋钮。

后果

恢复、fork、subagent、ACP、webhook 与 SDK 会话全部经由一个显式获取点持久化,且 dispose 可证明地释放写所有权(teardown 之后重新以写模式打开可以成功)。代价:在有活跃会话时重载后端插件会使它们的句柄失效——写入会响亮地失败,直到会话重启,而以前接管会静默重连;测试中 ctx.sessions.create + flush 在没有句柄时什么也不持久化(测试通过 create/append/close 播种);只有当紧邻其前没有观察读解析过同一产物时,恢复才重新读取冷日志——一个有界的 provider 内部 memo(按会话 id + stat 修订号,任何本地修改都使其失效)服务观察后提升与授权后恢复这两类交接,而不恢复已删除的 borrow/reservation 生命周期;session-query reader 自己的已准备缓存仍是其上方具备 pin 能力的一层(后续可考虑二者收敛);空的已创建会话在显式 flush 之前对其他进程不可见(ACP 为其可恢复空会话承诺强制执行一次 flush)。句柄所有权不改变序列化的 Session 表示。

相关