先把问题放到真实使用里。你让 agent 改一个复杂 bug,跑到一半关掉终端;第二天重新打开,希望它记得已经看过哪些文件、 工具跑出过什么、当前模型和权限是什么。或者你想从第三轮 fork 一条新思路,又不希望新 thread 把之前的上下文和 compact 结果搞乱。再或者你撤回最近一轮,期待后续请求不要再带上被撤掉的工具结果。
这些看起来像“保存聊天记录”。源码读下来会发现,聊天记录只是其中一层。Codex 真正要保存的,是之后可以被 runtime 重新解释的一串事实:哪些内容进入过模型历史,哪个 compaction 替换了历史,哪一轮的工作目录、sandbox、模型和 collaboration mode 是有效基线,哪些事件应该投影给客户端。
第十篇抓住一个判断:rollout 是 Codex 的可重放账本。
它按 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,恢复 reference baseline。 |
| thread 身份 | thread id、来源、父子关系。 | resume 继续同一 id,fork 创建新 id 但保留 lineage。 | 保存 SessionMeta,启动时决定 lineage。 |
这个拆法能解释为什么第六篇的 client projection、第七篇的多 agent、第九篇的 prompt cache,都会在第十篇重新出现: 它们都依赖同一条持久证据链。恢复如果重建错了,UI 可能看起来正常,模型视图却已经偏掉;模型视图如果偏掉, 后续 prompt cache 形状也会跟着变。
二、RolloutItem:一条 JSONL 里的五类事实
protocol 里 RolloutItem 只有五个 variant:
SessionMeta、ResponseItem、Compacted、
TurnContext、EventMsg。每一行外层还有一个 timestamp。
这让 rollout 文件可以像日志一样追加,也让恢复逻辑按 item 类型重新分派。
只看形状,一段 JSONL 可能像这样排列。每一行都是一条独立事实;恢复逻辑不会把它当 UI 文本读,
而是按 item.type 重新分派:
{"timestamp":"...","item":{"type":"session_meta","thread_id":"t1","source":"cli"}}
{"timestamp":"...","item":{"type":"turn_context","cwd":"workspace","sandbox":"workspace-write"}}
{"timestamp":"...","item":{"type":"event_msg","event":{"type":"turn_started","turn_id":"u1"}}}
{"timestamp":"...","item":{"type":"response_item","item":{"type":"tool_call","name":"shell"}}}
{"timestamp":"...","item":{"type":"compacted","window_id":"w1","summary":"..."}}
这里最容易被低估的是 TurnContextItem。源码注释写得很直接:每个真实用户 turn
在计算出模型可见 context updates 后,要持久化一次;中途 compact 如果通过 replacement history
重新建立 full context,也要再持久化一次。这样 resume/fork replay 才能找回最新的 durable baseline。
CompactedItem 也不是单纯的一段 summary。它可以带
replacement_history 和 window_id。前者给重建算法一个完整基座,
后者把 auto compact window 接回当前 session。
三、写入路径:先排队,再兑现 durability barrier
新建 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() -> durability barrier for rollback/fork/resume
shutdown() -> final drain before exit
这解释了为什么 rollback 和 fork 前都能看到 flush 语义。它们要基于“已经写完的事实”做快照或回放; 如果后台队列里还压着 item,后续重建就可能读到缺口。
四、恢复入口:先进入 InitialHistory
session 初始化并不直接拿一个 JSONL 文件往 state 里塞。protocol 先把入口统一成
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 | 从 rollout path 读出的完整 item 列表。 | 重建 history、恢复 previous settings、seed token usage。 |
Forked |
新 id | 从源 thread snapshot 裁剪后的 item 列表。 | 重建 history,把 forked rollout items 复制进新 thread。 |
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 rollout_suffix:
ResponseItem 进入 ContextManager;
带 replacement_history 的 compaction 会替换 history;
legacy compaction 缺少 replacement history 时,会走兼容重建;
ThreadRolledBack 会调用 drop_last_n_user_turns。
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:追加 marker,再让 replay 算出当前世界
rollback 的实现很克制。它先拒绝 num_turns == 0,也拒绝 active turn 进行中时 rollback。
然后它要求 thread persistence 可用,flush 当前持久化队列,读取存储里的 history,把一个
ThreadRolledBack 事件临时接到 item 列表尾部,调用同一套 reconstruction。
重建完成后,Codex 再把 rollback marker 作为 EventMsg 持久化到 rollout,并 flush。
这意味着旧事实还留在 append-only 文件里;当前状态来自“旧事实 + rollback marker”的解释结果。
后续 resume 读到同一个 marker,会得到同样的裁剪。
集成测试也按这个语义检查:rollback 到 compaction 之前时,第一轮仍然可见,compaction summary 仍然保留, 被撤掉的 post-compaction turn 不再出现在后续请求里。另一个测试检查 thread settings diff: 被撤回 turn 引入的 context update 不应该在下一轮重复残留。
七、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。
subagent fork 还有一个额外细节:它会先确保源 thread rollout materialized,再 flush,之后读取带 history 的 stored thread。这样子 agent 继承的是已经进入持久证据的一段 history,而不会依赖父进程里尚未落盘的临时现场。
这也解释了为什么第七篇的多 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 都有同一条证据链。 |
| 类型分层 | history、context、session meta、event 各有 owner。 | 客户端投影和模型视图可以分开重建。 |
| checkpoint | replacement_history 给 compaction 后的恢复基座。 |
长 thread 不必每次从第一行重新解释。 |
| marker 语义 | rollback 追加 ThreadRolledBack。 |
旧事实保留,当前世界由 replay 解释出来。 |
| durability barrier | fork、rollback、shutdown 前 flush。 | 快照基于已经落盘的事实。 |
到这里,Codex 的内部主线已经接近闭环:请求进入 runtime,context 形成模型视图,工具和权限制造副作用, events 投影给客户端,rollout 把过程留下,恢复再把它们变回下一轮可用的现场。 下一篇就适合看外部接口:SDK 和 app-server 怎样让其他调用方进入同一套 runtime。