DSH / Atlas
2026-07-23implementedsimplification

Collapse live persistence into one flush controller

将实时持久化归并到单个刷写控制器

The persistence coordinator represented one live session's write lifecycle with separate buffer, initialization, and retirement containers plus the per-id operation chain. Those structures mirrored the same fact: whether that exact `Session` still had initialization or events that must settle before its state could be released. The checkpoint-only drain also kept every event volatile until another plugin requested `s

English

Problem

The persistence coordinator represented one live session's write lifecycle with separate buffer, initialization, and retirement containers plus the per-id operation chain. Those structures mirrored the same fact: whether that exact Session still had initialization or events that must settle before its state could be released. The checkpoint-only drain also kept every event volatile until another plugin requested session/flush, even though the backend could begin durability work without blocking the synchronous producer.

Decision

Each live Session has one lifecycle entry containing initialization and one package-private write controller. The controller owns pending, the fixed batching timer, the optional active write, automatic-retry pause, and the shared flush barrier. A session/event listener copies the frozen event into pending; the first event starts a fixed deadline, and later events join without resetting it. A write takes one stable pending prefix; events admitted while it runs remain pending for a separately bounded follow-up batch.

session/flush is an immediate quiescence barrier. It waits for initialization, cancels the batching timer, joins any active attempt, and drains pending events, including events admitted while the barrier runs. A background failure is logged without rejecting the synchronous event producer, restores the complete ordered batch, and pauses automatic retry. A new event starts a fresh fixed window; explicit flush, retirement, or backend teardown retries immediately and surfaces a repeated failure.

Initialization now enters the existing per-id operation chain once and calls the unserialized core operations while it owns that turn. The chain remains separate from the live controller because detached public create/append/load calls can race without a Session object and still require identity-level serialization.

Crash repair is cold-only. For a live identity, load(id) snapshots the authoritative in-memory events before awaiting their flush, then returns them with SessionState.meta, the header actually used for durable writes; it rejects an open turn without reading or repairing storage. A cold load reserves its identity synchronously inside the per-id chain before awaiting stored-prefix reads or repair writes; the session/created publication boundary rejects and rolls back a same-id live session until the reservation clears. HMR adoption remains separate through loadStored plus the coordinator's cwd check and truncates torn storage without closing the authoritative live turn.

The live-controller map is also the retirement registry. Successful retirement drains and removes its controller; failed retirement leaves it in the map. Backend teardown stops event admission, flushes every controller still present, awaits remaining per-id operations, and closes the backend. No separate retirement set is needed to rediscover unfinished work.

Alternatives considered

Keep checkpoint-only write-behind. This can form larger batches, but makes durability depend on a separately mounted checkpoint policy and maximizes the crash-loss window between checkpoints. Bounded background scheduling still coalesces bursts and persists progress between mandatory barriers.

Use one coordinator-wide flush promise. The attachment pattern works for one file, but a global promise would serialize unrelated sessions. One controller per live session preserves independent backend progress while the per-id chain protects same-identity operations.

Latch the first background error permanently. This makes every later flush deterministic, but prevents the existing teardown retry from recovering a transient storage failure. Retaining the batch without latching the error preserves both observability and retry.

Reject every live load. This is safe but removes established balanced live snapshots used by persistence consumers and tests. Snapshot-before-flush gives the call a stable linearization point: successful flush proves exactly that snapshot is durable, while the live path never invokes crash repair.

Verification

  • Focused controller tests use a fake clock to prove the non-resetting fixed window, gate the first append, admit another event during that write, and observe an automatic second durable batch without calling session/flush.
  • The shared coordinator contract still covers live adoption, collisions, crash repair, and session/backend disposal over the in-memory, JSONL, and SQLite backends.
  • Failure and teardown tests keep rejected batches pending, retry them before close, and prove an in-flight controller delays backend close.
  • The shared backend contract persists an open live turn, proves load rejects without writing synthetic closers, completes and retires the owner, then reloads the exact completed turn.
  • An AgentLoop regression races resume() against a live open turn and proves the original agent can still durably complete it without an injected interrupted boundary.
  • A controlled backend blocks loadStored, attempts same-id session publication while repair owns the reservation, and proves rollback leaves no ghost controller before a balanced resume succeeds.
  • The ownerless-claim contract gives the live Session a different createdAt, then proves live and later cold loads both return the original stored header.

Consequences

The live-session entry keeps initialization beside one controller for pending events, its timer, active write, retry pause, and flush barrier. Separate coordinator containers retain persisted identity state, prepared cold Sessions, retiring identity waiters, and per-id operation chains because those lifecycles also exist without one writable live Session. Bounded writes reduce the ordinary crash-loss window and produce fewer backend batches than immediate scheduling, while mandatory barriers remain unchanged.

session/flush no longer chooses when ordinary persistence begins. It remains the ordering and error-observation boundary used by the loop and checkpoint policy, so a successful checkpoint still means every event admitted before its completion is durable.

中文

问题

持久化协调器使用彼此独立的缓冲区、初始化容器和退役容器,以及按 id 划分的操作链,表示一个活跃会话的写入生命周期。这些结构反映的是同一个事实:该 Session 是否仍有初始化操作或事件必须完成,之后才能释放其状态。仅由检查点触发的排空还会让每个事件都停留在易失状态,直至另一个插件请求 session/flush,尽管后端可以在不阻塞同步生产方的情况下开始持久化工作。

决策

每个活跃的 Session 都有一个生命周期条目,其中包含初始化和一个包私有写入控制器。该控制器负责 pending、固定批处理计时器、可选的活跃写入、自动重试暂停和共享刷写屏障。session/event 监听器将冻结的事件复制到 pending;第一个事件设定固定截止时间,后续事件加入但不会重置。一次写入会取出一个稳定的待处理前缀;写入期间接纳的事件仍留在待处理队列中,并形成另一个独立有界的后续批次。

session/flush 是即时完全停稳屏障。它等待初始化完成、取消批处理计时器、等待任何活跃尝试完成,并排空待处理事件,包括屏障运行期间接纳的事件。后台写入失败会被记录,但不会拒绝同步事件生产方;系统会恢复完整且顺序不变的批次,并暂停自动重试。新事件会开启新的固定窗口;显式刷写、退役或后端资源销毁会立即重试,并在失败再次发生时向调用方暴露失败。

初始化只进入现有的按 id 操作链一次,并在占有该轮执行权时调用未串行化的核心操作。该操作链与活跃控制器保持分离,因为公共 createappendload 调用即使没有 Session 对象仍可能发生竞态,依然需要按标识串行执行。

崩溃修复仅适用于冷态标识。对于活跃标识,load(id) 会在等待刷写完成前,先对内存中的权威事件生成快照,再将这些事件与 SessionState.meta(即持久化写入实际使用的标头)一同返回;若轮次仍打开,则在不读取或修复存储的情况下拒绝该次加载。冷态加载会先在按 id 操作链内同步占用对应标识,再等待读取已存储前缀或执行修复写入;在这项占用解除前,session/created 发布边界会拒绝同 id 活跃会话的发布并将其回滚。HMR(热模块替换)接管仍由 loadStored 与协调器的 cwd 检查独立处理,会截断撕裂的存储,但不会闭合权威的活跃轮次。

活跃控制器映射同时也是退役注册表。退役成功时,系统排空并移除其控制器;退役失败时,控制器保留在映射中。后端资源销毁会停止接纳事件,刷写所有仍存在的控制器,等待其余按 id 操作完成,然后关闭后端。无需另设退役集合来重新发现未完成的工作。

备选方案

保留仅由检查点触发的延后写入。 这种方式可以形成更大的批次,但会让持久性依赖另行挂载的检查点策略,并使检查点之间因崩溃而丢失数据的窗口达到最大。有界后台调度仍会合并突发事件,并在强制屏障之间持久化进度。

在整个协调器范围内使用一个刷写 promise。 这种挂接方式适用于单个文件,但全局 promise 会串行化互不相关的会话。每个活跃会话各有一个控制器,既能让不同会话的后端操作独立推进,又由按 id 操作链保护同一标识的操作。

永久锁存首次后台写入错误。 这会让后续每次刷写都得到确定的结果,却会阻止现有的资源销毁重试从暂时性存储故障中恢复。保留批次但不锁存错误,可以同时保留可观测性和重试能力。

拒绝对所有活跃会话的加载。 这样做很安全,但会让持久化消费方和测试无法再使用既有的闭合活跃会话快照。先生成快照再刷写,为调用提供了稳定的线性化点:刷写成功即可证明正是该快照已持久化,而活跃路径绝不调用崩溃修复。

验证

  • 针对控制器的测试使用假时钟证明固定窗口不会重置,随后阻塞第一次追加,在该次写入期间接纳另一个事件,并在不调用 session/flush 的情况下观测到自动执行的第二个持久批次。
  • 共享协调器约定仍覆盖内存、JSONL 和 SQLite 后端上的活跃会话接管、冲突、崩溃修复,以及会话和后端的资源释放。
  • 失败和资源销毁测试会让写入失败的批次保持待处理,在关闭前重试这些批次,并证明尚在执行的控制器会延迟后端关闭。
  • 共享后端约定会持久化一个仍打开的活跃轮次,证明 load 会拒绝且不会写入合成闭合事件,随后完成该轮次并让其所有者退役,最后重新加载完全相同的已完成轮次。
  • AgentLoop 回归测试让 resume() 与一个仍打开的活跃轮次发生竞态,并证明原有的 agent(智能体)仍能完成该轮次并将其持久化,其间不会注入 interrupted 边界。
  • 一个受控后端会阻塞 loadStored,在修复操作持有标识占用期间尝试发布同 id 会话,并证明回滚不会留下残留控制器,之后可以成功恢复一个闭合会话。
  • 无所有者认领约定会为活跃 Session 设置不同的 createdAt,并证明活跃加载和之后的冷态加载均返回最初存储的标头。

后果

活跃会话条目把初始化与一个控制器放在一起;该控制器负责待处理事件、计时器、活跃写入、重试暂停和刷写屏障。协调器仍以独立容器保存已持久化的标识状态、准备好的冷态 Session、标识退役等待方和按 id 操作链,因为即使不存在可写的活跃 Session,这些生命周期仍然存在。有界写入缩短了通常情况下因崩溃而丢失数据的窗口,相比即时调度产生更少的后端批次,同时不改变强制屏障。

session/flush 不再决定普通持久化何时开始。它仍是循环和检查点策略使用的顺序与错误观测边界,因此检查点成功仍表示在其完成前接纳的每个事件都已持久化。