SQ 进、EQ 出:同一件事两副面孔
命令走进程内的 Submission Queue。事件走能写成 JSON 的 Event Queue。Rust 名叫 TurnStarted,磁盘上仍写 task_started。
TurnStarted,写到 JSON 上却是 task_started。旧客户端碰到不认识的 type,同进程编不过,跨版本 JSON 解不出,resume 旧文件则跳行继续开。
- 生成 UUID7 作为提交 idsession/mod.rs L918
- 把 Op 包成 Submissionsession/mod.rs L817
- 送进容量 512 的 SQsession/mod.rs L833
- submission_loop 按变体分发handlers.rs L526
- send_event 用 sub_id 做 Event.idsession/mod.rs L1952
- 需要时再发 legacy 副本session/mod.rs L1965
- 按白名单决定是否写入 rolloutsession/mod.rs L2169
- 送进 unbounded EQsession/mod.rs L2185
- MCP 把整个 Event 序列化成 codex/eventoutgoing_message.rs L117
- resume 时坏行计入 parse_errorsrecorder.rs L1046
你给侧栏等 type 等于 turn_started。联调那天字段对得上,type 却写成 task_started。你改成新名,旧夹具里的旧名还能解出来。
然后你加了一个自己的事件。本地和内核一起编,过了。隔壁旧版 MCP 客户端解不出来。再过一周,新版写下的 rollout(会话落盘文件)拿到旧版里 resume。那一行被跳过,parse_errors 加一,会话还能开,少了一段生命周期。
命令里带着 oneshot 回调、审批决定,甚至 realtime 音频帧。事件要进 rollout,要被 MCP 写成 JSON,要被旧客户端按 type 分发。方向、寿命、能不能过网,叠在同一种「消息」上会互相拖累。
模块头只用四行,把说话方式写死:一次会话里,客户端和 agent 用 SQ / EQ 异步通信。
//! 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 行
TurnInput 的路由结果走 oneshot,不走 Event Queue。EventMsg 描述这一轮发生了什么。oneshot 只回答「这条提交有没有被接住」。
出处:codex-rs/core/src/session/handlers.rs 第 515 至 526 行
命令是人发的,频率低,堵住可以反压。事件是模型和工具喷出来的,堵住会把这一轮卡住。换语言重写,只要命令带回调、事件要落盘,这两条队列还是得分开。
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 行
标识符可以改,已经落盘的字符串改不起。rename 加 alias 是给磁盘留后门的通用做法。指标用哪一套,要单独测,不要假设和 serde 相同。
EventMsg 是内部事件词表,81 个变体,没有 #[serde(other)],也没标 non_exhaustive。加一个新 type,旧读取器怎么办,不能靠「看情况」。
三条路径,答案都写在代码里。
TUI、exec、MCP 和内核链到同一份类型。穷尽 match 编不过。旧客户端若还没升级,根本不会和这份新内核链在一起。
MCP 把整个 Event 序列化成 codex/event。旧客户端用旧词表去解,未知 type 让 serde 失败。内核已经发出去了,失败发生在客户端。
坏行把 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 的默认方向:拒绝打开、跳过坏行,或收成 Unknown。三条都能抄,不要让三条路径各做一套却不写下来。真源事件和通知流可以给不同默认值,但要写在信封上。
DSH:未知且未标 ignorable 就拒绝
DSH 把事件日志当成真源。信封上有一个 ignorable?: true。缺这个标记时,读取器碰到不认识的 type 必须拒绝重建,不能悄悄丢掉。忘了打标记,结果是过分拒绝,比静默恢复一份被掏空的会话更安全。
代价很清楚:旧 harness 打不开新日志。换来的是「能打开就完整」。Codex 的 EventMsg 已经 81 个,还要给 exec 输出和审批发瞬时事件,这些东西若全部成为真源,JSONL 会按 token 涨。
Grok:未知收成 Unknown,必须静默忽略
Grok 的会话事件协议只有 6 个变体。Unknown 带 #[serde(other)]。模块头写明:旧消费者碰到新的 event_type,解成 Unknown,不要失败。消费者必须静默忽略。原始类型名不会被保留。
适合通知流。通知丢了,会话还能靠别的状态活。Codex 的 TurnStarted 是 rollout 截断边界,真源事件不能静默丢。resume 路径选择跳过坏行,比 Grok 更接近「打开」,比 DSH 更接近「尽量打开」。
三行 JSON,四个出口
准备三行,type 分别是 task_started、turn_started、future_event。推演 MCP 原样解、Codex resume、DSH、Grok 各自怎样。哪一行会让 MCP 失败,哪一行会让 DSH 拒绝整份日志,哪两行在 Codex 里其实是同一个变体。
进阶一问:若把 TurnStarted 的 serde 改成只保留 rename = "turn_started",旧 rollout 会在哪一条边界上断。
type 先选一条默认方向:拒绝、跳行,或收成 Unknown。