스트림은 아직 가는데, 도구는 이미 일 시작했어요
모델이 아직 타이핑하는데 파일을 읽는 소리가 이미 났어요. 샘플링 루프의 순서는 이래요. 스트림 안에서 future를 만들고, 끝난 뒤에 한꺼번에 drain. 먼저 persist하고, 그다음 결과를 기다립니다.
- SSE 프레임은 먼저 일반 이벤트로 풀리고, 아직 비즈니스 의미는 없어요responses.rs L164
- kind가 output_item.done이면 OutputItemDone을 만들어요responses.rs L352
- 샘플링 루프는 오는 즉시 handle_output_item_done에 넘겨요turn.rs L2384
- function_call을 히스토리와 rollout에 먼저 씁니다stream_events_utils.rs L316
- 그다음 도구를 pin하고 순서 큐를 밀어요stream_events_utils.rs L320
- 스트림 종료·끊김·취소는 수신 루프를 떠날 뿐이에요turn.rs L2282
- drain은 삽입 순서로 결과를 히스토리에 씁니다turn.rs L2135
- 그제야 취소 토큰을 봐요. Stream은 재시도 가능turn.rs L2760
모델에게 파일 셋을 읽고 요약을 쓰라고 해요. 화면은 아직 타이핑 중인데 파일 읽는 소리가 이미 났어요. 그다음 Esc. 화면은 멈추지만 히스토리에는 그 요청이, 때로는 결과까지 남습니다. 취소는 아무 일도 없었던 거라고 생각하지만, 런타임은 그렇게 장부하지 않아요.
더 흔한 쪽은 이래요. 모델이 function_call 둘을 이미 보냈고 세 번째는 아직 오는 중인데, SSE가 response.completed 전에 닫힙니다. 다음 재시도는 빈 히스토리를 봐야 할까요, 이미 기록된 호출과 결과를 봐야 할까요?
Completed를 기다렸다가 쓰면, 일찍 닫힌 스트림이 이미 완성된 호출까지 버립니다. 재시도는 모델에게 같은 호출을 다시 보내게 해요. 취소 때 쓰기를 건너뛰면 히스토리에 요청 반쪽만 남고, 모델과 화면 모두 닫히지 않은 호출을 봅니다.
OutputItemDone은 파서 층이 response.output_item.done 한 프레임을 비즈니스 이벤트로 접은 것이에요. 오는 순간 샘플링 루프는 세션 히스토리와 rollout에 먼저 쓰고, 도구 실행을 future로 싸 순서 큐에 겁니다. 취소 토큰은 자식 토큰이라 부모가 켜지면 이 도구도 멈춥니다. 취소가 아무리 빨라도 이 function_call은 이미 히스토리에 있어요. 많아 봐야 aborted by user를 한 줄 더 씁니다.
출처: codex-rs/core/src/stream_events_utils.rs 190–192행; 316–327행. 타입 별칭 위 주석이 계약을 못 박아요. 끝난 모델 출력은 즉시 기록하고, 나중에 turn이 취소돼도 히스토리와 rollout은 동기 상태를 지킵니다.
히스토리는 위로만 더하고 고쳐 쓰지 않아요. 도구가 이미 디스크를 읽었다면 그 사실은 일어난 겁니다. 취소 트리는 실행을 끊을 수 있어도, 이미 쓴 요청은 못 지워요. 요청을 먼저 쓰고 결과를 나중에 써야 transcript가 항상 닫힙니다. 이 계약은 Rust에 묶이지 않아요. TypeScript로 바꿔도 먼저 append하고 그다음 promise를 push합니다.
출처: AGENTS.md 91–100행, Model visible context 첫 조항: No history rewrite.
모델은 종종 파일 읽기를 먼저 보낸 뒤 설명을 이어서 써요. 퇴근 호루라기를 기다렸다가 일 시작하면, 파일 읽기 지연과 타이핑 지연이 직렬이 됩니다. 스트림 안 출발은 그 두 시간을 겹쳐요. 대가는 취소와 끊긴 스트림이 이미 일한 future를 인수해야 한다는 것. 인수자가 없으면 고아 태스크가 생깁니다. 도구는 달리는데 히스토리가 안 맞아요.
수신 루프는 정상 Completed·일찍 닫기·or_cancel 어느 쪽이든 loop를 떠날 뿐이에요. 함수는 아직 반환하지 않았습니다. 이어서 항상 drain_in_flight를 호출해 삽입 순서로 future마다 결과를 기다린 뒤 히스토리에 쓰고, 그제야 취소 토큰을 봅니다.
끊긴 스트림은 Stream으로 가고 재시도할 수 있어요. 재시도는 clone_history로 prompt를 다시 만들며, 이미 쓴 호출과 결과는 남습니다. Esc는 TurnAborted로 가고 재시도하지 않아요. 달리던 도구는 중단 문구를 쓰고, drain이 그걸 평범한 출력으로 기록합니다.
출처: codex-rs/core/src/session/turn.rs 2282–2284행; 2744–2762행. codex-rs/protocol/src/error.rs 88–93행, 364–390행.
중간에 일 시작했으면 모든 출구에서 맞춰 기다려야 해요. 소유권은 샘플링 함수의 지역 변수에 있고, 따로 백그라운드 회수 큐는 없습니다. 성공·오류·취소가 이 마무리를 공유해요. 언어를 바꿔도 같아요. 비동기 루프를 떠난 뒤 먼저 allSettled하고, 그다음 재시도와 중지를 정합니다.
도구 셋은 동시에 달려도 돼요. 누가 먼저 끝나든 히스토리는 모델이 보낸 순서로 결과를 씁니다. 끝난 순서로 쓰면 같은 세션을 두 번 재생해도 안 맞을 수 있고, prompt cache도 더 약해져요. 순서 큐는 관측 순서와 실행 순서를 갈라 둡니다. 동시성 게이트는 다른 층. 여기선 이것만 기억하세요. 건 순서가 나중에 drain할 순서입니다.
출처: codex-rs/core/src/session/turn.rs 2130–2154행; 2391–2397행.
Claude Code: 기본은 스트림이 끝나길 기다리고, 스트림 안 게이트가 하나 더 있어요
기본 경로에서 스트림 루프는 tool_use만 모으고, for await가 끝난 뒤에야 runTools에 들어가요. 스트림이 끊기면 모은 block만 버리면 되고, 모든 출구에서 drain하는 지역 소유권은 아끼죠. 대가는 도구 지연과 타이핑 지연이 직렬이라는 것.
streamingToolExecution이 켜지면 행동이 Codex에 가까워져요. 스트림 안에서 addTool하고 즉시 일 시작. 실패 롤백은 이미 일한 도구를 discard해 옛 id가 재시도에 새지 않게 합니다. Codex에는 맞먹는 discard가 없어요. 먼저 persist하고 재시도가 히스토리를 읽도록 골랐거든요.
DSH: 3단 폭포와 단조 Guard, 누가 거부할 수 있는지를 봐요
DSH의 입구는 이미 만들어진 도구 호출이에요. pre / guard / around / post는 누가 거부할 수 있는지, 거부 뒤 결과가 남는지 답합니다. Guard는 거부 이유 또는 기권만 있고, 통과라는 선택지는 없어요. 그것의 drained는 한 번 execute 안의 마무리. SSE가 아직 날 때 이 폭포는 시작도 안 했어요.
양쪽 말은 비슷해 보여도 출구가 달라요. 한쪽은 권한 단조를 지키고, 한쪽은 스트리밍 transcript가 닫혀 있게 지킵니다. Guard를 Codex에 옮겨도 끊긴 스트림이 transcript를 잃는 것을 못 막고, persist-then-drain을 DSH에 옮겨도 플러그인이 거부를 통과로 바꿀 수 있는지는 답하지 못해요.
양쪽 모두 소스 대조 완료 · tools/src/index.ts 1–4행, 703–711행, 1328–1337행 · DSH · 3단 폭포와 단조 Guard쓰기와 걸기를 맞바꾸기
handle_output_item_done의 도구 분기에서 record_completed_response_item과 Box::pin(handle_tool_call)을 맞바꾸세요. 취소가 pin 전, persist 전에 일어납니다. 다음 샘플링과 세션 복구는 무엇을 볼까요?
답은 히스토리가 증분으로만 더해진다는 것과, drain_in_flight가 쓰는 타이밍에 떨어뜨리세요.
OutputItemDone이 오면 먼저 쓰고 일 시작해요. 스트림의 모든 출구는 먼저 drain하고, 결과는 보낸 순서로 씁니다. 취소는 실행을 끊지만, 이미 쓴 transcript는 그 자리에 남아요.