OpenAI Codex · 취소와 오류

취소를 누른 뒤, 각 층은 어떻게 수습하는가

도구 실패는 모델로 돌려보내고, 사용자가 Esc를 눌러야 turn이 멈춥니다. 취소 토큰은 태스크에서 샘플링을 거쳐 도구로 내려갑니다. 끝난 결과는 히스토리에 남고, 100밀리초 뒤의 강제 중단은 되돌릴 수 없어요.

강의 목표읽고 나면 세 가지를 말할 수 있어요. 도구가 실패해도 대화가 왜 이어지는지, Esc 뒤에 태스크·샘플링·도구가 어떤 순서로 수습하는지, 그리고 어느 층의 수습은 한 번 떨어지면 되돌릴 수 없는지요.
먼저 해보기 · Esc를 누르고, 누가 먼저 멈추는지 보세요
같은 대화 라운드, 사용자가 중간에 Esc
Esc를 누른 순간
다른 칸으로 바꿔 보세요. 히스토리의 성공 회신이 덮이는지, 어느 단계에 되돌릴 수 없음이 찍히는지요.
수습 순서시작 대기
1
프로토콜 입구Op::Interrupt 도착
2
태스크 토큰cancellation_token.cancel
3
샘플링 수습or_cancel이 TurnAborted가 됨
4
도구 수습자식 토큰이 취소되고, handler가 끝났는지 봅니다
5
강제 중단100밀리초 뒤 task.handle.abort되돌릴 수 없음
6
디스크와 이벤트조각을 먼저 쓰고, 그다음 TurnAborted를 보냅니다
히스토리와 부작용0개
논리 궤적 · 애니메이션 한 걸음이 소스의 어느 구간에 대응하는지
  1. 프로토콜 입구는 Interrupt이고, 백그라운드 terminal은 죽이지 않습니다protocol.rs L546
  2. 태스크 토큰을 먼저 cancel합니다. 신호이지 강제 중단이 아니에요tasks/mod.rs L887
  3. 샘플링은 자식 토큰으로 스트림을 보고, or_cancel이 TurnAborted가 됩니다turn.rs L2273
  4. 도구 디스패치가 한 번 더 child 하고, 부모 토큰이 취소되면 같이 취소됩니다stream_events_utils.rs L319
  5. handler가 이미 끝났으면 실제 결과를 남기고, 안 끝났을 때만 abort합니다parallel.rs L182
  6. 100밀리초를 기다리고, 아직 안 끝났으면 task.handle.aborttasks/mod.rs L913
  7. turn_aborted 조각을 추가하고 바로 flush_rollouttasks/mod.rs L927
  8. 맨 마지막에 EventMsg::TurnAborted를 보냅니다tasks/mod.rs L955
재생을 누르고, Esc 뒤에 각 층이 어떤 순서로 수습하는지, 어느 층이 되돌릴 수 없는지 보세요.
수습 순서토큰이 먼저 신호를 내고, 샘플링과 도구가 각자 마무리한 뒤에야 강제 중단·조각 기록·이벤트가 옵니다. 화면의 중지는 이 사슬이 끝난 뒤에야 나타나요.
되돌릴 수 없는 그 층100밀리초 뒤의 task.handle.abort에는 되돌림이 없어요. 이미 끝난 도구 회신도 aborted로 고쳐 쓰지 않습니다. 백그라운드 terminal은 계속 돌고, 죽이려면 다른 연산을 타야 해요.
다른 칸으로도구가 이미 끝났으면 히스토리에 성공 회신과 중지 표시가 남아요. 아직 도는 중이면 aborted by user와 중지 표시가 남습니다. 둘 다 추가이지, 롤백이 아니에요.
수업용 스케치:단계 순서는 소스 호출 사슬을 따르고, 소요 시간은 눌러 볼 수 있는 단스텝으로 늘려 두었어요. 논리 궤적 오른쪽 행 번호는 openai/codex 저장소 commit 4f39251a01에 대응합니다.
아이디어 1 · 도구 실패는 모델로 돌려보내고, 엔진 실패여야 turn이 멈춥니다
어떤 문제를 푸는가

Codex에게 테스트 파일을 고치라고 해요. 모델이 먼저 cargo test를 돌리고, 컴파일러가 rustc 오류를 두 화면 쏟아내며 종료 코드는 1입니다. 다음 초에 대화가 멈추고 화면에 turn aborted가 떠요. 에이전트를 처음 만드는 사람이 자주 이렇게 합니다. 도구가 Err를 반환하면 라운드 전체가 같이 죽어요. 모델은 stderr를 보기도 전에 세션이 끝납니다.

아이디어는 무엇인가

분진은 도구가 대화로 돌아오는 그 문에서 일어나요. 문턱이 세 층입니다.

가장 얕은 층: 프로세스는 이미 돌았어요. 종료 코드가 0이 아니거나, 명령이 시간 초과되거나, 샌드박스가 거절해도 모두 도구 회신이 됩니다. successtrue예요. handler가 끝났고, 회신을 모델에 먹여도 된다는 뜻입니다. 명령이 성공했는지는 본문에 적혀 있어요.

출처: codex-rs/core/src/tools/context.rs 344–353행

중간 층: 호출 자체가 안 됐어요. 인자가 깨졌거나, 프로세스가 안 뜨거나, apply_patch 컨텍스트가 안 맞으면 RespondToModel로 갑니다. 이때만 회신 successfalse예요. 모델이 문장을 읽고 고쳐 다시 시도합니다.

가장 깊은 층: payload가 안 맞거나 태스크 join이 실패해야 Fatal을 쓰고 CodexErr로 올려 turn을 멈춥니다.

도구 층 자신의 열거형은 두 칸뿐입니다. RespondToModel은 문자열을 모델에 돌려줍니다. Fatal이어야 엔진 오류로 올라가요. Fatal이 아니면 기본으로 Ok로 접습니다. 대화를 멈추려면 Fatal이나 CodexErr를 써야 해요.

codex-rs/tools/src/function_call_error.rs1–10행
use thiserror::Error;

/// Error returned while executing a model-visible tool invocation.
#[derive(Debug, Error, PartialEq)]
pub enum FunctionCallError {
    #[error("{0}")]
    RespondToModel(String),
    #[error("Fatal error: {0}")]
    Fatal(String),
}
소스 스냅샷 안내:로컬 저장소 openai/codex를 기준으로 codex-rs/tools/src/function_call_error.rs를 대조했어요. commit 4f39251a01, 대조일 2026-08-22. 이 열거형 자체가 분진 계약입니다. 두 칸이고, 경고나 재시도라는 세 번째 칸은 없어요.

cargo test 종료 코드 1은 가장 얕은 층에서 멈추고, 오류 열거형의 문턱도 넘지 못해요. 샌드박스 거절도 Ok로 갑니다. 프록시가 요청을 막고 명령 프로세스에 HTTP 403을 돌려줘도 얕은 층에 머뭅니다. 모델 API의 403이어야 엔진 오류예요. 두 403을 한 칸으로 합치지 마세요.

출처: codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs 384–411행; codex-rs/network-proxy/src/responses.rs 76–83행

가장 얕음 · 프로세스는 이미 돌았음 cargo test 종료 코드 1 도구 회신 success true 성패는 본문에 적힘 대화 계속 중간 · 호출이 안 됨 patch 컨텍스트 불일치 RespondToModel 회신 success false 대화 계속 가장 깊음 · 엔진 사고 payload 불일치 / Esc Fatal 또는 TurnAborted CodexErr로 승격 turn 정지
수업용 구조도:실패는 도구에서 대화로 돌아오며 문 세 개를 먼저 지납니다. 기본 정지는 가장 얕은 층이에요.
왜 오래가는가

handler의 IO 오류가 turn 루프까지 바로 올라오면 대화 라운드 전체가 같이 죽어요. 기본은 되먹임이고, 멈추려면 명시적으로 써야 합니다. 다른 언어로 다시 써도 이 선은 그대로예요. 내부 에이전트를 만든다면 이 칸부터 베끼세요. 도구 실패는 되먹이고, 엔진 실패여야 루프를 멈춥니다.

아이디어 2 · 취소를 토큰 나무로 만듭니다
어떤 문제를 푸는가

사용자가 Esc를 누르면 멈춰야 하는 건 지금 그 샘플링만이 아니에요. 샘플링은 아직 스트림을 읽고, 도구는 파일을 쓰는 중일 수 있고, 백그라운드 terminal은 이미 떠 있을 수 있습니다. 샘플링만 죽이면 도구는 디스크를 계속 고칩니다. 프로세스를 한칼에 전부 죽이면 unified exec의 백그라운드 작업도 잘못 죽어요.

아이디어는 무엇인가

태스크가 시작할 때 취소 토큰을 바로 만듭니다. 샘플 요청은 child_token()을 쓰고, 도구 디스패치가 한 번 더 child 해요. 부모 토큰이 취소되면 자식도 같이 취소됩니다. 자식이 스스로 취소해도 부모는 건드리지 않아요. 그게 취소 나무입니다.

출처: codex-rs/core/src/session/turn.rs 1399행; codex-rs/core/src/stream_events_utils.rs 319–324행

or_cancel은 누가 먼저 왔는지를 고정된 모양으로 씁니다. 어떤 future든 토큰 한 장과 짝을 이뤄요. 취소가 먼저 오면 CancelErr를 반환하고, 언제나 TurnAborted가 됩니다.

출처: codex-rs/async-utils/src/lib.rs 4–31행; codex-rs/protocol/src/error.rs 269–273행

사용자가 Esc를 누르면 프로토콜 입구는 Op::Interrupt예요. 계약은 고정입니다. 현재 태스크를 중지하고, 백그라운드 terminal은 죽이지 않아요. handle_task_abort는 먼저 cancel()한 뒤 100밀리초를 기다립니다. 시간 초과면 task.handle.abort()예요. 토큰이 먼저 신호를 내서 태스크가 스스로 마무리할 틈을 줍니다. 못 끝내면 그때 강제 중단하고, 그 한 칼은 되돌릴 수 없어요.

출처: codex-rs/protocol/src/protocol.rs 546–548행; codex-rs/core/src/tasks/mod.rs 66행, 880–913행

사용자 Esc Op::Interrupt 태스크 토큰 cancel 신호, 아직 마무리할 수 있음 샘플링 or_cancel child token 도구 자식 토큰 한 번 더 child handle.abort 100ms 뒤, 되돌릴 수 없음 turn_aborted 먼저 디스크에 쓰고 이벤트를 보냄 이벤트 백그라운드 terminal은 이 나무에 없어요. Interrupt의 계약이 바로 그걸 죽이지 않는 것입니다.
수업용 구조도:사용자 의도 하나가 아래로 내려갑니다. 강제 중단이 마지막 칼이고, 되돌릴 수 없는 그 칼이에요.
토큰이 먼저 신호를 내고, 강제 중단이어야 되돌릴 수 없습니다.
왜 오래가는가

사용자 의도 하나, 여러 층이 각자 마무리합니다. 협력 취소에 단단한 기한을 더하는 건 동시성 시스템의 흔한 모양이에요. Python에서는 asyncio.Event만으로 최소판을 만들 수 있습니다. CodexErr 변형 37개를 베낄 필요는 없어요.

아이디어 3 · 중지는 추가만 하고, 롤백하지 않습니다
어떤 문제를 푸는가

도구가 이미 파일을 고친 뒤에 취소가 오면, 히스토리는 어떻게 적을까요. 이미 끝난 출력을 aborted by user로 고쳐 쓰면, 다음 라운드 모델은 쓰기가 안 된 줄 알고 패치를 한 번 더 칠 수 있어요.

아이디어는 무엇인가

도구 future는 디스패치 결과와 토큰을 동시에 기다려요. 토큰이 먼저 와도 handler가 종말에 닿았는지를 봅니다. 이미 끝났으면 실제 결과를 남기고, 안 끝났을 때만 abort한 뒤 AbortedToolOutput을 만들며 본문은 aborted by user after Xs예요.

출처: codex-rs/core/src/tools/parallel.rs 177–206행, 243–260행

그다음 모델이 볼 수 있는 표시를 <turn_aborted> 안에 넣어 추가합니다. 문장은 두 가지를 인정해요. unified exec가 백그라운드에서 아직 돌 수 있고, 중지된 도구가 이미 일부를 실행했을 수 있다는 점입니다. 표시를 히스토리에 쓴 뒤 바로 flush_rollout()합니다. 어떤 클라이언트는 TurnAborted를 받으면 rollout을 다시 읽으므로, 표시는 먼저 디스크에 떨어져야 해요.

출처: codex-rs/core/src/context/turn_aborted.rs 1–35행; codex-rs/core/src/tasks/mod.rs 920–962행

왜 오래가는가

히스토리는 추가만 하고 고쳐 쓰지 않아요. 중지는 뒤에 조각만 붙이고, 이미 디스크에 떨어진 도구 출력은 그대로입니다. 다음 라운드 모델은 끝난 출력, 중지된 도구의 회신, 중지 표시를 동시에 볼 수 있어요. 컨텍스트 규율의 첫 조항이 바로 이 모양입니다.

출처: AGENTS.md 91–100행

가로 비교 · 같은 문제의 다른 답

DSH: throw는 isError로 접히고, 루프의 throw여야 turn이 멈춥니다

DSH는 라운드마다 AbortController를 새로 만듭니다. 도구 body의 throw는 루프를 뚫지 못해요. 실행기가 그걸 isError: true 회신으로 거두고 라운드는 이어집니다. bash 주석은 제품 계약으로 적혀 있어요. Non-zero exits are reported, not errored. 루프 자신의 실패여야 turn이 멈춥니다. 사용자 취소는 turn/end aborted로 씁니다.

DSH는 두 칸 열거형을 생략합니다. 실행기의 catch가 이미 “모델이 볼 수 있음”과 “엔진이 멈춰야 함”을 갈라 놓았기 때문이에요. 대가는 약속입니다. dispatchToolBody를 빠져나간 throw는 여전히 turn/end error가 됩니다. Codex는 타입으로 그 길을 좁혀요.

양쪽 소스 모두 확인 · 2026-08-22 · DSH agent.ts / tools/index.ts / tool-bash/render.ts

Grok: ToolError 전체를 모델로 돌려보내고, 엔진은 SamplingError에서 멈춥니다

Grok의 ToolError 모듈 머리는 detail을 모델에 돌려줘야 하는 설명으로 적어요. Cancelled, Timeout, Execution은 같은 되먹임입니다. 도구 층에 Fatal 칸은 없어요. 세션 층은 실행 실패를 tool_result로 거두고 turn은 이어집니다.

엔진은 다른 타입에서 멈춰요. SamplingError가 재시도 불가야 샘플링이 멈춥니다. Actor의 CancellationToken 발화는 종료이고, 한 번짜리 도구 취소는 CancelRegistry를 탑니다. 토큰이 Codex식 도구 분진을 맡지는 않아요.

양쪽 소스 모두 확인 · 2026-08-22 · xai-tool-runtime / xai-grok-sampling-types / xai-grok-shell
수업 실습
01

Esc가 두 순간에 떨어질 때, 히스토리에는 무엇이 남나요

apply_patch가 이미 파일을 썼고, 성공 회신은 아직 날아가는 중입니다. 그때 사용자가 Esc를 눌러요. 다음 라운드 모델이 히스토리에서 무엇을 볼까요. 성공 회신, aborted by user, <turn_aborted> 조각, 이 셋은 각각 나타날까요? 백그라운드 terminal은 아직 있나요?

순간을 handler가 아직 도는 중으로 바꿔 다시 답해 보세요. 어느 단계가 되돌릴 수 없는지, 이미 디스크에 떨어진 파일이 왜 중지 이벤트와 함께 사라지지 않는지요.

Takeaway:도구 실패는 모델로 돌려보내고, 기본 정지는 가장 얕은 층입니다. 사용자가 Esc를 누르면 토큰이 위에서 아래로 내려가고, 끝난 결과는 남으며, 100밀리초 뒤의 강제 중단은 되돌릴 수 없어요. 중지는 조각만 추가하고 히스토리를 롤백하지 않습니다.