第三部|可靠執行 · 第 5 章
Durable Session:先記 Intent,再做 Effect
用 transactional JSONL 保存接受的工作、model attempt 與 tool intent,讓 crash 後仍可解釋。
設想這個情境:Agent剛送出 bash rm old.log && deploy.sh,process在tool執行中被kill -9。重開後,deploy.sh到底跑了沒有?(這只是用來說明問題的情境,不需要你實際執行。)
核心規則:intent必須早於effect。
如果假設model request永遠成功、tool不會失敗、process也不會突然停止,那麼幾十行就能寫出一個Agent。真正困難的是失敗之後的狀態:process crash之後,系統仍必須判斷工作是否已接受、model是否呼叫過,以及tool effect是否可能已經發生。本章要建立一種durable格式,讓上面這個問題有明確答案,而不是猜測。
Durability 的精確定義
Tiny-agent承諾process-crash durability:一筆已接受的LF-terminated transaction已交給OS;重開時會丟棄最後LF之後的torn tail,並從完整prefix恢復。
它不承諾power-loss durability。沒有每筆file sync與directory sync,就不能保證斷電或storage controller failure後仍存在。教材刻意保留這個區別,不用含糊的「永不遺失資料」。
一個檔案,兩種行
第一行是唯一header:
{
"kind": "header",
"version": 2,
"id": "019...",
"createdAt": 1787371200000,
"cwd": "/workspace",
"provider": "openrouter",
"model": "deepseek/deepseek-v4-flash-0731",
"environmentIdentity": "/workspace"
}
之後每一行是一個完整transaction:一個fact,或一個non-empty facts array。Array中的facts對reducer原子可見。
[
{"kind":"entry","seq":1,"id":"...","entry":{"type":"message","message":{"role":"user","content":"修正bug"}}},
{"kind":"record","seq":2,"id":"...","record":{"type":"runStarted","operationId":"...","operationKind":"run","inputEntryId":"..."}}
]
Intent 必須早於 Effect
舊式session常在tool完成後才寫 result。若process在外部effect成功、result落盤前crash,重開後完全看不出tool是否執行過。
錯誤:execute effect → persist result
正確:persist toolStarted → execute effect → persist result
toolStarted會保存有效arguments、tool identity、replay policy、environment identity與預留的result entry
ID。Crash後看到 intent卻沒有result,就能明確得到:
這個effect可能已經發生,結果未知。
系統不猜測。Safe read可以在identity完全一致時重播;write、edit、bash與MCP則寫入interrupted synthetic result,不重播。
Entry、Record 與 Usage
| Kind | 保存內容 | 會送給模型? |
|---|---|---|
entry |
user/assistant/tool message、compaction checkpoint | 依active context投影 |
record |
run、attempt、tool intent、abort、operation outcome | 不會 |
usage |
physical model attempt或nested tool用量 | 不會 |
Usage獨立成ledger,避免從「最後一則assistant message」猜整個session成本。每筆usage綁定到exact attempt或toolStarted。
Transaction Invariants
seq從1開始,每個fact嚴格+1,包含array內部。- ID是唯一UUIDv7;reference必須指向已存在且正確ownership的fact。
- 完整transaction先驗證,成功後才更新memory state。
- 最後LF後的 bytes是torn tail;完整但非法的一行則是corruption,不能跳過。
- Session使用single-writer contract;runtime只防同process重複writer,跨process互斥由job runner保證。
深而小的 Storage Interface
interface SessionStore {
commit(facts: NewFact[]): Promise<CommittedFact[]>;
load(): Promise<SessionState>;
close(): Promise<void>;
}
commit隱藏seq、IDs、timestamp、serialization與FIFO queue;load隱藏framing、torn-tail
repair與pure reduction。刪除這個module會讓複雜度重新散回agent loop,因此它是真正通過deletion test的deep module。
稍後你會再看到這個形狀:commit/load隱藏了seq、ID、framing,這是全書第二次看到「小而深的介面」。
親手驗證
先用標準函式庫親手建立一個最小 append-only JSONL,再故意留下沒有 LF 的 torn tail。這個練習不需要模型或網路:
tmp=$(mktemp)
printf '%s\n' '{"kind":"header","version":2}' > "$tmp"
printf '%s\n' '[{"kind":"record","seq":1}]' >> "$tmp"
printf '%s' '{"torn":' >> "$tmp"
node -e 'const b=require("fs").readFileSync(process.argv[1]); const i=b.lastIndexOf(10); console.log(b.subarray(0,i+1).toString())' "$tmp"
輸出只應包含兩個 LF-terminated records;最後半截不能被當成已提交的交易。接著執行真正的 Store regression test,確認修復後才能繼續 append:
cd typescript
node --import tsx --test --test-name-pattern="repairs a torn tail" test/session.test.ts
--test-name-pattern必須放在檔案路徑之前,Node.js的test runner才會實際過濾;放在npm test --之後會被接到glob尾端而完全不生效,因此這裡直接呼叫node --test並指定單一檔案,只跑這一個測試。
最後開啟 schemas/session/fixtures/torn-tail.jsonl 與其 expected state,對照手作版本和 canonical
contract 的差別。
重建出的SessionState本身不能決定下一步該做什麼——這是下一章Planner的責任。