OpenAI Codex · 루프 골격

3층 Turn Loop: 누가 계속을 결정할 자격이 있나요

눈에 보이는 것은 대화 한 턴이에요. 안쪽에는 태스크 껍질·턴·샘플링 세 층 루프가 겹칩니다. 각 층은 자기 질문 하나만 답하고, 제어권은 한 번에 한 층에만 있어요.

강의 목표읽고 나면 세 가지를 말할 수 있어요. 첫째, 사용자 입력 한 줄이 태스크 껍질·턴·샘플링에서 각각 몇 바퀴인지, 각 층이 언제 들어가고 나가는지. 둘째, 끼어들기·도구 이어 달리기·stop hook을 어느 층이 받는지. 셋째, pending을 왜 두 번 묻는지.
먼저 해보세요 · 한 문장이 세 층에서 각각 몇 바퀴인지
같은 입력: 제어권이 어느 층에 있는지, 각 층 바퀴가 어떻게 느는지 보세요
대본
자르기
대본을 바꿔 어느 층이 바퀴를 더하는지 보세요. 한 층으로 합치면 끼어들기와 hook이 같은 문을 다퉈요.
0태스크 껍질
0
0샘플링
0도구

태스크 껍질 RegularTask

당직 반장. 이 운행이 아직 살아 있나요?

턴 run_turn

당번 기사. 이 턴에서 한 번 더 샘플링할까요?

샘플링 sampling

개찰구. 이 스트림이 끝났나요?

대기 벤치 pending

비어 있어요. 끼어들기는 먼저 여기 앉고, 지금 샘플링은 못 봅니다.

날고 있는 도구
논리 궤적 · 애니메이션 각 단계가 소스의 어느 구간인지
  1. 한가하면 spawn RegularTaskturn_input.rs L242
  2. 태스크 껍질이 TurnStarted를 보낸 뒤 loop에 들어감regular.rs L76
  3. 턴 시작에 스위치대로 pending을 줄 세움turn.rs L305
  4. 샘플링 run_sampling_requestturn.rs L381
  5. 도구는 그때 future를 걸어요stream_events_utils.rs L326
  6. Completed 뒤에야 drainturn.rs L2539
  7. needs_follow_up를 다시 계산turn.rs L423
  8. stop hook이 prompt를 가져오면 continueturn.rs L525
  9. 태스크 껍질이 has_pending_input을 다시 물어요regular.rs L86
  10. 바쁘면 Steered가 pending에 씀turn_input.rs L207
재생을 누르고, 한 문장이 세 층에서 각각 몇 바퀴인지 보세요.
수업용 스케치: 바퀴 수는 이 프리셋 대본을 따라가요. 행 번호는 openai/codex commit 4f39251a01에 대응합니다.
아이디어 1 · 누가 계속을 결정할 자격으로 자르기
어떤 문제를 푸는가

코딩 agent에게 함수 하나를 고치라고 해요. 화면에서는 대화 한 턴입니다. 당신이 한 마디 하고, 잠깐 바쁘더니, 마지막에 “다 고쳤어요”라고 답해요.

바쁠 때 사실은 일이 겹쳐 있어요. 모델이 도구를 불렀고, 중간에 “테스트는 pytest로”를 보탰습니다. 어시스턴트 메시지를 쓴 뒤 Stop hook이 linter를 아직 안 돌렸다고 해서, 샘플링을 한 번 더 해요.

이 일들을 같은 while에 넣으면 불린 몇 개가 출구를 다퉈요. 누가 먼저 보고, 누가 누구를 끊는지는 구두 약속이 됩니다. 층이 하나 줄면 깨끗한 소켓도 하나 줄어요. 끼어들기·이어 달리기·스트림 재시도가 한데 엉킵니다.

아이디어는 무엇인가

Codex는 세 층으로 나눕니다. 태스크 껍질 RegularTask::run은 이 운행이 턴을 한 번 더 열지 정해요. 턴 run_turn은 도구 이어 달리기·끼어들기·hook이 계속할지 정합니다. 샘플링 층은 모델 스트림 하나를 Completed까지 받기만 해요.

바깥 입구 start_or_steer_turn은 세션이 한가한지 스스로 보지 않아요. 반환값은 Core가 이 입력을 받았는지만 말하고, hooks도 샘플링도 기다리지 않습니다. 한가하면 spawn RegularTask, 바쁘면 Steered가 pending에 씁니다. 세 층 루프는 태스크 껍질에서야 돌기 시작해요.

출처: codex-rs/core/src/codex_thread.rs 333–344행 · codex-rs/core/src/session/turn_input.rs 1–9행

태스크 껍질 RegularTask 질문: 이 운행이 아직 살아 있나요. 진입: spawn 뒤. 종료: run_turn이 돌아오고 큐가 비었을 때. 턴 run_turn 질문: 이 턴에서 한 번 더 샘플링할까요. 진입: 태스크 껍질이 호출. 종료: 이어 달리기가 없고 stop hook이 통과할 때. 샘플링 sampling 질문: 이 스트림이 끝났나요. 진입: run_sampling_request. 종료: Completed이고 도구 drain이 끝난 뒤. 재시도는 이 층에 남아요. 취소와 스트림 중단은 Err. 제어권은 아직 턴으로 안 돌아왔습니다. TurnStarted는 한 번만 보내요
교육용 구조도: 바깥 층은 이 운행이 살아 있게 지키고, 가운데는 한 번 더 샘플링할지 정하며, 안쪽은 이 스트림만 받아 끝냅니다.

태스크 껍질은 TurnStarted를 한 번 보내고, 큐에 처리할 입력이 있는 한 run_turn을 다시 호출해요. 두 번째 들어갈 때 next_input은 비어 있고, 새 메시지는 input_queue에서 가져옵니다. turn_id는 고정이라, 화면이 “새 턴이 시작됐어요”를 다시 깜빡이지 않아요.

출처: codex-rs/core/src/tasks/regular.rs 76–90행

턴 층은 샘플링이 돌려준 두 가지를 불린 하나로 합쳐요. model_needs_follow_up || has_pending_input. 참이면 스스로 continue. 거짓이어야 stop hook을 돌립니다. hook이 prompt를 들고 퇴근을 막으면 이 층이 다시 돌고, 태스크 껍질과 샘플링 층은 아직 안 나가요.

출처: codex-rs/core/src/session/turn.rs 423행 · 500–525행

샘플링 층에도 바퀴가 둘 있어요. 바깥은 재시도 가능한 오류, 안쪽은 SSE 스트림 하나. 도구 호출은 Completed를 기다리지 않습니다. OutputItemDone이 오는 즉시 future를 걸어요. 스트림이 Completed를 받으면 먼저 drain_in_flight하고, 결과를 run_turn에 돌려줍니다.

출처: codex-rs/core/src/stream_events_utils.rs 326–327행 · codex-rs/core/src/session/turn.rs 2539–2584행 · 2749행

왜 오래 성립하는가

세 층은 “누가 계속을 결정할 자격인가”로 잘라요. 샘플링 층은 이번 스트림만 보고, 재시도는 해도 턴 전체를 닫지는 못합니다. 턴 층은 도구·pending·예산·hook을 보고, 샘플링을 이어갈 수는 있어도 TurnStarted를 다시 보내지는 못해요. 태스크 껍질은 태스크가 아직 있고, 큐에 일이 남았는지를 봅니다.

파일을 어떻게 쪼개도 이건 안 바뀌어요. 다른 언어로 다시 써도 물을 것은 세 가지입니다. 이 스트림이 끝났나, 이 턴에서 한 번 더 샘플링하나, 이 태스크 운행이 아직 살아 있나.

아이디어 2 · pending을 두 번 물어 stop hook을 사이에 둡니다
어떤 문제를 푸는가

RegularTaskrun_turn이 둘 다 pending을 봐요. 같은 사용자 문장을 두 곳에서 꺼내면 두 바퀴 돕니다. 한곳만 남기면 hook과 늦게 온 메시지가 같은 출구로 밀려 들어가요.

아이디어는 무엇인가

두 곳이 묻는 일은 서로 달라요.

턴 층은 샘플링이 막 끝났을 때 물어요. “이 턴에서 한 번 더 샘플링할까요?” 태스크 껍질은 run_turn이 이미 break한 뒤에 묻습니다. “이 운행이 run_turn에 한 번 더 들어갈까요?” 전자는 끼어들기와 도구 결과를 같은 turn_id에 남겨 둬요. 후자는 화면이 퇴근해도 되는 순간에, 큐에 반드시 처리할 입력이 또 온 경우예요.

샘플링이 돌아옴 도구는 이미 drain 턴이 pending을 물어요 이 턴에서 한 번 더 샘플링할까요 재고 있음: 스스로 continue 태스크 껍질은 이번 run_turn을 아직 기다려요 재고 없음: stop hook 실행 block은 이어 달리고, stop이어야 break 태스크 껍질이 pending을 다시 물어요 이 운행이 run_turn에 한 번 더 들어갈까요
교육용 분기 그림: 두 번의 판단이 stop hook을 사이에 두고, 두 순간의 두 질문을 물어요.

턴 층의 그 질문을 빼면, 모델이 최종 답을 쓴 뒤 run_turn이 stop hook을 돌리고 break해요. 중간의 “테스트는 pytest로”는 태스크 껍질이 run_turn에 다시 들어가길 기다립니다. 기능은 메꿀 수 있지만 함수 반환이 한 번 늘고, stop hook이 끼어들기가 모델에 들어가기 전에 한 바퀴 돕니다.

태스크 껍질의 그 질문을 빼면, run_turnshould_stop으로 돌아온 뒤 태스크가 바로 끝나요. 큐에 늦게 온 사용자 메시지는 사라지거나, 세션이 한가해지길 기다렸다가 maybe_start_turn_for_pending_work가 새 turn_id로 태스크를 엽니다. TurnStarted가 다시 깜빡이고, 재생에서는 턴 통이 둘이 돼요.

stop hook이 block과 prompt를 돌려주면 제어권은 턴 층에 남아요. 샘플링 층은 이미 돌아왔고, 태스크 껍질은 이번 run_turn을 아직 기다립니다. block은 턴 층 내부 이어 달리기. stop은 턴 층이 제어권을 태스크 껍질에 돌려주는 일이에요.

출처: codex-rs/core/src/session/turn.rs 509–537행 · codex-rs/hooks/src/events/stop.rs 67–74행

두 번의 판단이 stop hook을 사이에 두니, 두 번 묻는 거예요.
왜 오래 성립하는가

소스에는 “왜 두 번 묻나”를 따로 쓰지 않았어요. 구현에서 거꾸로 밀어 봅니다. hook이 이어 달릴 때 제어권은 턴 층에 있어 태스크 껍질은 못 봐요. hook이 통과한 뒤에야 태스크 껍질이 “퇴근 순간에 또 온 그 한 줄”을 받을 기회가 생깁니다. 두 질문은 다른 순간에 일어나니 두 번 물어요. 구체적인 언어나 hook 프로토콜과는 상관없습니다.

가로 비교 · 같은 문제의 다른 자르기

DSH: 의미로 잘라 Turn, Step, Inbox

DSH의 바깥 고리는 kick이에요. while (await this.turn()) {}. turn()이 다시 while (true)를 감고, 매 바퀴 preStep 다음 step(). 세 번째 층은 세 번째 while이 아닙니다. Inbox는 배열 둘, next-turnnext-step. 호출 쪽이 큐에 넣을 때 followup·steer·inject를 고르세요.

양쪽 다 세 층이라고 부르지만 자르는 축이 달라요. DSH는 의미: Turn은 일 한 덩어리, Step은 모델 요청 한 번과 도구, Inbox는 말하는 타이밍. Codex는 수명: 태스크 껍질은 이 운행이 살아 있는지, 턴은 이 턴에서 한 번 더 샘플링할지, 샘플링은 이 스트림이 끝났는지. 그래서 DSH는 세션 이벤트에서 큐 둘을 재생할 수 있고, Codex는 스트림 재시도·취소·end_turn을 샘플링 층에 가두고 TurnStarted를 한 번만 깜빡일 수 있어요.

양쪽 모두 소스 대조 완료 · 2026-08-22 · DSH · Inbox

Claude Code: 단일 while과 상태 주머니

메인 루프는 query.ts에 있어요. 가변 상태는 state 객체 하나에 넣고, 루프 꼭대기에서 구조 분해하며, continue에서 주머니 전체를 다시 씁니다. needsFollowUp은 어시스턴트 메시지의 tool_use 블록만 켜요. follow-up이 없으면 같은 층이 압축·stop hook·다음 turn을 이어서 합니다. turnCount가 하나 늘고, transitionnext_turn, 다시 while (true) 꼭대기로.

이어 달리기·압축·stop hook이 같은 상태 주머니에 들어가요. continue 한곳을 고치면 stopHookActive·turnCount·transition을 같이 맞춰야 합니다. Codex는 이 세 일을 세 층에 나누고, DSH는 끼어들기를 Inbox에 줍니다. 양쪽 다 같은 불린 하나로 문을 다투지 않아요.

양쪽 모두 소스 대조 완료 · 2026-08-22
수업 실습
01

태스크 껍질의 그 질문을 항상 거짓으로

모델이 도구를 부르기 시작할 때 “현재 디렉터리도 나열해 줘”를 보태요. 먼저 세 층으로 밀어 보세요. 태스크 껍질·턴·샘플링이 각각 몇 바퀴인지, 이 후속 한 줄을 어느 층이 가져가는지.

그다음 regular.rs 86행의 has_pending_input을 항상 거짓으로 만들어, 태스크 껍질이 첫 run_turn 반환 뒤 바로 끝나게 하세요. 이 후속 한 줄은 사라질까요, 다음 턴을 기다릴까요, 아니면 maybe_start_turn_for_pending_work가 새 turn_id를 줄까요.

Takeaway: 세 층의 종료 조건을 함수 셋으로 쓰세요. 끼어들기는 pending에만. stop hook은 턴 루프에만. 태스크 껍질은 턴이 돌아온 뒤에만 큐를 봅니다. 하나의 while과 불린 셋으로 거두지 마세요.