OpenAI Codex · 事件语言

SQ 进、EQ 出:同一件事两副面孔

命令走进程内的 Submission Queue。事件走能写成 JSON 的 Event Queue。Rust 名叫 TurnStarted,磁盘上仍写 task_started。

课程目标读完能说清三件事。命令从 Submission Queue 进内核,事件从 Event Queue 出来。同一条生命周期,代码里叫 TurnStarted,写到 JSON 上却是 task_started。旧客户端碰到不认识的 type,同进程编不过,跨版本 JSON 解不出,resume 旧文件则跳行继续开。
先玩一遍 · 同一件事,进和出各长什么样
投入 TurnInput,看 SQ 信封和 EQ 盒子怎么对上
盖子上的 type
前两个都能解成 TurnStarted。第三个看 MCP 摔碎、resume 跳行。回车生效。
下行 · Submission Queuebounded 0/512
还没投入命令
Submission 信封等投稿。只有 id 和 op,没有 JSON。
上行 · Event Queueunbounded · 0
事件还没出来先走左边的命令通道。
MCP 原样门等事件
resume 跳行门等落盘
对照出口DSH / Grok 还没上场
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 生成 UUID7 作为提交 idsession/mod.rs L918
  2. 把 Op 包成 Submissionsession/mod.rs L817
  3. 送进容量 512 的 SQsession/mod.rs L833
  4. submission_loop 按变体分发handlers.rs L526
  5. send_event 用 sub_id 做 Event.idsession/mod.rs L1952
  6. 需要时再发 legacy 副本session/mod.rs L1965
  7. 按白名单决定是否写入 rolloutsession/mod.rs L2169
  8. 送进 unbounded EQsession/mod.rs L2185
  9. MCP 把整个 Event 序列化成 codex/eventoutgoing_message.rs L117
  10. resume 时坏行计入 parse_errorsrecorder.rs L1046
点播放,看同一句话从 SQ 进、从 EQ 出,两边各长什么样。
进的形状左边是进程内命令。TurnInput 带着 oneshot 回调,所以整封 Submission 不做 serde。
出的形状右边是能写成 JSON 的 Event。id 对上左边那条提交,盖子上的 type 才是对外词。
切到 future_eventMCP 解不出来。resume 把这一行丢进 parse_errors,会话照开。DSH 会拒绝整份日志,Grok 收成 Unknown。
教学示意:提交 id 为课程化短号,真实实现是 UUID7。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 命令和事件拆成两种语言
它解决什么问题

你给侧栏等 type 等于 turn_started。联调那天字段对得上,type 却写成 task_started。你改成新名,旧夹具里的旧名还能解出来。

然后你加了一个自己的事件。本地和内核一起编,过了。隔壁旧版 MCP 客户端解不出来。再过一周,新版写下的 rollout(会话落盘文件)拿到旧版里 resume。那一行被跳过,parse_errors 加一,会话还能开,少了一段生命周期。

命令里带着 oneshot 回调、审批决定,甚至 realtime 音频帧。事件要进 rollout,要被 MCP 写成 JSON,要被旧客户端按 type 分发。方向、寿命、能不能过网,叠在同一种「消息」上会互相拖累。

思路是什么

模块头只用四行,把说话方式写死:一次会话里,客户端和 agent 用 SQ / EQ 异步通信。

codex-rs/protocol/src/protocol.rs第 1 至 4 行
//! Defines the protocol for a Codex session between a client and an agent.
//!
//! Uses a SQ (Submission Queue) / EQ (Event Queue) pattern to asynchronously communicate
//! between user and agent.
源码快照说明:依据本地仓库 openai/codex,核对文件 codex-rs/protocol/src/protocol.rs,commit 4f39251a01,核对日期 2026-08-22。代码块保留源码原文,这四行就是整课的模式声明。

下行条目是 Submission。它有关联用的 id,有要执行的 Op(内核动词,当前 28 个),只派生 Debug,没有 serde。上行条目是 Event。它有 serde。id 对上当初那条提交,msg 才是事件本体。

出处:codex-rs/protocol/src/protocol.rs 第 185 至 200 行;codex-rs/protocol/src/protocol.rs 第 1276 至 1283 行

会话启动时同时建两条通道。下行 bounded,容量 512。上行 unbounded。客户端连打 512 条还没被 loop 收走,下一次 send 会等。事件可以堆积,占内存,不反压这一轮。

出处:codex-rs/core/src/session/mod.rs 第 460 至 461 行;codex-rs/core/src/session/mod.rs 第 533 至 534 行

客户端 submit Op SQ 512 submission_loop 按 Op 变体分发 send_event Event Queue unbounded 客户端 next_event 同一条 UUID7:左边是 Submission.id,右边是 Event.id
教学化结构图:命令从左边进,事件从右边出,用同一条 id 对上。

TurnInput 的路由结果走 oneshot,不走 Event Queue。EventMsg 描述这一轮发生了什么。oneshot 只回答「这条提交有没有被接住」。

出处:codex-rs/core/src/session/handlers.rs 第 515 至 526 行

为什么长期成立

命令是人发的,频率低,堵住可以反压。事件是模型和工具喷出来的,堵住会把这一轮卡住。换语言重写,只要命令带回调、事件要落盘,这两条队列还是得分开。

思路二 · wire 名保住磁盘,代码名可以改
它解决什么问题

Rust 变体已经改名叫 TurnStarted。若 JSON 上的字符串跟着改,旧 rollout 和旧客户端会在反序列化边界上断。按标识符名猜 wire 名,会猜错。

思路是什么

serde 写出 task_started,读入时也认 turn_started。Display 和指标走 turn_started。同一变体两套字符串:磁盘保住旧名,代码用新名。

出处:codex-rs/protocol/src/protocol.rs 第 1337 至 1340 行

item 生命周期还会再喷一份旧名字。新前端看 ItemStarted,旧前端看 ExecCommandBeginAgentMessage。队列上会出现重复语义。这是迁移动线,给还没迁到 TurnItem 的消费者留的。

出处:codex-rs/core/src/session/mod.rs 第 1965 至 1973 行;codex-rs/protocol/src/legacy_events.rs 第 65 至 69 行

TurnStarted Rust 变体名 serde 写出 Display task_started 磁盘与 MCP 看到的 turn_started 指标与 alias 读入 旧 rollout 仍能解 新名只是读入别名
教学化对照:改标识符不必改磁盘。代价是同一变体要同时记住两套字符串。
改代码名,先用 rename 保住已经落盘的字符串。
为什么长期成立

标识符可以改,已经落盘的字符串改不起。rename 加 alias 是给磁盘留后门的通用做法。指标用哪一套,要单独测,不要假设和 serde 相同。

思路三 · 未知 type 的默认方向要先写下来
它解决什么问题

EventMsg 是内部事件词表,81 个变体,没有 #[serde(other)],也没标 non_exhaustive。加一个新 type,旧读取器怎么办,不能靠「看情况」。

思路是什么

三条路径,答案都写在代码里。

同进程、同版本

TUI、exec、MCP 和内核链到同一份类型。穷尽 match 编不过。旧客户端若还没升级,根本不会和这份新内核链在一起。

跨版本 JSON

MCP 把整个 Event 序列化成 codex/event。旧客户端用旧词表去解,未知 type 让 serde 失败。内核已经发出去了,失败发生在客户端。

resume 旧文件

坏行把 parse_errors 加一,然后 continue。未知 type 不会让整个会话打不开。它会少一行。函数仍返回已经解出来的 items。

出处:codex-rs/mcp-server/src/outgoing_message.rs 第 108 至 133 行;codex-rs/rollout/src/recorder.rs 第 1009 至 1071 行

Op 反过来。它标了 non_exhaustivesubmission_loop 末尾 _ => false,未知命令被丢掉,loop 不崩。事件是对外词表,漏一个变体要在编译期被看见。命令面向内部扩展,丢掉比崩掉更安全。

出处:codex-rs/core/src/session/handlers.rs 第 684 行

未知 type 同进程:穷尽 match 编不过 跨版本 JSON:serde 失败 resume:跳行,parse_errors 加一 会话仍开,少一行
教学化路径图:同一份未知事件,编译期、JSON 边界、落盘恢复各有一个落点。
为什么长期成立

词表会变。先决定未知 type 的默认方向:拒绝打开、跳过坏行,或收成 Unknown。三条都能抄,不要让三条路径各做一套却不写下来。真源事件和通知流可以给不同默认值,但要写在信封上。

横向对比 · 不认识的 type 怎么办

DSH:未知且未标 ignorable 就拒绝

DSH 把事件日志当成真源。信封上有一个 ignorable?: true。缺这个标记时,读取器碰到不认识的 type 必须拒绝重建,不能悄悄丢掉。忘了打标记,结果是过分拒绝,比静默恢复一份被掏空的会话更安全。

代价很清楚:旧 harness 打不开新日志。换来的是「能打开就完整」。Codex 的 EventMsg 已经 81 个,还要给 exec 输出和审批发瞬时事件,这些东西若全部成为真源,JSONL 会按 token 涨。

已核对源码 · 2026-08-22 · DSH · 日志重建不变量 · packages/core/session/src/types.ts 第 404 至 422 行

Grok:未知收成 Unknown,必须静默忽略

Grok 的会话事件协议只有 6 个变体。Unknown#[serde(other)]。模块头写明:旧消费者碰到新的 event_type,解成 Unknown,不要失败。消费者必须静默忽略。原始类型名不会被保留。

适合通知流。通知丢了,会话还能靠别的状态活。Codex 的 TurnStarted 是 rollout 截断边界,真源事件不能静默丢。resume 路径选择跳过坏行,比 Grok 更接近「打开」,比 DSH 更接近「尽量打开」。

已核对源码 · 2026-08-22 · crates/common/xai-tool-protocol/src/session_event.rs 第 11 至 65 行
课堂练习
01

三行 JSON,四个出口

准备三行,type 分别是 task_startedturn_startedfuture_event。推演 MCP 原样解、Codex resume、DSH、Grok 各自怎样。哪一行会让 MCP 失败,哪一行会让 DSH 拒绝整份日志,哪两行在 Codex 里其实是同一个变体。

进阶一问:若把 TurnStarted 的 serde 改成只保留 rename = "turn_started",旧 rollout 会在哪一条边界上断。

Takeaway:命令通道和事件通道分开,命令可以带回调,事件必须能写成 JSON。wire 名和代码名分开写,改标识符时用 rename 保住磁盘。未知 type 先选一条默认方向:拒绝、跳行,或收成 Unknown。