先把问题放到真实使用里。你让 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 路径写成公共契约。
这一篇按六个问题走:
- 为什么“继续一个 thread”比显示旧消息更难?
RolloutItem中不同类型的记录分别负责什么?- 写入路径怎样做到先排队、再落盘、失败后还能重试?
- resume 和 fork 为什么先进入
InitialHistory? - 重建算法为什么要倒序扫描,再正序 replay?
- 旧 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。