第 2.8 节从协议分层解释了 ResponseItem、EventMsg 和 UI 投影为何不能合并。本节把视角收窄到持久化:当 Session 产生一个 EventMsg 时,客户端收到它和未来 Session 能恢复它,究竟是什么关系?
直觉上,人们常把这两个结果理解成一次“发送并保存”操作。源码却给出更精确的答案:普通事件路径会先尝试持久化,再向客户端通道投递;两个步骤有明确顺序,却没有共同提交或共同失败。理解这一点,才能正确解释“界面已经显示,但重启后没有”以及“历史中存在,但当前客户端没收到”这两类故障。
三个名字相近但职责不同的对象
讨论调用链以前,先区分三个协议层次:
| 对象 | 主要字段 | 主要消费者 |
|---|---|---|
Event | 关联提交的 id 与 EventMsg payload | Session 的事件接收方 |
EventMsg | 具体运行事件 | Core 观察者、投影层和客户端适配器 |
RolloutItem::EventMsg | 持久化联合类型中的事件变体 | Rollout 策略、Recorder 和恢复逻辑 |
普通发送路径不会把整个 Event 写入 Rollout。它克隆 event.msg,包装成 RolloutItem::EventMsg;外层的关联 id 不随这个包装直接保存。某些事件 payload 自身还带有 thread_id、turn_id 或 item_id,那是另一组有明确语义的关联键,不能与 Event.id 混为一谈。
这种类型关系也解释了两条路径为何有交集但不同构:事件通道传递 Event,Rollout 保存经过策略筛选的 RolloutItem;二者并不是同一份字节流的两个副本。
普通事件路径先持久化,再投递
Session::send_event 先处理 Turn 错误状态和内部追踪,再构造 Event,最终进入 send_event_raw_with_persistence。后者的执行顺序是:
async send_event_raw_with_persistence(event, persist):
if persist:
persist_rollout_items([
RolloutItem::EventMsg(copy(event.msg))
])
record_protocol_event(event.msg)
deliver_event_raw(event)
其中 persist_rollout_items 会调用 LiveThread::append_items。上一节已经说明,Store 最终按 history_mode 决定这个 EventMsg 是否进入规范历史。如果策略过滤后的序列为空,本地 Store 直接成功返回;因此,“先持久化”对 Delta、ItemStarted、审批请求等瞬时事件,准确含义是先经过持久化入口和策略判定,而不是先写下一行 JSONL。
投递侧的 deliver_event_raw 也有自己的本地状态更新:它先根据事件刷新最后已知的 Agent Status,再通过 tx_event 发送 Event。Session 初始化时使用的是无界 async_channel,所以这条通道主要承担进程内事件扇出,不是磁盘队列,也不是重连后的历史来源。
调用顺序不等于原子性
虽然源码用 await 固定了先后关系,但普通路径没有把两步放进同一个事务。
persist_rollout_items 的返回类型是 ()。如果 LiveThread::append_items 失败,它记录一条 error 日志,然后返回;send_event_raw_with_persistence 仍会继续投递客户端。反过来,如果 Rollout 已经接受事件,而 tx_event.send 发现接收端关闭,投递函数只记录 debug 日志,也不会撤销前面的持久化。
于是,一个事件可能落入下列状态:
| 持久化侧 | 投递侧 | 结果 |
|---|---|---|
| 策略保留且写入成功 | 发送成功 | 当前客户端可见,恢复历史也存在 |
| 策略保留但写入失败 | 发送成功 | 当前客户端可见,恢复时可能缺失;持久化错误只见于日志 |
| 策略保留且写入成功 | 通道已关闭 | 当前接收方未见,恢复历史仍可存在 |
| 策略主动丢弃 | 发送成功 | 预期的瞬时事件:只服务当前运行期 |
| 跳过持久化 | 发送成功 | 特殊路径显式选择只投递,不物化本地 Rollout |
这张表给出两个实用诊断规则:客户端见过某事件,不能证明 Rollout 中存在它;Rollout 中存在某事件,也不能证明某个客户端连接成功接收了它。
完成事件如何兼容两种历史格式
emit_turn_item_completed 构造 EventMsg::ItemCompleted 后调用普通 send_event。send_event 投递原始事件以后,还会调用 as_legacy_events 生成需要兼容的旧式事件,并让这些事件再次走 send_event_raw。
于是同一个完成语义在运行期可能出现不止一种事件表示。不过 8.2 节的持久化策略会在 Store 边界消除重复:
Paginated保留结构化ItemCompleted,丢弃对应的旧式完成事件;Legacy通常丢弃ItemCompleted,保留旧式事件;- Plan 与 Sleep 的完成项按兼容例外处理。
这里可以清楚看到 Event Stream 与 Rollout 的不同目标。事件流可以为了兼容不同观察者而发送多个表示;规范历史必须根据 Thread 契约选出唯一表示。
还有三类路径不能套用普通发送模型
只持久化,不产生对应客户端事件
Session 多处直接调用 persist_rollout_items 保存 ResponseItem、WorldState、TurnContext 和 Compacted。这些对象对恢复至关重要,但不必都以同构 Event 投递。Rollout 因而不可能由客户端事件流完整重建。
未物化 Thread 可以只投递
send_event_raw_without_materializing_rollout 会先检查当前 Rollout 路径。若本地路径已分配但文件尚不存在,它把 persist 设为 false,避免仅仅发送一个设置类事件就创建 Rollout。事件仍可投递,持久化则等待真正需要物化 Thread 的路径。
这不是持久化故障,而是显式的生命周期选择。诊断时应先分清“策略过滤”“刻意跳过”和“写入失败”。三者在客户端看来都可能表现为“事件只出现于当前连接”,但原因不同。
回滚使用更强的显式顺序
Thread 回滚不是简单调用普通 send_event。处理器先在内存中应用重建结果,再显式追加 ThreadRolledBack 标记并调用 flush_rollout,最后通过 deliver_event_raw 投递回滚事件。若刷新失败,它先发送 Warning,然后仍投递回滚结果。
这个特例说明:当业务操作需要明确的持久化屏障时,调用点必须显式表达它,不能从 send_event_raw 的名称推断出统一 durability 保证。
Resume 恢复状态,而不是重演事件动画
恢复路径读取的是经过筛选的 Rollout。重建逻辑用其中的 ResponseItem、TurnContext、WorldState、压缩检查点和少量规范事件,建立新的 Session 状态。它不会按原来的时间间隔重发文本 Delta,不会重新弹出已经失去等待者的审批请求,也不会因为读到一次工具完成记录就重新执行外部工具。
这一区别可以用命令执行说明:
| 运行期信息 | 当前客户端用途 | Resume 用途 |
|---|---|---|
ExecCommandBegin | 创建运行中状态 | 不重放 |
ExecCommandOutputDelta | 实时追加 stdout/stderr | 不逐片重放 |
ItemCompleted(CommandExecution) | 展示最终聚合结果 | Paginated 历史的结构化投影输入 |
LocalShellCall / 对应 Output | 维持模型调用链 | 重建模型可见历史 |
| 外部命令产生的文件或网络副作用 | 环境中的真实结果 | 不由 Rollout 自动撤销或重做 |
因此,恢复等价性指的是“得到足以继续工作的会话语义”,而不是“让用户再次观看相同动画”。
设计取舍:错误隔离换来了分叉状态
普通发送函数吞掉两侧错误,使事件通道关闭不至于阻止持久化,也使短暂的存储故障不至于让整个事件生产路径停住。这种错误隔离有利于运行时继续推进,却允许 UI 可见状态和可恢复状态发生分叉。
如果上层操作要求更强承诺,就需要额外机制:显式 flush、向调用者返回持久化错误、重试队列,或把完成通知推迟到 durability barrier 之后。源码中的回滚路径已经展示了其中一种做法。不能把这种更强保证反向假定到所有 send_event 调用上。
可用于核对实现的不变量
- 普通
send_event_raw的持久化尝试发生在协议记录和客户端投递之前; Event.id与持久化的EventMsgpayload 是不同层次的关联信息;- 事件通过持久化入口,不代表策略一定写入该事件;
- 持久化失败不能被客户端成功接收事件所掩盖,诊断时仍要检查 error 日志;
- 客户端通道关闭不能删除已经接受的 Rollout 项;
- Resume 不重新执行工具、不重发审批,也不逐片重放历史 Delta;
- 需要 durability barrier 的操作必须显式调用
flush或使用更强的业务协议。
源码与测试证据
| 位置 | 可以核对的结论 |
|---|---|
codex-rs/protocol/src/protocol.rs | Event 外层关联 ID、EventMsg 和 RolloutItem 的类型关系 |
codex-rs/core/src/session/mod.rs | send_event、持久化优先顺序、错误处理、Agent Status 更新与通道投递 |
codex-rs/core/src/session/handlers.rs | 未物化设置事件与 Thread 回滚采用的特殊路径 |
codex-rs/rollout/src/policy.rs | 哪些 EventMsg 真正进入两种历史格式 |
codex-rs/core/src/session/tests.rs | Started/Completed 事件顺序、回滚标记持久化和事件接收 |
codex-rs/rollout/src/persistence_metrics_tests.rs | 完成事件在 Legacy/Paginated 中选择不同规范表示 |
本节边界
本节只分析 Session 层的持久化尝试与事件投递。LiveThread::append_items 返回以前,数据如何穿过 Recorder 的命令通道、Writer Task 与刷新屏障,是下一节的主题。
源码导航
codex-rs/core/src/session/mod.rscodex-rs/core/src/session/handlers.rscodex-rs/protocol/src/protocol.rscodex-rs/rollout/src/policy.rscodex-rs/core/src/session/tests.rscodex-rs/rollout/src/persistence_metrics_tests.rs
评论
登录后即可评论