雨天小六

读懂 Codex(8.7):Flush、Persist、Shutdown 与 Discard 的不同保证

· 更新于 2026-08-03 · 专栏:读懂 Codex

#Codex#Agent Runtime#持久化#Rollout#软件架构

四个看似都在“保存”的操作具有不同线性化点:Persist 保证存在,Flush 确认既有队列,Shutdown 排空并结束所有权,Discard 则丢弃 Live Writer 而不强迫 pending 落盘。

本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:比较四种生命周期操作的保证、错误和适用时机。输入:当前 writer 是否物化、pending 是否为空、调用者是否结束生命周期;状态所有者:RolloutRecorder 与 LocalThreadStore live recorder map;成功结果:不同强度的耐久屏障或 writer 生命周期终止。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。

状态边界

问题本节答案
输入当前 writer 是否物化、pending 是否为空、调用者是否结束生命周期
状态所有者RolloutRecorder 与 LocalThreadStore live recorder map
成功产物不同强度的耐久屏障或 writer 生命周期终止
研究范围比较四种生命周期操作的保证、错误和适用时机

正常路径:先看顺序点

Flush、Persist、Shutdown 与 Discard 的不同保证的正常路径时序图,展示Caller 选择所需保证、Store 获取同 Thread 写锁、Recorder 执行对应命令、必要时写 pending/flush、Shutdown/Discard 从 live map 移除
图 8.7-1:Caller 选择所需保证 → Store 获取同 Thread 写锁 → Recorder 执行对应命令 → 必要时写 pending/flush → Shutdown/Discard 从 live map 移除。这张图标出本节的实际顺序点与状态所有者。

这条路径可以压缩成五步:

  1. Caller 选择所需保证。
  2. Store 获取同 Thread 写锁。
  3. Recorder 执行对应命令。
  4. 必要时写 pending/flush。
  5. Shutdown/Discard 从 live map 移除。

图中的箭头不是“可能调用”的依赖图,而是源码中决定可见性和所有权转移的先后关系。前一步没有确认时,后一步不能替它作出更强的成功承诺。

源码机制拆解

Persist 保证物理存在

它会触发延迟物化并写出 SessionMeta/pending,因此适合在 Fork、外部引用或列表发现之前建立耐久 Thread。它不是只对已经存在的文件调用 fsync。

Flush 是队列屏障

Flush 等待命令之前的 pending 写出并刷新文件。它不创造新的业务项;若文件尚未物化但已有需要保存的状态,Recorder 会在屏障路径完成物化。

Shutdown 交还唯一写者资格

Shutdown 尝试排空、关闭 writer task,Paginated 模式再 best-effort 投影并同步路径,最后从 live_recorders 移除。失败时 writer 可保持存活以便重试,不能先释放所有权。

Discard 面向初始化补偿

InitGuard 在 Session 构造中途失败时调用 discard,把 recorder 从 map 移除,却不强制把尚未提交的临时状态变成永久 Thread。它不是正常关机的快捷方式。

终态路径需要显式选择

TurnComplete/Aborted 记录只是业务项;调用路径还要决定是否 flush。尤其 Resume、Rollback、Fork 和非 subagent 初始化会用显式屏障建立可观察边界。

Python 风格伪代码

下面的伪代码只保留设计职责、状态和失败顺序;它不逐行翻译 Rust,也不借 Python 语法虚构源码中不存在的事务:

async def persist(live):
    await live.store.write_op(live.id, PERSIST)

async def flush(live):
    await live.store.write_op(live.id, FLUSH)

async def shutdown(live):
    result = await live.recorder.shutdown_and_drain()
    if result.ok:
        await live.store.project_best_effort(live.id)
        live.store.remove_live_recorder(live.id)
    return result

async def discard(live):
    # initialization rollback: do not manufacture durability
    live.store.remove_live_recorder(live.id)

阅读时要特别看三处:哪个对象拥有可变状态,哪一个 await 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。

失败、取消与恢复

Flush、Persist、Shutdown 与 Discard 的不同保证的失败路径图,区分把 Persist 当空操作、Shutdown 写失败仍移除 recorder、初始化失败却调用 Shutdown、只 append 不等 terminal durability
图 8.7-2:同一操作在不同故障点留下的状态不同,恢复责任必须回到拥有该状态的组件。
故障点已留下的状态可观察结果恢复责任
把 Persist 当空操作延迟 Thread 没有文件Fork/Resume 找不到源Persist 必须物化 SessionMeta
Shutdown 写失败仍移除 recorderpending 失去唯一所有者后续无法重试失败时保留 writer 生命周期
初始化失败却调用 Shutdown临时 Thread 被强行物化留下幽灵历史InitGuard 使用 Discard
只 append 不等 terminal durability命令可能仍在队列进程崩溃丢尾部在需要的终态显式 Flush

这里没有统一的“回滚一切”。内存状态、日志行、SQLite 投影、父子拓扑和工具造成的文件/网络变化分别有自己的提交点。恢复代码只能根据已经存在的权威证据继续,不能用较弱的投影替较强的事实背书。

必须保持的不变量

  • Persist 可把无业务项 Thread 变为可发现文件
  • Flush 不越过更早的 AddItems
  • 成功 Shutdown 后不存在 live recorder
  • Discard 不承诺 pending 耐久

这些不变量比“最终能 Resume”更严格:正常路径要成立,Writer 竞争、任务取消、坏尾行、投影落后和旧格式兼容时也必须成立。

设计取舍

拆成四个动词增加 API 数量,却让创建补偿、普通屏障和永久关闭不再共享含糊的 save/close 语义。

源码可以直接证明字段、分支、调用顺序和测试期望;“为什么这样设计”的表述是基于这些事实作出的工程归纳,不把它包装成未公开的产品承诺。

Mini Codex 复刻

用状态表测试 unmaterialized/materialized × pending/empty × operation,明确文件存在、Future 结果和 writer map 的期望。

复刻时先验证协议不变量,再补性能优化。一个能在故障注入下说明“留下了什么”的小实现,比一个只在正常路径调用 save() 的演示更接近真实 Runtime。

源码导航

相邻测试也很重要:

本节结论

四个看似都在“保存”的操作具有不同线性化点:Persist 保证存在,Flush 确认既有队列,Shutdown 排空并结束所有权,Discard 则丢弃 Live Writer 而不强迫 pending 落盘。

阅读导航

上一节:8.6 · 下一节:8.8

评论


← 返回文章列表