第三部|可靠執行 · 第 6 章
Reducer、Planner 與 Recovery
以 pure reducer 重建狀態,再由 planner 區分 retry、replay、interrupted 與 blocked。
延續上一章的 deploy.sh:現在硬碟裡已經老實記下「我打算執行這個 tool」(intent)。重開程式時,系統看到這筆記錄,卻沒看到對應的結果——這代表 effect 可能發生了,也可能沒有,結果未知。
這時候最危險的做法,是重開之後就直接把這個 tool 再跑一次——如果 deploy.sh 其實已經跑完,你就重複部署了一次。這一章要建立的,就是「重開後先想清楚、再動手」的兩個步驟:第一步只看存檔,還原「現在是什麼狀態」;第二步才拿這個狀態去對照「現在的環境和設定」,決定下一步能不能做、要怎麼做。
兩個 Pure Functions
reduceSession(bytes) → SessionState
planRecovery(state, currentConfiguration) → RecoveryPlan
Reducer 不認識目前 tools、model 或 environment,也不執行 I/O。Planner 不寫 Session、不分配 ID、不呼叫 model/tool,只描述下一項 effect 或 blocked reason。這樣的分離讓每個 crash prefix 都能用 deterministic fixtures 驗證。
這是本書第三次看到同一招:把複雜度收進一個小而深的介面。Tool 的 execute、SessionStore 的 commit/load,都靠這招把 I/O 細節藏起來,呼叫方不需要知道內部怎麼做——但它們自己就會真的去讀檔、寫檔、呼叫 bash,是會碰 I/O 的殼。reducer/planner 把這招再往前推一步:不只是藏細節,還把「決定要做什麼」和「真的去做」拆成兩個函數——一個不碰 I/O 的純函數(reducer、planner 本身),配一個真的會碰 I/O 的殼(SessionStore、Agent loop)。
Reducer 重建什麼
type SessionState = {
transcript: Message[];
activeContext: Message[];
usage: Usage;
operation:
| { kind: "idle" }
| { kind: "run"; step?: StepState; toolCalls: ToolCallState[] }
| { kind: "compaction"; step?: StepState; resultEntryId: string };
};
transcript 保留完整 model-visible 歷史;activeContext 反映最新 compaction 投影;operation
說明目前是否有未完成 run/compaction。
Retry 與 Replay 完全不同
Retry Model Attempt
重送相同 context 給無外部副作用的 provider request。
- 只處理 crash-unknown attempt
- configuration digest 必須一致
- 最多 attempt 2
Replay Tool Effect
再次執行可能觸碰外部世界的 tool。
- 預設 never
- 只有 exact built-in read 是 safe
- replayKey 與 environment 必須一致
Live 429、5xx 或 timeout 會寫 stepFailed,不會自動 retry。這份設計只允許 process crash 造成的 unknown
attempt 多一次機會,避免無界重複計費。
其實有四種可能結局,不是兩種:retry(同一個 model attempt
再送一次)、replay(同一個 safe tool effect 再執行一次)、blocked(configuration
或 replay 宣告不符,需要人為介入,不寫終局)、failed(stepFailed 是 live request
已經記錄的失敗,runtime 選擇不自動 retry)。混淆 retry 與 replay 是最常見的誤解;混淆 blocked 與 failed
是第二常見——blocked 代表「recovery 在目前 configuration/environment 下無法安全自動繼續,必須停下來交給人」,failed
代表「這次 live request 已經寫入 stepFailed,runtime 選擇不自動 retry,避免無界重複計費」;timeout
之類的錯誤本來就無法證明 server 端到底有沒有處理完,failed 因此不等於「已經確定這次沒成功」。
核心 Recovery 決策
| Durable prefix | Planner 結果 |
|---|---|
| run 已接受,沒有 attempt | start assistant attempt 1 |
| attempt 1 open,沒有 settled response | config 相同則 attempt 2,否則 blocked |
| attempt 2 仍 open | attempts_exhausted |
| length response 含 tool calls | 補 truncated synthetic results,不執行 |
| safe read 已 started、無 result | identity 相同才 replay |
| never tool 已 started、無 result | 補 interrupted,不 replay |
| terminal assistant 已有、缺 finish | 只補 operationFinished |
Configuration Identity 防止錯誤恢復
Attempt 會保存 model、system prompt digest、ordered tool definitions、adapter identity、routing identity 與 output options digest。Planner 會比較目前 configuration。
Tool intent 另外綁定:
environmentIdentity:預設是 cwd canonical realpath,也可由 trusted host 覆寫。replayKey:識別 exact implementation 與 effect semantics。definitionDigest:識別 model-facing schema。
任何不一致都回傳 blocked,不執行 effect,也不寫 failed terminal。恢復原本 configuration 或明確 abort,才是合法下一步。
-
曾寫入 abortRequested?——最優先,略過以下所有檢查
- 補齊未完成項後寫 Aborted 終局
-
無 abortRequested、且無 stepAttempt?
- 開始 Attempt 1(本次 plan 決定,之後繼續執行)
-
無 abortRequested、且有 stepAttempt?——不論這個 attempt 是還在進行還是已經
stepFailed,都先過這一關- configurationDigest 不符 → Blocked:configuration_changed(不寫終局,需人為介入)
- digest 相符,但 environmentIdentity 不符 → Blocked:environment_changed(不寫終局,需人為介入)
-
digest 與 environment 皆相符,且尚未 settled
- attempt = 1 → Retry:再送一次 attempt 2(本次 plan 決定,之後繼續執行)
- attempt = 2 仍未 settled → Blocked:attempts_exhausted(不寫終局,需人為介入)
-
digest 與 environment 皆相符,且已
stepFailed→ Failed 終局(不可重試)——注意:只有先通過上面兩關(digest、environment 都相符),才會走到這裡;stepFailed本身不保證一定走向這個結局。
-
assistant 已結算、某個 tool call 無 result?——逐一檢查每個 pending tool,同樣先查
environment 再查 declaration/digest
- tool 的 environmentIdentity 不符 → Blocked:environment_changed(不寫終局,需人為介入)
- tool declaration 缺失或 definitionDigest 不符 → Blocked:configuration_changed(不寫終局,需人為介入)
- replay = safe,且 replayKey/digest 全部相符 → Replay:重播這個 tool effect(本次 plan 決定,之後繼續執行)
- tool 本身記錄 safe,但目前 declaration 非 safe 或 replayKey 不符 → Blocked:replay_declaration_changed(不寫終局,需人為介入)
- replay = never → 補 Synthetic Interrupted,不重播(本次 plan 決定,之後繼續執行)
Synthetic Results 維護 Transcript
Recovery 不是只改變 internal state;它必須補出 provider 可接受的 tool result:
invalidArguments → tool未執行
unknownTool → tool未執行
truncated → arguments可能不完整,未執行
aborted → abort時尚未開始
interrupted → intent已寫,effect狀態未知,不重播
這些內容是跨語言 contract,不能 localize 或隨意改寫,因為它們同時進入 model transcript 與 shared fixtures。
把所有 Durable Prefix 當測試對象
Repo 中的 shared corpus 包含 reducer 與 planner fixtures。每個語言都必須從相同 JSONL bytes 得到等價 state,從真實 prefix 與 current configuration 得到相同 plan。它不是測試 helper,而是四語言 parity 的永久 merge gate。
make test
# TypeScript / Go / Python / Rust 都會跑各自的 reducer、planner 與 production tests
真實案例:一個真的用 kill -9 挖出來的 bug
這不是假設情境。我們真的對 tiny-agent 的四個語言 port 各跑了一次「先讓一個 safe-replay tool(read)的
toolStarted 落盤、再對真正的 process 送出真的 SIGKILL」,然後重開程式、讓
resumeSession() 接手。
Replay 本身四個語言都做對了:重開後真的重新執行了那次 read,拿到硬碟上的真實內容。問題出在 replay
之後:recovery 接著自動送出下一輪 model request,而這次模型只是正常回答完畢(stop,沒有
tool_calls)。TypeScript port 在這一步直接把 session 弄壞了——reducer 丟出
INVALID_TRANSITION,整份 durable log 從此無法再被開啟。Go、Rust、Python 三個 port 面對完全一樣的序列都沒有事。
根因藏在 assistantCalls() 這個小函式裡:它原本的寫法是「往回掃整段 transcript,找最後一個
曾經帶過 tool_calls 的 assistant 訊息」。這句話平常沒問題,但當「目前這一輪」的 assistant
回覆恰好是 plain stop(沒有 tool_calls)時,這個掃描會直接跳過它,抓到更早、已經處理完的那一輪。Planner
於是誤判成「那些舊的 tool_calls 還沒解決」,用一個早就過期的 contextThroughEntryId
去開新的 stepAttempt——reducer 一比對,發現它跟現在真正最新的
activeContextThroughEntryId 不一致,判定成不合法的狀態轉移,直接拒收。
修法只是把「找最後一個曾經帶 tool_calls 的 assistant」這句話,換成「找最後一個 assistant 訊息,不管它有沒有帶 tool_calls」——因為在這個協議下,新的 assistant 訊息本來就不可能在前一輪的 tool_calls 全部解決之前被寫進去,所以最後一個 assistant 訊息一定就是「目前這一輪」,不需要額外過濾。
這個案例值得記住的不是這個特定的修法,而是它怎麼被找到的:單元測試裡的 fixture 都是綠燈,真正的漏洞要靠真的
kill -9 一個真的 process 才會現形。四語言 parity 的永久 merge gate(上一節)保證了「同一份
durable bytes 在四個語言得到同一個 plan」,但它沒辦法保證「這份 bytes 本身涵蓋了所有真實會發生的 crash
timing」——那要靠真正對著跑起來的程式按下去的那一下 kill。
親手驗證
Reducer 與 planner 都是 pure functions,因此最好的練習是直接對同一 durable prefix 執行離線測試。先跑 safe 與 never 兩條 recovery fixture:
cd typescript
node --import tsx --test --test-name-pattern="replay-safe-tool|interrupted-never-tool" test/session-reducer.test.ts
cat ../schemas/session/planner-fixtures/replay-safe-tool.expected.json
cat ../schemas/session/planner-fixtures/interrupted-never-tool.expected.json
--test-name-pattern 必須放在檔案路徑之前才會實際過濾;放在 npm test -- 之後會被接到 glob
尾端而完全不生效,因此這裡直接呼叫 node --test 並指定單一檔案,只跑這兩個 fixture 對應的測試。
比較兩份 plan:safe read 必須包含相同的 replayKey 與 environment identity;never tool 只能 materialize
interrupted synthetic result,不能再次執行 effect。再將 fixture 的 current configuration 中任一 digest
改掉,重跑 planner 測試,預期結果應是 blocked,而且原 JSONL bytes 不得改變。
以上都是 process crash 後被動發現的狀態。如果使用者按下 Esc 是主動要求中斷,情況又不一樣——下一章處理這條路徑。