第三部|可靠執行 · 第 7 章
Cancellation 與 Durable Compaction
處理 abort race、合法 transcript,以及 active-context cut 與 durable source partition 的差異。
換個情境:這次不是 process 被強制關掉,是你自己在 bash deploy.sh 跑到一半按下 Esc。跟上一章的
crash 不一樣,這是「使用者主動要求停」,系統理論上有機會好好收尾——但如果收尾的順序錯了,結果一樣糟:如果先取消掉正在跑的 Promise,卻還沒把「使用者要求取消」這件事寫進硬碟,中途再 crash 一次,重開後系統完全不知道使用者曾經要求停止,可能會誤判成「還在跑」而去 retry 或 replay。
所以可靠的 Agent 不能只是「把 Promise 取消掉」。它必須先留下 durable 的 abort intent,再通知目前 phase 停下來,並在退出前補齊合法 transcript 與 operation outcome。
Abort 的正確順序
persist abortRequested
→ signal active model / tool / compaction
→ reconcile open attempt 與 tool calls
→ persist operationFinished(aborted)
→ release resources
順序不能反過來。若先 signal 後 crash,Session 可能完全不知道使用者曾要求 abort;恢復程式可能錯誤地 retry model 或 replay safe tool。
Cancellation 依 Phase 分流
| Phase | 動作 | Durable reconciliation |
|---|---|---|
| model | 取消 HTTP request 或停止前景等待 | open attempt 寫 stepFailed(aborted) |
| tool | signal tool;bash 盡力終止 process tree | started call 寫 interrupted;未 started 寫 aborted |
| compaction | 取消 summary request | 關閉 attempt,再結束 compaction operation |
三條路徑互斥,不是依序執行。Esc 只 abort 目前 operation;Ctrl+C 在必要時 abort
後,還會關閉 MCP clients、Session writer 並恢復 terminal。
Abort Race 的核心規則
Abort 可能與 model response 或 tool completion 同一時間發生。系統必須把競爭序列化成兩種合法歷史:
- 成功 settlement 先 durable:abort 看到 operation 已結束,不改寫結果。
abortRequested先 durable:晚到的成功 response 不能再把 operation 寫成 completed。
這也是為什麼 durable Session 不只靠 AbortController.signal.aborted:memory flag 無法解釋 crash
後的先後順序。
你剛才看到的 abort race,其實是「系統要主動改變狀態,且必須先 durable 才能改」這類問題的一種——這裡改的是 operation
本身進行到哪一步。compaction 是同一類問題的另一種:它自己也是一個獨立的 durable operation(跟 run 一樣有
compactionStarted/operationFinished),但它要多處理一層——active context 該怎麼跟著變。
Compaction 不是刪除歷史
/compact 呼叫目前 model 產生 summary,然後重建 active context。原始 facts 仍留在 append-only Session
中,因此可以 audit 與 recovery。
完整 durable facts
→ reducer materialized active context
→ summary + retained message tail
→ 後續 model requests
兩個容易混淆的邊界
| Track | entry 1 | entry 2 | ... | entry N-6 | ... | entry N |
|---|---|---|---|---|---|---|
| Track1:Active-context cut(給模型看) | entry 1 → 送摘要 | entry 2 → 送摘要 | ... → 送摘要 | entry N-6 → 送摘要 | ... → 原樣保留 | entry N → 原樣保留 |
| Track2:Durable source partition(給稽核/recovery 看) | entry 1 → compactedEntryIds | entry 2 → compactedEntryIds | ... → compactedEntryIds | entry N-6 → compactedEntryIds | ... → retainedEntryIds | entry N → retainedEntryIds |
1. Active-context cut
用於決定本次摘要輸入:保留至少最近 6 則 message,再把 cut 向前移動到 user boundary,避免拆散完整 turn。Repeated compaction 時,prior summary 會參與新的摘要輸入。
2. Durable source partition
用於稽核與 recovery:保存截至 inputThroughEntryId 的所有 durable source message-entry IDs,並分成
compactedEntryIds 與 retainedEntryIds,再計算 sourceDigest。
Prior summary 不是 source message entry,所以不會放進 source partition;但它仍會參與 active-context summarization。兩者解決不同問題。
Compaction 也是正式 Operation
compactionStarted
→ stepAttempt(stepKind=compaction)
→ usage
→ compaction entry
→ operationFinished(completed)
Compaction entry 保存 materialized retained tail 與 source IDs。若 crash 發生在 summary 已經落盤、finish
尚未落盤,recovery 只補 operationFinished,不會再次呼叫 model。若 open attempt 未知,則遵循相同 attempt
cap。
Tool Output 也是 Context 控制
TypeScript 與 Rust 會保留最後 2,000 行或 50 KB(先達到者);Go 與 Python 則以約 50 KB 的 byte-oriented tail
為主,不承諾相同的行數語意。四種實作都有約 10 MB 的 capture safety limit,但超限行為不同:有的回傳 capped-output
診斷,有的將它視為 tool error。只有在實作確實捕獲完整輸出時,才會另存
.tiny-agent/tool-output/ 並回傳路徑。
親手驗證
以下練習完全使用 repository 的 deterministic tests,不需要 OpenRouter key。先觀察 cancellation 與 compaction 的 production regression tests,再閱讀固定的 durable facts:
npm --prefix typescript test -- \
--test-name-pattern="abort|compaction"
grep -R 'abortRequested\|compactionStarted\|sourceDigest' \
schemas/session/fixtures schemas/session/planner-fixtures | head -40
檢查順序:先找到 abortRequested,再確認對應的 attempt/tool reconciliation 與
operationFinished;compaction 則比對 source partition、sourceDigest 與 materialized
retained tail。最後重跑同一個 recovery test,確認第二次 resume 不會再次呼叫模型或重複寫入 outcome。
到此為止,你已經有一個能正確處理失敗的迴圈——下一章要問的是,你怎麼知道它平常做得好不好、做得對不對。