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。