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。