SQ in, EQ out: two faces of the same thing
Commands walk the Submission Queue; events walk the Event Queue. Rust calls it TurnStarted; JSON still writes task_started. Where an unknown type lands on each of the three paths
THE QUESTION THIS PAGE ANSWERS
ANSWER FIRSTWhat is the key idea behind “SQ in, EQ out: two faces of the same thing”?
Commands walk the Submission Queue; events walk the Event Queue. Rust calls it TurnStarted; JSON still writes task_started. Where an unknown type lands on each of the three paths
Follow the handoffs, not the demo. A system becomes dependable at the boundaries between model, tools, state, permissions, and people. Read each handoff as a place where you can observe, test, and recover.
Name the input, owner, approval, and recovery action for one automated step.
A successful run that cannot explain what happened or be safely repeated.
TurnStarted, JSON writes task_started. An old client that hits an unknown type fails to compile in-process, fails to decode cross-version JSON, and on resume of an old file skips the line and still opens.
- Mint a UUID7 as the submission idsession/mod.rs L918
- Wrap the Op as a Submissionsession/mod.rs L817
- Send it into the SQ of capacity 512session/mod.rs L833
- submission_loop dispatches by varianthandlers.rs L526
- send_event uses sub_id as Event.idsession/mod.rs L1952
- Emit a legacy copy when neededsession/mod.rs L1965
- An allowlist decides whether to write the rolloutsession/mod.rs L2169
- Send it into the unbounded EQsession/mod.rs L2185
- MCP serializes the whole Event as codex/eventoutgoing_message.rs L117
- On resume, a bad line increments parse_errorsrecorder.rs L1046
You told the sidebar to wait for type equal to turn_started. On integration day the fields lined up, but type was written task_started. You switch to the new name; the old name in old fixtures still decodes.
Then you added an event of your own. Local and kernel compiled together; it passed. The old MCP client next door could not decode it. A week later, a rollout written by the new build (the session-on-disk file) is resumed in the old build. That line is skipped, parse_errors increments, the session still opens — one lifecycle slice is missing.
A command carries a oneshot callback, an approval decision, even a realtime audio frame. An event has to enter the rollout, be written as JSON by MCP, and be dispatched by old clients on type. Direction, lifetime, and whether it can cross the wire — stacked on one “message” they drag each other down.
The module header uses four lines to lock the speaking style: in one session, client and agent talk asynchronously over 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, checked file codex-rs/protocol/src/protocol.rs, commit 4f39251a01, checked on 2026-08-22. The code block keeps the original. These four lines are the lesson’s pattern statement.The downlink item is a Submission. It has an id for correlation, an Op to run (a kernel verb; 28 today), derives only Debug, and has no serde. The uplink item is an Event. It has serde. The id matches the original submit; msg is the event body.
Source:codex-rs/protocol/src/protocol.rs lines 185–200; codex-rs/protocol/src/protocol.rs lines 1276–1283
Session start builds both channels at once. Downlink is bounded, capacity 512. Uplink is unbounded. If the client fires 512 items the loop has not taken, the next send waits. Events can pile up, use memory, and do not back-pressure this turn.
Source:codex-rs/core/src/session/mod.rs lines 460–461; codex-rs/core/src/session/mod.rs lines 533–534
A TurnInput routing result walks oneshot, not the Event Queue. EventMsg describes what happened this turn. oneshot only answers “was this submit caught.”
Source:codex-rs/core/src/session/handlers.rs lines 515–526
Commands are sent by people, at low frequency; a jam can back-pressure. Events are sprayed by the model and tools; a jam would stall this turn. Rewrite in another language: as long as commands carry callbacks and events must persist, these two queues still have to split.
The Rust variant is already renamed TurnStarted. If the JSON string followed, old rollouts and old clients would break at the deserialize boundary. Guessing the wire name from the identifier guesses wrong.
serde writes task_started and also reads turn_started. Display and metrics walk turn_started. One variant, two strings: disk keeps the old name, code uses the new one.
Source:codex-rs/protocol/src/protocol.rs lines 1337–1340
The item lifecycle also sprays one more old name. New frontends watch ItemStarted; old frontends watch ExecCommandBegin or AgentMessage. Duplicate meaning will sit on the queue. That is a migration path, left for consumers not yet moved to TurnItem.
Source:codex-rs/core/src/session/mod.rs lines 1965–1973; codex-rs/protocol/src/legacy_events.rs lines 65–69
Identifiers can change; strings already on disk cannot afford to. rename plus alias is the usual back door for disk. Which set metrics use needs its own test — do not assume it matches serde.
EventMsg is the internal event vocabulary: 81 variants, no #[serde(other)], not marked non_exhaustive. Add a new type and the old reader’s fate cannot be “it depends.”
Three paths; the answers are all written in the code.
TUI, exec, MCP, and the kernel link the same type. An exhaustive match will not compile. An old client that has not upgraded will not be linked with this new kernel at all.
MCP serializes the whole Event as codex/event. An old client decodes with the old vocabulary; an unknown type makes serde fail. The kernel already sent it; the failure happens on the client.
A bad line increments parse_errors, then continue. An unknown type will not keep the whole session from opening. It will miss one line. The function still returns the items it already decoded.
Source:codex-rs/mcp-server/src/outgoing_message.rs lines 108–133; codex-rs/rollout/src/recorder.rs lines 1009–1071
Op flips. It is marked non_exhaustive; the tail of submission_loop is _ => false; an unknown command is dropped and the loop does not crash. Events are the outward vocabulary — a missing variant must be seen at compile time. Commands face inward extension; dropping is safer than crashing.
Source:codex-rs/core/src/session/handlers.rs line 684
Vocabularies change. Decide the default direction for an unknown type first: refuse to open, skip the bad line, or collect as Unknown. All three can be copied — do not let the three paths each invent a set and never write it down. Source-of-truth events and notification streams can have different defaults, but write them on the envelope.
DSH: unknown and not marked ignorable is a refuse
DSH treats the event log as source of truth. The envelope has ignorable?: true. Without that mark, a reader that hits an unknown type must refuse to rebuild — it cannot quietly drop. Forget the mark and you over-refuse, which is safer than silently restoring a hollowed-out session.
The cost is clear: an old harness cannot open a new log. What you buy is “if it opens, it is complete.” Codex’s EventMsg is already 81, and still has to emit ephemeral events for exec output and approvals. If all of that became source of truth, the JSONL would grow by the token.
Grok: unknown collects as Unknown, must ignore silently
Grok’s session event protocol has only 6 variants. Unknown carries #[serde(other)]. The module header says: an old consumer that hits a new event_type decodes as Unknown and must not fail. Consumers must ignore silently. The original type name is not kept.
Fits a notification stream. Drop a notice and the session can still live on other state. Codex’s TurnStarted is a rollout cut boundary; a source-of-truth event cannot be dropped silently. The resume path skips the bad line — closer to “open” than Grok, closer to “open if you can” than DSH.
Three JSON lines, four exits
Prepare three lines whose type is task_started, turn_started, future_event. Walk MCP as-is decode, Codex resume, DSH, and Grok. Which line fails MCP, which line makes DSH refuse the whole log, and which two lines are actually the same variant in Codex.
Advanced: if TurnStarted serde kept only rename = “turn_started”, on which boundary would an old rollout break.
type, pick a default direction first: refuse, skip the line, or collect as Unknown.
The handoffs inside “Try it first · The same thing: what in and out each look like”
“You told the sidebar to wait for type equal to turn_started .” shows that an Agent is not defined by the model alone. Each handoff between model, context, tools, state, permissions, and people affects both progress and recovery.
Write the state before adding capability
Starting from “Then you added an event of your own.”, split the workflow into starting state, next action, tool result, state update, and stop condition. Debugging then means finding the first lost piece of information or authority instead of saying vaguely that the model “got worse”.
- Mint a UUID7 as the submission id session/mod.rs L918
- Wrap the Op as a Submission session/mod.rs L817
- Send it into the SQ of capacity 512 session/mod.rs L833
A happy path is not reliability
Use “Advanced: if TurnStarted serde kept only rename = “turn_started” , on which boundary would an old rollout break” to replay one successful and one failed run. Record the context, tool result, and owner at each turn; the workflow is maintainable when a second person can follow it without the original builder.
From “Try it first · The same thing: what in and out each look like” to “Idea 1 · Commands and events are two languages”
“Try it first · The same thing: what in and out each look like” grounds the problem in “Drop a TurnInput in; watch how the SQ envelope and EQ box line up Play Step Reset type on the lid task_started turn_started future_event Or write your own The first two both decode as TurnStarted. The third: MC…”. “Idea 1 · Commands and events are two languages” then moves it toward “You told the sidebar to wait for type equal to turn_started . On integration day the fields lined up, but type was written task_started . You switch to the new name; the old name in old fixtures still decodes”. Together, they show that the lesson is not just a conclusion to remember, but a claim with conditions.
Carry the judgment into the next situation
When analyzing an Agent, trace state, action, tool result, and next step in order. Each handoff should explain where information came from, who confirmed it, and where failure stops.
- “Try it first · The same thing: what in and out each look like”: Drop a TurnInput in; watch how the SQ envelope and EQ box line up Play Step Reset type on the lid task_started turn_started future_event Or write your own The first two both decode as TurnStarted. The third: MC…
- “Idea 1 · Commands and events are two languages”: You told the sidebar to wait for type equal to turn_started . On integration day the fields lined up, but type was written task_started . You switch to the new name; the old name in old fixtures still decodes
- “The closing point”: send_event uses sub_id as Event.id session/mod.rs L1952
The final “The closing point” brings the discussion to “send_event uses sub_id as Event.id session/mod.rs L1952”. The useful thing to carry forward is knowing which judgments must be revisited when input, scale, or risk changes.
I turned one judgment from this article into a small experiment I could run today. Knowing what to observe next is more useful than simply remembering the conclusion.
After reading this, I first looked for the conditions behind the idea instead of copying the method into a project. That order made the later trade-offs much clearer.
When this judgment reaches real work, which constraint should be added first? I am curious which step matters most between reading and the first practical attempt.
No discussion on this article yet.