Esc 之後發生了什麼:取消、崩潰恢復與重入
每輪一個取消訊號,中斷也要把帳記平,kill -9 之後重啟還能接著跑,已落盤的勞動成果一條不丟。
turn/end 回日誌;以及行程被 kill -9 之後,重啟的 DSH 靠什麼把半截會話救回來、又保住了哪些東西。
下面是一條正在幹活的輪次:模型流式輸出中,還叫了一個長工具。左邊是會話日誌,右邊是執行狀態。三個情景按鈕對應三種事故:按 Esc、kill -9 後重啟、以及一個假想的截斷式恢復做對照。點播放,字幕會告訴你每一步日誌裡多了什麼、少了什麼。
packages/core/agent-loop/src/agent.ts(取消與 turn/end 落日誌)與 packages/core/session/src/repair.ts(崩潰恢復合成 closer)。情景 C 的截斷式恢復是教學假設,DSH 未實現該行為,用來對照資料損失。它解決什麼問題
想像取消做成一個全域開關:agent 身上掛一個布林標誌位,誰都能設,各處程式碼自己抽空看一眼。翻車遲早發生:上一輪註冊的某個超時回撥半夜甦醒,順手把正在跑的新一輪取消了;或者取消用 Promise.race 實現,race 輸掉的那個工具呼叫沒人善後,還在後台偷偷改檔案、寫狀態,成了殭屍工作。
取消這件事,難點是停得乾淨、只停該停的。這兩樣,全域開關都給不了。
思路是什麼
DSH 的取消是一根顯式傳遞的線,一頭拴在輪次上,另一頭拴在每個正在幹活的邊界上。驅動器每次醒來幹活,新建一個 AbortController(取消控制器);一輪跑完、佇列裡還有活,就再換一個新的。任何時刻最多只有一個控制器有效,取消權的生命週期和輪次一樣長。
Esc 只是拉了一下這根線。介面把按鍵翻譯成 agent.cancel({ kind: 'user' }),子 agent 被父級打斷則是 { kind: 'parent' },取消自帶身份。cancel 的入口小到可以背下來,就兩個動作:預設先把 inbox 清空,排隊沒跑的訊息全部作廢,想保住排隊工作就傳 keepInbox,只中斷當前活動;然後對當前控制器 abort(cause)。空閒時調 cancel 是空操作,不會給未來的工作預埋取消狀態。
同一個 signal 顯式地發給 pre-step、提示片語裝、模型請求、流式讀取、工具執行、審批這些環節,連 bash 工具都能順著它殺掉整個行程組。傳遞是協作式的:迴圈在每個 await 邊界前後檢查中斷,不用 Promise.race 半路丟棄一個還在跑的 Promise,所以不會有殭屍工作偷偷改狀態。
最後是權力交接。迴圈在發出 turn/end 之前就清掉本輪的取消持有者,之後哪怕持久化重新整理還沒結算完,誰也取消不了已經完成的輪次工作;下一輪拿到的是全新的 signal,舊回撥想越權,連把手都摸不到。
出處:每輪新建 AbortController 在 packages/core/agent-loop/src/agent.ts 第 187 行,跑完換新在第 325 行,cancel 入口(可選清 inbox,再 abort 帶型別化 cause)在第 134 至 140 行;取消權不跨輪洩漏的設計記錄見專案 Agent Note 2026-07-16。
為什麼長期成立
顯式令牌加作用域綁定,是結構化併發的通則:Go 的 context、.NET 的 CancellationToken 走的都是這條路。令牌由建立者負責收回,活不過自己的作用域,越權自然無從談起。只要系統裡同時有流式 IO 和外部行程要停,換個語言重寫,這根顯式的線還是得有。
它解決什麼問題
假設被取消的輪次不寫終態:日誌停在半截,回放的人不知道這輪怎麼結束的;UI 沒法如實告訴使用者哪些排隊的活被扔了;下游拿到日誌,分不清這輪是被人有序停掉的,還是意外死掉的。中斷是正常業務,不記帳的中斷才是事故。
思路是什麼
輪次的主體邏輯包在 try 裡。catch 分支發現 signal.aborted,就把結局定為 aborted;finally 裡無論如何寫一條 turn/end 回日誌。已經落盤的流式 chunk、工具輸出一個都不刪。
於是日誌裡的死法只有兩種,各有專屬簽名。aborted 由迴圈親手寫下:有人調了 cancel,輪次有序收尾。interrupted 迴圈從來不發,它只在崩潰恢復時由持久化後端合成,是唯一一個非迴圈出品的結局。看一眼 reason,就知道這輪的結束方式。
兩個配套細節。日誌只存粗粒度的 aborted,不存是誰按的,user 還是 parent 屬於執行時資訊,回放不需要也不該知道。被清掉的排隊訊息沒有任何 turn/end 描述它,foldConsumedWork 單遍掃日誌,靠 inbox 記錄裡的 outcome: 'canceled' 算出 droppedUnrun,UI 才能如實說有活被扔了、沒跑。
出處:catch 定結局在 packages/core/agent-loop/src/agent.ts 第 302 至 305 行,finally 寫 turn/end 在第 316 至 323 行;droppedUnrun 的摺疊在 consumed-work.ts 第 87 行。
為什麼長期成立
所有退出路徑都寫終態,是一切拿日誌當權威狀態的系統的底線,資料庫事務日誌的 commit 和 abort 記錄同理。異常路徑和正常路徑出同樣的帳,重建現場的人才不用猜。模型換代、語言重寫,這條紀律都不變。
它解決什麼問題
取消好歹有 finally 善後,kill -9 連善後的機會都沒有。行程死掉的瞬間,日誌停在半截:turn/start 開著,某個工具呼叫記了 tool/call,卻永遠等不到 tool/result。重啟後冷載入這份日誌,擺在面前的是一道選擇題:把沒寫完的輪次刪掉,還是補齊?
刪掉看似乾淨,代價大得多。長週期任務的單個輪次可能非常龐大,幾十個步驟、大量工具輸出,這些在崩潰前都已經持久追加。截斷等於把使用者的勞動成果陪葬,演示的情景 C 算的就是這筆帳。
思路是什麼
DSH 選補齊。恢復邏輯生成幾條確定性的合成事件把尾巴關上:先給每個懸空的工具呼叫補一條錯誤佔位的 tool/result,再關掉開著的 step,最後合成 turn/end,結局標 interrupted。順序有講究:step 還開著就寫 turn/end 違反日誌不變式,所以先補 step 的邊界,再補 turn 的。
給懸空工具呼叫補的那條佔位結果,措辭分兩種情況。工具記錄了啟動但結果沒落盤的,佔位文字告訴模型結果未知,只有只讀或冪等操作才可以重試,有副作用的要先核實外部狀態或問使用者,原文強調 「Do not retry blindly」。工具壓根沒啟動的,直接說需要就重試。
「它不會截斷日誌:在長週期任務中,單個輪次可能非常龐大(許多步驟、大量工具輸出),而這些事件在崩潰前已被持久追加。後端改為用一個合成的 turn/end { reason: { kind: 'interrupted' } } 關閉這個遺留輪次,在不改變其前後任何獨立事件的情況下配平被中斷的執行。」
還有一條容易忽略的邊界:這套修復只對冷會話生效。會話還活著的時候,load 會等權威記憶體快照落盤、只在日誌配平時返回,活躍輪次沒閉合就直接拒絕,絕不給一個正在跑的輪次插入合成邊界。寫盤那頭也有講究:持久化外掛批次落盤,迴圈在領取下一輪之前用 session/flush 做檢查點,把順序和寫盤錯誤都看在眼裡。
出處:佔位結果的兩種措辭在 packages/core/session/src/repair.ts 第 91 至 124 行,closer 的合成順序(先 step 後 turn)在第 126 至 132 行;只修冷會話在 docs/subsystems/persistence.zh.md 第 17 行,flush 檢查點見同文件對應小節。
為什麼長期成立
追加式日誌加讀取時修復,就是資料庫 WAL(預寫日誌)恢復的思路:崩潰後不改寫歷史,只補足讓狀態機能繼續走的最小事件。只要權威狀態放在事件日誌裡,恢復邏輯重寫多少遍都長這樣。反過來說,敢截斷日誌的系統,等於預設單輪工作便宜到可以隨便扔,這個假設在長任務時代不成立。
Grok Build
行程級 · 專門的崩潰 crateGrok 有一個專職 crate xai-crash-handler:用 sigaction 接住 SIGSEGV 和 SIGBUS,崩潰現場只用訊號安全操作把二進位快照寫進 last-crash.bin,還順手用預計算的轉義序列把終端恢復原狀;下次啟動再解析符號、生成崩潰報告,保留最近 5 份(crate README 與 handler.rs)。
它回答的問題是行程怎麼死的、終端別留爛攤子。DSH 的 repair.ts 回答的是會話日誌怎麼活下來。兩家各佔一層,正面對比下 DSH 的獨特點在於把恢復語義做進了持久化契約:load 的介面註釋直接承諾補齊中斷尾部、不改寫已提交事件。在已核對的 Grok Build 材料中未見等價的會話日誌配平機制,這一條基於已公開原始碼。
Claude Code
任務級 · AbortController 掛在 task 上後台 agent 的取消走 killAsyncAgent:從任務狀態裡取出 abortController 調 abort,把任務標成 killed(restored-src 的 LocalAgentTask.tsx 第 283 至 298 行,書稿第 6 章引用)。控制器的生命週期跟著任務走,一個任務一個。
DSH 把粒度再切細一檔:控制器跟著輪次走,同一個 agent 的下一輪自動拿新訊號,舊回撥想越權都拿不到把手。另外 DSH 明確不持久化取消原因,durable 日誌只留 aborted;Claude Code 的會話恢復與中斷記錄細節未在已核對的書稿章節展開,這裡保留未知項。
推演兩個時刻的日誌差異
同一個輪次,取消發生在兩個不同時刻:a)模型流式輸出到一半;b)bash 工具正在執行。
問題一:分別寫出日誌從 step/start 到 turn/end 之間會出現哪些事件,turn/end 的 reason 是什麼。
問題二:把這兩種情況的事故換成 kill -9,重啟後 repair 分別要合成幾條 closer?提示:流式中斷時沒有懸空的 tool/call;工具執行中斷時有一條記錄了啟動的 tool/call,對應結果未知的佔位文字。