第三部|可靠執行 · 第 6 章
Reducer、Planner 與 Recovery
以 pure reducer 重建狀態,再由 planner 區分 retry、replay、interrupted 與 blocked。
重開檔案不應該直接執行effect。第一步是用pure reducer回答「durable facts表示什麼狀態」;第二步才由pure planner回答「在目前configuration下,允許做什麼」。
兩個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,才是合法下一步。
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
親手驗證
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是主動要求中斷,情況又不一樣——下一章處理這條路徑。