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

Status: implemented

[English](2026-07-23-collapse-persistence-flush-state.md) | 中文

[有界写入批处理决策](../architecture/2026-08-08-bounded-session-persistence-write-batching.md)取代了本 Agent Note 中的即时调度节奏。单控制器归属、失败保留、按 id 串行化、退役和完全停稳的资源释放决策仍然有效。

## 问题

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

## 决策

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

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

初始化只进入现有的按 id 操作链一次，并在占有该轮执行权时调用未串行化的核心操作。该操作链与活跃控制器保持分离，因为公共 `create`、`append`、`load` 调用即使没有 `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` 不再决定普通持久化何时开始。它仍是循环和检查点策略使用的顺序与错误观测边界，因此检查点成功仍表示在其完成前接纳的每个事件都已持久化。
