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이 줄을 건너뜁니다. Enter로 적용해요.
하행 · Submission Queuebounded 0/512
아직 명령을 넣지 않았어요
Submission 봉투투고를 기다립니다. id와 op만 있고 JSON은 없어요.
상행 · Event Queueunbounded · 0
이벤트가 아직 안 나왔어요왼쪽 명령 통로를 먼저 가세요.
MCP 그대로 문이벤트 대기
resume 줄 건너뛰기 문디스크 기록 대기
대조 출구DSH / Grok이 아직 안 나왔어요
로직 궤적 · 애니메이션 각 단계가 소스의 어느 구간에 대응하는지
  1. UUID7을 만들어 제출 id로 씁니다session/mod.rs L918
  2. Op를 Submission으로 쌉니다session/mod.rs L817
  3. 용량 512인 SQ로 넣습니다session/mod.rs L833
  4. submission_loop가 변체별로 나눕니다handlers.rs L526
  5. send_event가 sub_id를 Event.id로 씁니다session/mod.rs L1952
  6. 필요할 때 legacy 사본을 또 보냅니다session/mod.rs L1965
  7. 허용 목록으로 rollout에 쓸지 정합니다session/mod.rs L2169
  8. unbounded EQ로 넣습니다session/mod.rs L2185
  9. MCP가 Event 전체를 codex/event로 직렬화합니다outgoing_message.rs L117
  10. resume 때 깨진 줄은 parse_errors에 들어갑니다recorder.rs L1046
재생을 눌러 보세요. 같은 한 마디가 SQ로 들어가고 EQ로 나올 때, 양쪽 모양이 어떤지 보입니다.
들어가는 모양왼쪽은 프로세스 안 명령입니다. TurnInput이 oneshot 콜백을 달고 있어, Submission 봉투 전체는 serde를 하지 않아요.
나오는 모양오른쪽은 JSON으로 쓸 수 있는 Event입니다. id가 왼쪽 그 제출과 맞고, 뚜껑의 type이 바깥 단어예요.
future_event로 바꾸기MCP는 풀지 못합니다. resume은 이 줄을 parse_errors에 넣고, 세션은 그대로 열어요. DSH는 로그 전체를 거절하고, Grok은 Unknown으로 받습니다.
수업용 도해:제출 id는 수업용 짧은 번호이고, 실제 구현은 UUID7입니다. 로직 궤적 오른쪽 행 번호는 openai/codex 저장소 commit 4f39251a01에 대응합니다.
아이디어 1 · 명령과 이벤트를 언어 둘로 갈라놓기
어떤 문제를 푸는가

사이드바에 typeturn_started이길 기다리라고 했습니다. 연동 날 필드는 맞는데, typetask_started로 쓰여 있었어요. 새 이름으로 바꿔도, 옛 픽스처의 옛 이름은 여전히 풀립니다.

그다음 자기 이벤트를 하나 더했습니다. 로컬과 커널을 같이 빌드하니 통과했어요. 옆자리 옛 MCP 클라이언트는 풀지 못합니다. 일주일 뒤, 새 버전이 쓴 rollout(세션 디스크 파일)을 옛 버전에서 resume합니다. 그 줄은 건너뛰어지고 parse_errors가 하나 늘며, 세션은 열리지만 생명주기 한 토막이 빠집니다.

명령에는 oneshot 콜백, 승인 결정, 심지어 realtime 오디오 프레임이 달립니다. 이벤트는 rollout에 들어가고, MCP가 JSON으로 써야 하며, 옛 클라이언트가 type으로 나눠야 해요. 방향, 수명, 망을 건널 수 있는지를 같은 “메시지”에 쌓으면 서로 발목을 잡습니다.

아이디어는 무엇인가

모듈 머리는 네 줄만으로 말하는 방식을 박아 둡니다. 한 세션에서 클라이언트와 agent는 SQ / EQ로 비동기 통신해요.

codex-rs/protocol/src/protocol.rs1–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만 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행

클라이언트 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행

왜 오래가는가

명령은 사람이 보내고 빈도가 낮아, 막히면 역압해도 됩니다. 이벤트는 모델과 도구가 뿜어 내서, 막히면 이번 턴이 걸려요. 다른 언어로 다시 써도, 명령이 콜백을 달고 이벤트가 디스크에 남아야 하면 이 큐 둘은 여전히 갈라져야 합니다.

아이디어 2 · wire 이름이 디스크를 지키고, 코드 이름은 바꿀 수 있어요
어떤 문제를 푸는가

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행

TurnStarted Rust 변체 이름 serde가 씀 Display task_started 디스크와 MCP가 보는 것 turn_started 지표와 alias 읽기 옛 rollout은 여전히 풀림 새 이름은 읽기 별명일 뿐
수업용 대조:식별자를 바꿔도 디스크는 안 바꿔도 됩니다. 대가는, 같은 변체가 문자열 두 벌을 한꺼번에 기억해야 한다는 점이에요.
코드 이름을 바꿀 때, 먼저 rename으로 이미 디스크에 있는 문자열을 지키세요.
왜 오래가는가

식별자는 바꿀 수 있지만, 이미 디스크에 있는 문자열은 못 바꿉니다. rename과 alias는 디스크에 뒷문을 남기는 흔한 방법이에요. 지표가 어느 벌을 쓰는지는 따로 재 보세요. serde와 같다고 가정하지 마세요.

아이디어 3 · 모르는 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_exhaustive가 붙어 있고, submission_loop 끝은 _ => false라서, 모르는 명령은 버려지고 loop는 안 죽어요. 이벤트는 바깥 어휘라 변체 하나가 빠지면 컴파일 때 보여야 합니다. 명령은 안쪽 확장을 향해, 버리는 편이 죽는 것보다 안전해요.

출처:codex-rs/core/src/session/handlers.rs 684행

모르는 type 같은 프로세스: 빠짐없는 match는 컴파일 안 됨 버전을 넘긴 JSON: serde 실패 resume: 줄 건너뛰기, parse_errors +1 세션은 열리고, 한 줄이 빠짐
수업용 경로도:같은 모르는 이벤트가 컴파일 때, JSON 경계, 디스크 복원에서 각각 착지점 하나를 갖습니다.
왜 오래가는가

어휘는 바뀝니다. 모르는 type의 기본 방향을 먼저 정하세요. 열기를 거절할지, 깨진 줄을 건너뛸지, Unknown으로 받을지. 셋 다 베낄 수 있어요. 세 경로가 제각각 한 벌을 만들고 적어 두지 않으면 안 됩니다. 참 소스 이벤트와 알림 흐름은 기본값을 달리해도 되지만, 봉투에 써야 해요.

가로 비교 · 모르는 type을 어떻게 할지

DSH: 모르고 ignorable도 아니면 거절

DSH는 이벤트 로그를 참 소스로 봅니다. 봉투에 ignorable?: true가 있어요. 이 표시가 없으면, 모르는 type을 만난 읽기 쪽은 재구성을 거절해야 하고, 몰래 버리면 안 됩니다. 표시를 잊으면 과하게 거절하게 되는데, 속 빈 세션을 조용히 복구하는 것보다 안전해요.

대가는 분명합니다. 옛 harness는 새 로그를 못 열어요. 얻는 것은 “열리면 완전하다”입니다. Codex EventMsg는 이미 81개이고, exec 출력과 승인용 순간 이벤트도 보내야 해요. 이 전부가 참 소스가 되면 JSONL이 토큰 따라 불어납니다.

소스 대조 완료 · 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_started, turn_started, future_event. MCP 그대로 풀기, Codex resume, DSH, Grok이 각각 어떻게 하는지 밀어 보세요. 어느 줄이 MCP를 실패시키고, 어느 줄이 DSH로 로그 전체를 거절시키며, Codex에서 어느 두 줄이 실제로는 같은 변체인지.

한 단계 더: TurnStarted의 serde를 rename = “turn_started”만 남기면, 옛 rollout이 어느 경계에서 끊기는지.

Takeaway:명령 통로와 이벤트 통로를 갈라놓으세요. 명령은 콜백을 달 수 있고, 이벤트는 JSON으로 쓸 수 있어야 합니다. wire 이름과 코드 이름을 따로 쓰고, 식별자를 바꿀 때는 rename으로 디스크를 지키세요. 모르는 type은 기본 방향을 먼저 고르세요. 거절, 줄 건너뛰기, 또는 Unknown으로 받기.