一个回合运行时,Codex 会产生许多对象:完整消息、工具调用、命令输出片段、审批请求、告警、计数信息、回合边界以及供界面实时刷新的增量事件。若把它们原样写入历史,恢复程序就不得不理解大量只对当时界面有意义的中间状态;若过滤过度,当前会话看似正常,重启后却可能缺少继续执行所需的语义。
因此,持久化入口不能只有序列化器,还需要一层明确的选择策略。本节讨论 codex_rollout::policy 如何回答三个问题:
- 哪些顶层
RolloutItem构成可重放历史; ResponseItem与EventMsg为什么采用不同的分类规则;Legacy与Paginated两种历史格式如何避免保存同一语义的两种表示。
至于通过通道异步写入、刷新以及关闭屏障,将在 8.4 节讨论。
持久化的对象不是运行时事件全集
RolloutItem 是持久化策略看到的顶层联合类型。它既包含模型协议对象,也包含 Codex 自己的执行标记:
| 类别 | 代表项 | 持久化目的 |
|---|---|---|
| 会话和上下文边界 | SessionMeta、TurnContext、WorldState、Compacted | 恢复会话配置、回合环境和压缩后的上下文 |
| 模型交互对象 | ResponseItem | 重建模型可见的消息、推理和工具调用链 |
| Agent 间通信 | InterAgentCommunication、InterAgentCommunicationMetadata | 保留跨 Agent 交付及其本地控制信息 |
| 运行协议事件 | EventMsg | 保存回合边界、结构化条目和兼容旧历史所需的事件 |
顶层分类中,除 ResponseItem 和 EventMsg 外,其余变体都直接保留。后两者还要进入各自的二级判定,因为它们的内部变体同时混合了长期语义和实时交互状态。
源码中的批量函数 persisted_rollout_items 依次调用单项判定,并把命中的对象克隆到新向量中。因而它有两个容易验证的性质:保留项的相对顺序不变,未命中策略的项不会留下占位符。
function select_for_rollout(items, history_mode):
selected = []
for item in items:
if is_persisted(item, history_mode):
selected.append(copy(item))
return selected
这里的函数不写文件,也不报告“已经持久化”。它只把候选序列变成规范写入序列。
ResponseItem 保存完整语义,而不是临时能力描述
ResponseItem 来自模型交互协议。当前策略保留以下几组对象:
- 消息与推理:
Message、AgentMessage、Reasoning; - 工具调用及结果:本地 Shell、Function、Tool Search、Custom Tool 的 Call 与 Output;
- 其他模型操作:Web Search、Image Generation;
- 上下文压缩结果:
Compaction、ContextCompaction。
这些对象的共同点,不是“用户可以在界面上看到”,而是它们已经形成模型交互或上下文演化中的完整语义单元。例如,只保存 FunctionCall 而丢掉 FunctionCallOutput,恢复后的调用链就不完整;只保存流式文本片段而没有最终消息,则恢复程序必须重新承担流合并职责。
当前不保存的 ResponseItem 有三类:
| 类型 | 不进入 Rollout 的含义 |
|---|---|
AdditionalTools | 本次请求临时提供的附加工具描述不成为长期历史 |
CompactionTrigger | 触发压缩的控制信号不等同于压缩结果 |
Other | 未形成稳定恢复语义的兜底对象不自动落盘 |
这也说明“是否序列化得了”不是持久化判据。一个对象完全可以有 JSON 表示,却仍不适合作为跨版本、跨进程恢复的契约。
源码中还有一个名称相近的 should_persist_response_item_for_memories。它服务于记忆生成,规则与 Rollout 不同,例如会排除推理和图像生成项,并过滤 developer 消息。两者不能互换:前者回答“恢复 Thread 需要什么”,后者回答“生成长期记忆允许消费什么”。
EventMsg 必须先区分规范状态和传输过程
EventMsg 的类型远多于 ResponseItem。其中既有 TurnStarted、TurnComplete 这样的生命周期边界,也有 AgentMessageContentDelta、ExecCommandOutputDelta 这样的流式片段,还有审批请求、连接状态和界面通知。
不依赖历史模式、始终保留的事件包括:
| 事件组 | 事件 |
|---|---|
| 资源与目标状态 | TokenCount、ThreadGoalUpdated、ThreadRolledBack |
| 回合边界 | TurnStarted、TurnComplete、TurnAborted |
| Thread 配置 | ThreadSettingsApplied |
这些事件描述的是恢复或查询仍然关心的状态边界。与之相对,内容 Delta、命令输出 Delta、Begin/Progress 通知、审批交互、Warning、Error、连接状态和实时语音事件等都被归为瞬时事件。它们仍可在当前 Session 的事件流中发送,但不会因此自动进入 Rollout。
“错误不持久化”尤其容易被误读。它只说明 EventMsg::Error 不属于这份可重放历史,并不代表错误不会写日志、上报指标或通过其他状态域记录。这里的策略边界只覆盖 Rollout。
History Mode 解决同一语义的双重表示
Legacy 和 Paginated 并不只是两种文件分页方式。它们还决定某些运行语义以哪一种事件表示进入历史。
Paginated 模式使用 EventMsg::ItemCompleted 携带结构化 TurnItem,供后续 Turn/Item 投影和分页读取。Legacy 模式则保留一组旧事件,例如用户消息、Agent 消息、推理、Review Mode 切换和若干工具结束事件。若把两组表示同时保存,同一个完成动作就可能在恢复或投影时出现两次。
核心选择可以写成下表:
| 事件类别 | Legacy | Paginated |
|---|---|---|
| 公共状态事件 | 保留 | 保留 |
| 旧式完成事件集合 | 保留 | 丢弃 |
一般 ItemCompleted(TurnItem) | 丢弃 | 保留 |
ItemCompleted(Plan) | 保留 | 保留 |
ItemCompleted(Extension::Sleep) | 保留 | 保留 |
| 瞬时事件 | 丢弃 | 丢弃 |
最后两个例外来自源码中的兼容规则:在 Legacy Rollout 中,Plan 和 Sleep 没有可替代它们的原始 ResponseItem 或旧式等价事件,所以仍保留对应的 ItemCompleted。因此,不能把实现简化成“Legacy 一律不要 ItemCompleted”。
function persist_event(event, mode):
if event is common_state_event:
return true
if event is ItemCompleted(item):
return mode == Paginated
or item is Plan
or item is Extension.Sleep
if event is legacy_completion_event:
return mode == Legacy
return false
历史模式属于 Thread 的持久化契约。创建 Thread 时,它写入 SessionMeta;恢复现有历史时,本地 Store 从第一个规范 SessionMeta 取得模式。后续写入不能根据单个事件临时切换规则,否则同一 Rollout 的前后部分会采用不同语义。
策略在 Store 边界重新生效
LiveThread::append_items 会先计算过滤结果,用于持久化指标和后续元数据同步;但它传给 ThreadStore::append_items 的仍是原始 RolloutItem 序列。AppendThreadItemsParams 的注释明确规定:Store 实现负责在写入规范历史和自身投影之前应用共享策略。
本地 Store 的实际路径如下:
LiveThread.append_items(raw_items)
│
├─ 计算 selected_items,供指标和元数据同步使用
│
└─ ThreadStore.append_items(raw_items)
│
├─ 按该 Thread 的 history_mode 再次执行共享策略
├─ 结果为空:直接成功返回
└─ 结果非空:交给 RolloutRecorder,并执行 flush
这种分工看似有一次重复计算,却守住了抽象边界:调用者可以知道哪些项会影响元数据,Store 则不能信任上游已经过滤。内存 Store 与本地 Store 都必须遵守同一契约,未来替换存储实现时也不会绕开策略。
还要注意操作顺序。LiveThread 只有在 Store 写入成功后,才用过滤后的项更新 Thread 元数据。因此,“预览文本已经更新而规范历史尚未接受对应项”不应成为正常成功路径。
穷举白名单是一项演进约束
策略函数使用 Rust 的穷举 match,没有 _ => true 这样的默认分支。为 RolloutItem、ResponseItem 或 EventMsg 增加新变体时,编译器会迫使维护者在相应匹配中作出选择。
这项约束同时控制四类风险:
- 恢复风险:漏存关键项会使热 Session 与恢复后的 Session 不等价;
- 重复风险:兼容格式的两种表示同时落盘,可能造成重复消息或重复投影;
- 体积风险:高频 Delta 和 Progress 事件会迅速放大 Rollout;
- 契约风险:把临时对象写入历史,会让尚未稳定的结构变成长期兼容负担。
白名单的代价是每次扩展协议都要更新策略和测试。这不是附带工作,而是协议设计的一部分:新增事件时,开发者必须说明它是规范状态、兼容表示,还是只服务当前连接的瞬时通知。
可用于核对实现的不变量
- 过滤只能删除对象,不能改变保留项的相对顺序;
- Call 与其完成后的 Output 都应有明确的持久化判定;
- 同一 Thread 的事件策略始终使用其规范
history_mode; Paginated的一般ItemCompleted与对应 Legacy 完成事件不能同时成为规范表示;- 过滤结果为空时,不应制造空的 Rollout 记录;
- Store 不能假设调用者已经执行过持久化策略;
- Rollout 策略与 Memory 输入策略即使函数形态相似,也不能相互替代。
源码与测试证据
| 位置 | 可以核对的结论 |
|---|---|
codex-rs/protocol/src/protocol.rs | ThreadHistoryMode、EventMsg 与 RolloutItem 的类型定义 |
codex-rs/rollout/src/policy.rs | 顶层、ResponseItem、Memory 输入和 EventMsg 的分类规则 |
codex-rs/thread-store/src/types.rs | Store 接收原始项并负责应用共享策略;恢复时从规范元数据确定模式 |
codex-rs/thread-store/src/live_thread.rs | 写入前测量、Store 调用和写入后元数据同步的顺序 |
codex-rs/thread-store/src/local/live_writer.rs | 本地 Store 过滤、空结果短路、记录与刷新 |
codex-rs/rollout/src/persistence_metrics_tests.rs | 两种模式下 ItemCompleted、Review Mode 表示选择的断言 |
本节边界
本节只回答“哪些对象有资格进入 Rollout”。选中对象何时对恢复可见、实时事件与持久化事件为何可能分叉,以及 Recorder 如何处理并发写入和关闭,需要继续考察事件发送顺序与异步写入协议。
源码导航
codex-rs/rollout/src/policy.rscodex-rs/protocol/src/protocol.rscodex-rs/thread-store/src/types.rscodex-rs/thread-store/src/live_thread.rscodex-rs/thread-store/src/local/live_writer.rscodex-rs/rollout/src/persistence_metrics_tests.rs
评论
登录后即可评论