취소를 누른 뒤, 각 층은 어떻게 수습하는가
도구 실패는 모델로 돌려보내고, 사용자가 Esc를 눌러야 turn이 멈춥니다. 취소 토큰은 태스크에서 샘플링을 거쳐 도구로 내려갑니다. 끝난 결과는 히스토리에 남고, 100밀리초 뒤의 강제 중단은 되돌릴 수 없어요.
- 프로토콜 입구는 Interrupt이고, 백그라운드 terminal은 죽이지 않습니다protocol.rs L546
- 태스크 토큰을 먼저 cancel합니다. 신호이지 강제 중단이 아니에요tasks/mod.rs L887
- 샘플링은 자식 토큰으로 스트림을 보고, or_cancel이 TurnAborted가 됩니다turn.rs L2273
- 도구 디스패치가 한 번 더 child 하고, 부모 토큰이 취소되면 같이 취소됩니다stream_events_utils.rs L319
- handler가 이미 끝났으면 실제 결과를 남기고, 안 끝났을 때만 abort합니다parallel.rs L182
- 100밀리초를 기다리고, 아직 안 끝났으면 task.handle.aborttasks/mod.rs L913
- turn_aborted 조각을 추가하고 바로 flush_rollouttasks/mod.rs L927
- 맨 마지막에 EventMsg::TurnAborted를 보냅니다tasks/mod.rs L955
task.handle.abort에는 되돌림이 없어요. 이미 끝난 도구 회신도 aborted로 고쳐 쓰지 않습니다. 백그라운드 terminal은 계속 돌고, 죽이려면 다른 연산을 타야 해요.Codex에게 테스트 파일을 고치라고 해요. 모델이 먼저 cargo test를 돌리고, 컴파일러가 rustc 오류를 두 화면 쏟아내며 종료 코드는 1입니다. 다음 초에 대화가 멈추고 화면에 turn aborted가 떠요. 에이전트를 처음 만드는 사람이 자주 이렇게 합니다. 도구가 Err를 반환하면 라운드 전체가 같이 죽어요. 모델은 stderr를 보기도 전에 세션이 끝납니다.
분진은 도구가 대화로 돌아오는 그 문에서 일어나요. 문턱이 세 층입니다.
가장 얕은 층: 프로세스는 이미 돌았어요. 종료 코드가 0이 아니거나, 명령이 시간 초과되거나, 샌드박스가 거절해도 모두 도구 회신이 됩니다. success는 true예요. handler가 끝났고, 회신을 모델에 먹여도 된다는 뜻입니다. 명령이 성공했는지는 본문에 적혀 있어요.
출처: codex-rs/core/src/tools/context.rs 344–353행
중간 층: 호출 자체가 안 됐어요. 인자가 깨졌거나, 프로세스가 안 뜨거나, apply_patch 컨텍스트가 안 맞으면 RespondToModel로 갑니다. 이때만 회신 success가 false예요. 모델이 문장을 읽고 고쳐 다시 시도합니다.
가장 깊은 층: payload가 안 맞거나 태스크 join이 실패해야 Fatal을 쓰고 CodexErr로 올려 turn을 멈춥니다.
도구 층 자신의 열거형은 두 칸뿐입니다. RespondToModel은 문자열을 모델에 돌려줍니다. Fatal이어야 엔진 오류로 올라가요. Fatal이 아니면 기본으로 Ok로 접습니다. 대화를 멈추려면 Fatal이나 CodexErr를 써야 해요.
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행
handler의 IO 오류가 turn 루프까지 바로 올라오면 대화 라운드 전체가 같이 죽어요. 기본은 되먹임이고, 멈추려면 명시적으로 써야 합니다. 다른 언어로 다시 써도 이 선은 그대로예요. 내부 에이전트를 만든다면 이 칸부터 베끼세요. 도구 실패는 되먹이고, 엔진 실패여야 루프를 멈춥니다.
사용자가 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행
사용자 의도 하나, 여러 층이 각자 마무리합니다. 협력 취소에 단단한 기한을 더하는 건 동시성 시스템의 흔한 모양이에요. Python에서는 asyncio.Event만으로 최소판을 만들 수 있습니다. CodexErr 변형 37개를 베낄 필요는 없어요.
도구가 이미 파일을 고친 뒤에 취소가 오면, 히스토리는 어떻게 적을까요. 이미 끝난 출력을 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는 타입으로 그 길을 좁혀요.
Grok: ToolError 전체를 모델로 돌려보내고, 엔진은 SamplingError에서 멈춥니다
Grok의 ToolError 모듈 머리는 detail을 모델에 돌려줘야 하는 설명으로 적어요. Cancelled, Timeout, Execution은 같은 되먹임입니다. 도구 층에 Fatal 칸은 없어요. 세션 층은 실행 실패를 tool_result로 거두고 turn은 이어집니다.
엔진은 다른 타입에서 멈춰요. SamplingError가 재시도 불가야 샘플링이 멈춥니다. Actor의 CancellationToken 발화는 종료이고, 한 번짜리 도구 취소는 CancelRegistry를 탑니다. 토큰이 Codex식 도구 분진을 맡지는 않아요.
Esc가 두 순간에 떨어질 때, 히스토리에는 무엇이 남나요
apply_patch가 이미 파일을 썼고, 성공 회신은 아직 날아가는 중입니다. 그때 사용자가 Esc를 눌러요. 다음 라운드 모델이 히스토리에서 무엇을 볼까요. 성공 회신, aborted by user, <turn_aborted> 조각, 이 셋은 각각 나타날까요? 백그라운드 terminal은 아직 있나요?
순간을 handler가 아직 도는 중으로 바꿔 다시 답해 보세요. 어느 단계가 되돌릴 수 없는지, 이미 디스크에 떨어진 파일이 왜 중지 이벤트와 함께 사라지지 않는지요.