SQ 进、EQ 出:同一件事两副面孔
命令走进程内的 Submission Queue。事件走能写成 JSON 的 Event Queue。Rust 名叫 TurnStarted,磁盘上仍写 task_started。
本页解决的问题
先给结论「SQ 进、EQ 出:同一件事两副面孔」要解决的关键问题是什么?
命令走进程内的 Submission Queue。事件走能写成 JSON 的 Event Queue。Rust 名叫 TurnStarted,磁盘上仍写 task_started。
跟着交接处走,不要只看 Demo。 系统是否可靠,往往取决于模型、工具、状态、权限和人的交接处。把每次交接都当成可以观察、测试和恢复的地方。
为一个自动化步骤写清输入、负责人、审批和恢复动作。
一次运行成功了,却说不清发生了什么,也无法安全重放。
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。
「先玩一遍 · 同一件事,进和出各长什么样」的能力藏在每次交接里
「你给侧栏等 type 等于 turn_started 。联调那天字段对得上, type 却写成 task_started 。你改成新名,旧夹具里的旧名还能解出来」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。
先写清状态,再增加能力
从「然后你加了一个自己的事件。本地和内核一起编,过了。隔壁旧版 MCP 客户端解不出来。再过一周,新版写下的 rollout(会话落盘文件)拿到旧版里 resume。那一行被跳过, parse_errors 加一,会话还能开,少了一段生命周期」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。
- 生成 UUID7 作为提交 id session/mod.rs L918
- 把 Op 包成 Submission session/mod.rs L817
- 送进容量 512 的 SQ session/mod.rs L833
成功路径不能代表系统可靠
用「进阶一问:若把 TurnStarted 的 serde 改成只保留 rename = "turn_started" ,旧 rollout 会在哪一条边界上断」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。
从「先玩一遍 · 同一件事,进和出各长什么样」走到「思路一 · 命令和事件拆成两种语言」
「先玩一遍 · 同一件事,进和出各长什么样」先把问题落在「投入 TurnInput,看 SQ 信封和 EQ 盒子怎么对上 播放 单步 重置 盖子上的 type task_started turn_started future_event 或自己写 前两个都能解成 TurnStarted。第三个看 MCP 摔碎、resume 跳行。回车生效。 下行 · Submission Queue bounded 0 /512 还没投入命令 Submission 信封 等投稿。只有 id…」上;到了「思路一 · 命令和事件拆成两种语言」,讨论继续推进到「你给侧栏等 type 等于 turn_started 。联调那天字段对得上, type 却写成 task_started 。你改成新名,旧夹具里的旧名还能解出来」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。
把这条判断带到下一个场景
分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。
- 「先玩一遍 · 同一件事,进和出各长什么样」:投入 TurnInput,看 SQ 信封和 EQ 盒子怎么对上 播放 单步 重置 盖子上的 type task_started turn_started future_event 或自己写 前两个都能解成 TurnStarted。第三个看 MCP 摔碎、resume 跳行。回车生效。 下行 · Submission Queue bounded 0 /512 还没投入命令 Submission 信封 等投稿。只有 id…
- 「思路一 · 命令和事件拆成两种语言」:你给侧栏等 type 等于 turn_started 。联调那天字段对得上, type 却写成 task_started 。你改成新名,旧夹具里的旧名还能解出来
- 「最后的要点」:send_event 用 sub_id 做 Event.id session/mod.rs L1952
最后的「最后的要点」把讨论落到「send_event 用 sub_id 做 Event.id session/mod.rs L1952」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。
我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。
读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。
如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。
还没有这篇文章的讨论。