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,不殺後臺 terminalprotocol.rs L546
  2. 任務令牌先 cancel,這是訊號還不是硬拆tasks/mod.rs L887
  3. 取樣用子令牌盯著流,or_cancel 變成 TurnAbortedturn.rs L2273
  4. 工具派發再 child 一次,父令牌取消則一起取消stream_events_utils.rs L319
  5. handler 已走完就保留真實結果,沒走完才 abortparallel.rs L182
  6. 等 100 毫秒,沒收完就 task.handle.aborttasks/mod.rs L913
  7. 追加 turn_aborted 片段,立刻 flush_rollouttasks/mod.rs L927
  8. 最後才發 EventMsg::TurnAbortedtasks/mod.rs L955
點播放,看 Esc 之後各層按什麼順序收手,哪一層不可逆。
收手順序令牌先發訊號,取樣和工具各自收尾,最後才硬拆、寫片段、發事件。介面上的中止,是這條鏈走完之後才出現的。
不可逆的那一層100 毫秒之後的 task.handle.abort 沒有回切。已經完成的工具回執也不會被改寫成 aborted。後臺 terminal 繼續跑,要殺它走另一條操作。
撥到另一檔工具已經跑完時,歷史裡留下成功回執加中止標記。工具還在跑時,留下 aborted by user 加中止標記。兩種都是追加,都不是回滾。
教學示意:步驟時序按原始碼呼叫鏈編排,耗時被拉成可點的單步。邏輯軌跡右側行號對應 openai/codex 倉庫 commit 4f39251a01。
思路一 · 工具失敗回給模型,引擎失敗才停 turn
它解決什麼問題

你讓 Codex 改一個測試檔案。模型先跑 cargo test,編譯器吐了兩屏 rustc 報錯,退出碼是 1。下一秒對話停了,介面彈出 turn aborted。很多人第一次寫 agent 會這麼幹:工具返回 Err,整輪跟著死。模型還沒看見 stderr,會話已經結束。

思路是什麼

分診發生在工具回到對話的那道門上,一共三層門檻。

最淺一層:行程已經跑過。退出碼非零、命令逾時、沙箱拒絕,都收成工具回執。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。想停對話,得寫出 FatalCodexErr

codex-rs/tools/src/function_call_error.rs第 1 至 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 迴圈,整輪對話跟著死。預設回灌,想停必須顯式寫出。這條線換語言重寫也用得上。自己做內部 agent,先抄這一檔:工具失敗回灌,引擎失敗停迴圈。

思路二 · 取消做成一棵令牌樹
它解決什麼問題

使用者按 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_abortcancel(),再等 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 就能做最小版。不必抄 37 個變體的 CodexErr

思路三 · 中止只追加,不回滾
它解決什麼問題

取消發生在工具已經改了檔案之後,歷史裡怎麼記。如果把已經完成的輸出改寫成 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 寫成必須回給模型的說明。CancelledTimeoutExecution 都是同一種回灌。工具層沒有 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 毫秒之後硬拆不可逆。中止只追加片段,不回滾歷史。