SQ로 들어가고 EQ로 나온다: 같은 일의 얼굴 둘
명령은 프로세스 안 Submission Queue로 갑니다. 이벤트는 JSON으로 쓸 수 있는 Event Queue로 갑니다. Rust 이름은 TurnStarted이고, 디스크에는 여전히 task_started를 씁니다.
TurnStarted, JSON에는 task_started예요. 옛 클라이언트가 모르는 type을 만나면, 같은 프로세스에서는 컴파일이 안 되고, 버전을 넘긴 JSON은 풀리지 않으며, 옛 파일을 resume하면 그 줄을 건너뛰고 세션은 열립니다.
- UUID7을 만들어 제출 id로 씁니다session/mod.rs L918
- Op를 Submission으로 쌉니다session/mod.rs L817
- 용량 512인 SQ로 넣습니다session/mod.rs L833
- submission_loop가 변체별로 나눕니다handlers.rs L526
- send_event가 sub_id를 Event.id로 씁니다session/mod.rs L1952
- 필요할 때 legacy 사본을 또 보냅니다session/mod.rs L1965
- 허용 목록으로 rollout에 쓸지 정합니다session/mod.rs L2169
- unbounded EQ로 넣습니다session/mod.rs L2185
- MCP가 Event 전체를 codex/event로 직렬화합니다outgoing_message.rs L117
- resume 때 깨진 줄은 parse_errors에 들어갑니다recorder.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만 derive하며 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이 토큰 따라 불어납니다.
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으로 받기.