Codex 源码阅读 · 第十篇

从 Rollout 记录重建会话与分叉

第九篇讲 prompt cache 时,我们把速度落到了请求形状。可一条 thread 不只要跑得快,还要能在进程退出后继续、 能从旧会话 resume、能 fork 出新分支,也能正确解释历史版本留下的撤回记录。 这些能力的共同前提,是 Codex 把一次运行写成可以重新解释的记录。

项目:openai/codex 主题:rollout / recovery 范围:公开源码
Codex rollout recovery,展示 append-only JSONL、reverse scan、replay suffix、model history、context baseline 和 resume fork 输出
先倒序选择幸存检查点,再正序重放尾部。恢复与分叉是重建后的两种用途;旧 rollback marker 只参与历史兼容,不能据此推导当前主动回滚 API。

先把问题放到真实使用里。你让 agent 改一个复杂 bug,跑到一半关掉终端;第二天重新打开,希望它记得已经看过哪些文件、 工具跑出过什么、当前模型和权限是什么。或者你想从第三轮 fork 一条新思路,又不希望新 thread 把之前的上下文和 compact 结果搞乱。如果旧会话含有撤回记录,恢复后的请求也不能重新带回当时被撤掉的工具结果。

这些看起来像“保存聊天记录”。源码读下来会发现,聊天记录只是其中一层。Codex 真正要保存的,是之后可以被 runtime 重新解释的一串事实:哪些内容进入过模型历史,哪个 compaction 替换了历史,哪一轮的工作目录、sandbox、模型和 collaboration mode 是有效基线,哪些事件应该重新交给客户端。

rollout 是一份按时间追加的 typed JSONL 记录。它追加 RolloutItem, 写入时保持顺序并允许失败后重试,恢复时再从这些 item 重建模型历史、上下文基线、token usage、旧 rollback 记录裁剪后的可见状态,以及 fork 的起点。

取材范围。 本文只描述 openai/codex 公开源码中的 rollout schema、writer、session 初始化、resume/fork 与旧 rollback 记录的兼容 路径和相关测试。它不推断 Codex 后端私有存储,也不把本地文件系统上的具体 rollout 路径写成公共契约。

这一篇按六个问题走:

  1. 为什么“继续一个 thread”比显示旧消息更难?
  2. RolloutItem 中不同类型的记录分别负责什么?
  3. 写入路径怎样做到先排队、再落盘、失败后还能重试?
  4. resume 和 fork 为什么先进入 InitialHistory?
  5. 重建算法为什么要倒序扫描,再正序 replay?
  6. 旧 rollback 记录、compaction、prompt cache 怎样共同依赖这些记录?

一、恢复需要四类状态,不能只保存消息

客户端看到的是消息列表,模型下一轮需要的是有序 input,runtime 继续工作还要知道上下文基线和权限设置。 这三层互相关联,却不能混成一个字段。只保存 UI 文本,后面会丢失工具调用、context diff、compaction replacement、token usage 和 turn 设置;只保存模型 input,又很难恢复客户端事件流。

恢复对象 读者能看到什么 runtime 还需要什么 rollout 的角色
客户端状态 用户消息、工具进度、警告,以及旧记录中的 rollback 事件。 事件顺序、turn 起止位置、是否是恢复或 fork。 保存 EventMsg,让客户端转换代码能重放事件。
模型历史 下一轮模型应该看到的消息和工具结果。 compaction 后的替换历史,以及 rollback 后剩余的尾部。 保存 ResponseItem 与 CompactedItem。
上下文基线 工作目录、sandbox、权限、模型、协作模式。 哪一轮已经建立 full context,后续只需要 diff。 保存 TurnContextItem 与 WorldState,恢复 reference baseline。
thread 身份 thread id、来源、父子关系。 resume 继续同一 id,fork 创建新 id 但保留 lineage。 保存 SessionMeta,启动时决定 lineage。

这个拆法能解释为什么第六篇的客户端事件转换、第七篇的多 agent、第九篇的 prompt cache,都会在第十篇重新出现: 它们都依赖同一组持久记录。恢复如果重建错了,UI 可能看起来正常,模型视图却已经偏掉;模型视图如果偏掉, 后续 prompt cache 形状也会跟着变。

二、RolloutItem:消息之外还要保存恢复元数据

当前 RolloutItem 定义在 history crate,已经不止五类。除了 SessionMeta、ResponseItem、Compacted、TurnContext 和 EventMsg,还有 WorldState、TokenUsageRecord、RetainedContext、agent 间消息和仅供 realtime 展示的记录。恢复代码按用途解释它们,不会把所有持久数据都送给模型。

下面仅保留解释机制所需字段,不是完整 schema。RolloutLine 在顶层放 timestamp、可选 ordinal,并扁平化 type/payload;ResponseItem 的 wire 形状 还可在 payload 旁单独保存 metadata。恢复逻辑按顶层 type 分派。

{"timestamp":"...","ordinal":1,"type":"session_meta","payload":{"id":"t1"}}
{"timestamp":"...","ordinal":2,"type":"world_state","payload":{"full":true,"state":{}}}
{"timestamp":"...","ordinal":3,"type":"response_item","payload":{"type":"function_call","call_id":"A","name":"shell","arguments":"{}"},"metadata":{"client_authored":false}}
{"timestamp":"...","ordinal":4,"type":"compacted","payload":{"message":"...","window_number":1,"replacement_history":[]}}

其中 TurnContextItem 记录可恢复的配置,WorldState 保存环境状态的完整快照或增量。写入路径 先记录模型可见上下文,再保存 WorldState,最后在需要时写 TurnContext。单有一个配置对象,并不能证明模型已经看到它;恢复还会检查该段是否有真实输入边界,或压缩后有效的完整 WorldState。

CompactedItem 不只有 summary 和 replacement_history。它还可携带保留上下文、窗口编号及关联 id,以及最新 token usage 记录。replacement history 提供模型历史基座;其他字段让恢复不必为了配置、权限判断所需的事实或 token 总量无限向前扫描。具体字段可选,旧记录仍需要兼容处理。

三、写入先排队,flush 等待文件写入完成

新建 session 和恢复 session 的 recorder 初始化路径不一样。新 session 会先预计算 rollout path 和 SessionMeta,但文件创建可以延后到显式 persist();resume 则要立刻打开旧 rollout 文件并 append。这个设计让短命 session 不必一启动就落盘,同时又保证恢复出来的 session 继续写在已有证据后面。

真正写入时,RolloutRecorder 不在调用方线程里同步写文件。它把 AddItems、 Persist、Flush、Shutdown 发给后台 writer task。 普通 item 先进入 pending_items;只有写成功的前缀会从队列里移除。 如果一次 I/O 失败,writer 会丢掉文件句柄进入 recovery mode,下一个 barrier 再重新打开并重试未写完的后缀。

writer discipline:
record_canonical_items(items) -> queue AddItems
persist()                   -> materialize file + write pending
flush()                     -> wait for preceding file writes (not fsync)
shutdown()                  -> final drain before exit

例如从仍在运行的源 thread 准备 fork 时,源码先 flush 再读取,避免快照漏掉 writer 队列里的记录。不过这里的完成边界是文件写入和 flush();writer 实现 并没有在该处调用 fsync/sync_all。不要把“已接受入队”“已完成文件写入”和“断电后保证稳定介质持久性”视为同一承诺。

四、恢复入口:先进入 InitialHistory

session 初始化并不直接拿一个 JSONL 文件往 state 里塞。history crate 先把入口统一成 InitialHistory:New、Cleared、 Resumed(ResumedHistory)、Forked(Vec<RolloutItem>)。 新 session 和 clear session 延迟注入 initial context;resume 和 fork 则会创建一个默认 turn context, 然后调用 apply_rollout_reconstruction。

入口 thread id history 来源 启动后的动作
New / Cleared 新 id 没有旧 rollout。 把 initial context 推迟到第一轮真实 turn。
Resumed 沿用旧 id 由 thread store 读取的可恢复历史。 重建 history、恢复 previous settings、seed token usage。
Forked 新 id 从源 thread snapshot 裁剪后的 item 列表。 重建 history;持久化可复制历史,也可记录父历史引用。

resume 还有一个很现实的保护:如果旧 rollout 记录的模型和当前模型不同,会发 warning,提醒这可能影响性能。 这和第九篇可以接上。模型变了,provider 行为、context window、prompt cache 形状都可能变化; 恢复算法能把历史补回来,但不能保证换模型后的表现完全等价。

五、重建算法:倒着找基座,正着补尾巴

reconstruct_history_from_rollout 是这一篇的核心。它没有从第一行一路 replay 到最后。 它先倒序扫描,找到足够新的 surviving checkpoint:最新能留下来的 replacement_history、 最新的 previous turn settings、最新的 reference context item,以及当前 window id。 找到这些以后,旧 item 对当前 history 的影响就可以截断。

倒序扫描时,rollback 被处理成一个计数器。ThreadRolledBack 表示“丢掉最近 N 个真实用户 turn”; 倒序看就是跳过接下来 N 个含 UserMessage 的 turn segment。测试里还专门覆盖了一个细节: 没有真实用户消息的 standalone task turn 不应该消耗 rollback 次数。

倒序选出最新幸存检查点后,正序 replay 先安装该检查点的 replacement history,再追加尾部 ResponseItem 和 agent 间消息,恢复保留上下文,兼容旧 compact 与 rollback marker。尤其不能见到尾部任何 replacement_history 就再次覆盖:较新的检查点可能属于已撤回的 turn,必须沿原始记录重放,让旧 rollback 语义找得到对应边界。

reconstruction:
reverse scan
  find newest surviving replacement_history
  recover previous_turn_settings
  recover reference_context_item
  account for ThreadRolledBack markers

forward replay
  seed ContextManager from replacement_history
  append surviving ResponseItem suffix
  apply rollback markers to the rebuilt history

这个“倒着找、正着放”的形状很重要。倒序扫描让恢复不必重新解释很久以前的完整日志; 正序 replay 保住了剩余尾部的语义顺序。compact 越早提供完整 replacement history,恢复就越有明确基座。

六、旧 rollback 记录与有界读取的兼容边界

当前提交协议已经不再提供旧的主动 ThreadRollback 操作。ThreadRolledBack 仍保留在持久 schema 和恢复代码中,用于解释过去写入的记录。drop_last_n_user_turns 的注释 明确限定了这个用途。不能把兼容读取逻辑当成当前可调用的回滚 API。

ModelContextScan 为最新模型上下文提供有界倒序读取:它需要同时找到带 replacement_history 和 window_number 的检查点,以及能建立有效配置基线的 turn。裸 role=user 不足以证明真实用户边界,因为环境片段也可能用这个角色。若遇到旧 rollback marker、缺 replacement_history 或缺窗口编号的 compact,就扫描到开头,优先保证兼容语义。

读取决定,形状示意:
完整检查点 + 可恢复配置基线 -> 可停止向旧记录扫描
旧 rollback marker          -> 继续读到开头
旧 compact 缺关键字段       -> 继续读到开头
只有 contextual user 消息   -> 尚不能证明用户 turn 边界

本地 thread store 实际用这个扫描器读取 rollout 段;core reconstruction 则继续解释收到的 item 序列。存储层“读多少”与运行时“怎样重建”是两层职责,不能因为有倒序算法就断言所有恢复入口都不会读取完整文件。

七、fork:复制一段可解释的历史,而不是复制进程

fork 和 resume 共享很多恢复入口,但 lineage 不同。resume 沿用旧 thread id;fork 要创建新 thread id, 同时记录 forked_from_thread_id。源码里 fork 会先从 rollout path 或 store history 读出 InitialHistory,再通过 fork_history_from_snapshot 生成 fork 起点, 最后启动一个新 thread。

fork 现在有两种持久化策略。普通 fork 使用 ForkPersistence::Copied;prepared fork 可使用 Referenced,保存 history_base 与继承 item 数量。子会话仍得到可解释的模型上下文,但不必在自己的 rollout 中重复写整个父历史。该区别不能简化成所有 fork 都复制全部 JSONL。

这也解释了为什么第七篇的多 agent 能力要和 rollout 放在一起看。fork 出来的 agent 如果要继承历史, 它继承的是一段可以被 InitialHistory::Forked 解释的 rollout items,而不是父 agent 的临时变量。

八、这和 prompt cache 有什么关系

第九篇说 prompt cache 依赖稳定前缀。第十篇补上一个前提:恢复以后,下一轮请求的前缀也要能回到合理形状。 TurnContextItem 帮 runtime 知道哪些 context 已经建立过;CompactedItem.replacement_history 给历史一个新的基座;rollback marker 让撤回后的尾部被一致裁剪。

如果恢复只靠可见聊天文本,runtime 很可能把 context diff 当成 full context 重复塞入,或者把被 rollback 的工具结果继续带进模型视图。那样不仅语义错了,prompt cache 的 exact prefix 也会被扰乱。 公开测试里的 compact/resume/fork 场景正是在验证:compact 后的输入前缀,在 resume 和 fork 后仍能保持预期关系。

九、读完这段源码,可以带走什么

rollout/recovery 给 coding agent 一个很朴素的工程原则: 能长期运行的 agent,必须把“当前状态”写成可重放的事实,而不是只保存一张状态快照。

设计规则 Codex 里的对应做法 带来的结果
有序记录 RolloutLine 外层 timestamp,内部 RolloutItem。 恢复与审计读取同一组记录;旧 rollback marker 仍参与兼容恢复。
类型分层 history、context、session meta、event 由不同模块解释。 客户端状态和模型视图可以分开重建。
checkpoint replacement_history 给 compaction 后的恢复基座。 长 thread 不必每次从第一行重新解释。
marker 语义 兼容旧 ThreadRolledBack 记录。 旧记录保留,replay 据此算出当前 history。
file-write barrier 读取活动源 thread 快照前 flush;退出时完成排队写入。 快照等待文件写入完成;不额外承诺 fsync。

到这里,Codex 的内部执行路径已经接近完整:请求进入 runtime,context 形成模型视图,工具和权限控制副作用, events 交给客户端,rollout 记录过程,恢复再把记录重建成下一轮需要的 history 和 settings。 下一篇就适合看外部接口:SDK 和 app-server 怎样让其他调用方进入同一套 runtime。

参考源码