OpenAI Codex · 事件语言 SQ 进、EQ 出:同一件事两副面孔 命令走进程内的 Submission Queue。事件走能写成 JSON 的 Event Queue。Rust 名叫 TurnStarted,磁盘上仍写 task_started。 一句话速览 命令走进程内的 Submission Queue。事件走能写成 JSON 的 Event Queue。Rust 名叫 TurnStarted,磁盘上仍写 task_started。
课程目标 读完能说清三件事。命令从 Submission Queue 进内核,事件从 Event Queue 出来。同一条生命周期,代码里叫 TurnStarted ,写到 JSON 上却是 task_started 。旧客户端碰到不认识的 type ,同进程编不过,跨版本 JSON 解不出,resume 旧文件则跳行继续开。
先玩一遍 · 同一件事,进和出各长什么样
投入 TurnInput,看 SQ 信封和 EQ 盒子怎么对上
播放 单步 重置
盖子上的 type
task_started turn_started future_event
或自己写
前两个都能解成 TurnStarted。第三个看 MCP 摔碎、resume 跳行。回车生效。
下行 · Submission Queue bounded 0 /512
还没投入命令 Submission 信封 等投稿。只有 id 和 op,没有 JSON。
上行 · Event Queue unbounded · 0 条
事件还没出来 先走左边的命令通道。
MCP 原样门 等事件 resume 跳行门 等落盘 对照出口 DSH / Grok 还没上场
逻辑轨迹 · 动画每一步对应源码里的哪一段
生成 UUID7 作为提交 id session/mod.rs L918 把 Op 包成 Submission session/mod.rs L817 送进容量 512 的 SQ session/mod.rs L833 submission_loop 按变体分发 handlers.rs L526 send_event 用 sub_id 做 Event.id session/mod.rs L1952 需要时再发 legacy 副本 session/mod.rs L1965 按白名单决定是否写入 rollout session/mod.rs L2169 送进 unbounded EQ session/mod.rs L2185 MCP 把整个 Event 序列化成 codex/event outgoing_message.rs L117 resume 时坏行计入 parse_errors recorder.rs L1046
点播放,看同一句话从 SQ 进、从 EQ 出,两边各长什么样。
进的形状 左边是进程内命令。TurnInput 带着 oneshot 回调,所以整封 Submission 不做 serde。 出的形状 右边是能写成 JSON 的 Event。id 对上左边那条提交,盖子上的 type 才是对外词。 切到 future_event MCP 解不出来。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 ,旧前端看 ExecCommandBegin 或 AgentMessage 。队列上会出现重复语义。这是迁移动线,给还没迁到 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_exhaustive , submission_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_started 、 turn_started 、 future_event 。推演 MCP 原样解、Codex resume、DSH、Grok 各自怎样。哪一行会让 MCP 失败,哪一行会让 DSH 拒绝整份日志,哪两行在 Codex 里其实是同一个变体。 进阶一问:若把 TurnStarted 的 serde 改成只保留 rename = "turn_started" ,旧 rollout 会在哪一条边界上断。
Takeaway: 命令通道和事件通道分开,命令可以带回调,事件必须能写成 JSON。wire 名和代码名分开写,改标识符时用 rename 保住磁盘。未知 type 先选一条默认方向:拒绝、跳行,或收成 Unknown。