# ADR-119: Durable Execution & SAGA Compensation **狀態**:Accepted **日期**:2026-05-03(台北) **決策者**:統帥 **範圍**:Agent run 的步驟級 journal、補償命令設計、SAGA 觸發條件 **關聯**:ADR-114(worker lease)、ADR-106(Run State contract) --- ## 背景 ADR-114 解決了 worker crash 後的 run 回收問題(stale reaper)。但 stale reaper 只是把 run 回到 PENDING 狀態,讓 worker 重試。這帶來新問題: **問題 1:無法知道 run 到哪一步才 crash** worker 重新取到 run 後,從頭執行 → 重複呼叫已完成的步驟(例如:已經寫了 K8s repair,重試時再寫一次)。 **問題 2:部分執行的副作用不可逆** Agent 已執行了 `k8s_exec_pod`(重啟了某個 Pod),但後續步驟失敗了。重試時再重啟一次,造成服務閃斷兩次。 **問題 3:無法對 approval 決策做補償** `WAITING_APPROVAL` 的 run 被 reject 後,Agent 已呼叫的 tool 副作用無法回滾。 **設計目標**: - Step-level idempotency(步驟可重試而不產生重複副作用) - Compensation(補償命令)設計 - 不依賴外部 durable execution 服務(Temporal 等),使用 DB + Redis 實作 --- ## 決策 ### D1 — SAGA 步驟 Journal(saga_steps JSONB) `awooop_run_state` 表新增 `saga_steps` JSONB 欄位: ```sql ALTER TABLE awooop_run_state ADD COLUMN saga_steps JSONB NOT NULL DEFAULT '[]'; -- saga_steps 結構(JSONB array) -- [ -- { -- "step_id": "uuid", -- "step_type": "llm_call" | "tool_call" | "approval_request" | "callback", -- "tool_id": "k8s_exec_pod", -- tool_call 時填入 -- "tool_params": {...}, -- tool_call 時填入(供補償使用) -- "status": "pending" | "completed" | "failed" | "compensated", -- "result": {...}, -- 步驟結果(completed 時填入) -- "compensation_cmd": {...}, -- 補償命令(tool_call 才有) -- "started_at": "ISO8601", -- "completed_at": "ISO8601" -- } -- ] ``` ### D2 — 步驟冪等性(Step Idempotency) Worker 重新取到 run 後,**跳過已完成的步驟**: ```python async def resume_run_from_checkpoint(run: RunState) -> None: completed_steps = { step["step_id"] for step in run.saga_steps if step["status"] == "completed" } for step in plan_steps: if step.step_id in completed_steps: # 跳過已完成的步驟,使用已記錄的 result result = get_step_result(run.saga_steps, step.step_id) else: # 執行步驟,寫入 journal result = await execute_step(step) await append_saga_step(run.run_id, step, result) ``` **每個步驟執行前 journal 寫入「pending」,執行後更新為「completed」或「failed」**: ```python async def execute_step_with_journal(run_id: str, step: Step) -> StepResult: # 寫入 pending(防止重複執行) await upsert_saga_step(run_id, step.step_id, status="pending") try: result = await step.execute() await upsert_saga_step(run_id, step.step_id, status="completed", result=result) return result except Exception as e: await upsert_saga_step(run_id, step.step_id, status="failed", error=str(e)) raise ``` ### D3 — Compensation(補償命令)設計 **只有 tool_call 步驟有補償命令**(LLM call 不可逆): | Tool | 補償命令 | 可補償條件 | |------|---------|----------| | `k8s_exec_pod`(exec 類)| N/A(副作用不可逆)| 不補償 | | `k8s_scale_deployment`(scale 到 N)| scale 回原值 | 有原始 replica count | | `k8s_create_resource` | `k8s_delete_resource` | 有 resource 名稱 | | `pg_mutate`(INSERT)| `pg_mutate`(DELETE 對應 ID)| 有 inserted_id | | `knowledge_write` | `knowledge_delete` | 有 entry_id | **補償命令格式**(在 saga_steps 中): ```json { "step_id": "uuid", "tool_id": "k8s_scale_deployment", "tool_params": {"deployment": "api", "replicas": 3}, "status": "completed", "compensation_cmd": { "tool_id": "k8s_scale_deployment", "params": {"deployment": "api", "replicas": 1}, // 原始值 "reason": "SAGA compensation: scale deployment back" } } ``` ### D4 — SAGA 觸發條件 **自動觸發補償**的情境: ```python SAGA_COMPENSATION_TRIGGERS = [ # 1. approval 被 reject RunStatus.WAITING_APPROVAL → reject → trigger_compensation, # 2. approval_token 過期(15min TTL) approval_token_expired → trigger_compensation, # 3. 後續步驟失敗,已有 compensation_cmd 的步驟需要回滾 step_failed AND has_compensatable_steps → trigger_compensation, # 4. 主動 CANCEL user_cancel → trigger_compensation if has_compensatable_steps, ] ``` **補償執行順序**:反向(最後執行的步驟先補償): ```python async def run_saga_compensation(run: RunState) -> None: completed_steps_with_compensation = [ step for step in reversed(run.saga_steps) if step["status"] == "completed" and step.get("compensation_cmd") ] for step in completed_steps_with_compensation: cmd = step["compensation_cmd"] try: await execute_mcp_tool( run_id=run.run_id, tool_id=cmd["tool_id"], tool_params=cmd["params"], project_id=run.project_id ) await update_saga_step(run.run_id, step["step_id"], status="compensated") except Exception as e: # 補償失敗不 raise,繼續補償其他步驟 await update_saga_step(run.run_id, step["step_id"], status="compensation_failed", error=str(e)) await write_audit_log("SAGA_COMPENSATION_FAILED", run.run_id, step, str(e)) ``` ### D5 — SAGA 狀態欄位 `awooop_run_state` 補充欄位: ```sql ALTER TABLE awooop_run_state ADD COLUMN saga_status VARCHAR(32) DEFAULT 'in_progress'; -- 'in_progress' | 'compensating' | 'compensated' | 'compensation_failed' ALTER TABLE awooop_run_state ADD COLUMN compensation_started_at TIMESTAMPTZ; ALTER TABLE awooop_run_state ADD COLUMN compensation_completed_at TIMESTAMPTZ; ``` ### D6 — 不補償的情境 以下情境**不執行 SAGA 補償**,只記錄 audit log: - `step_type = "llm_call"`(LLM 輸出不可逆,補償無意義) - `step_type = "k8s_exec_pod"`(exec 副作用不可逆) - `attempt_count >= max_attempts`(已超過最大重試,直接 FAILED) - `status = "CANCELLED"` 且無 compensatable steps(直接結束) --- ## 與 ADR-114 的關係 | 機制 | ADR-114 | ADR-119 | |------|---------|---------| | Worker crash 後恢復 | Stale reaper 回收 → PENDING | ✓(由 ADR-114 觸發)| | 重試時跳過已完成步驟 | ✗ | saga_steps journal checkpoint | | 部分執行副作用回滾 | ✗ | compensation_cmd 反向執行 | | approval reject 處理 | ✗(FAILED)| SAGA 補償 + FAILED | --- ## 後果 ### Benefits - Worker crash 重試後不重複執行已完成步驟(冪等性) - Approval reject 後可選擇性回滾 K8s 操作(k8s_scale, k8s_create) - saga_steps journal 是完整的執行審計紀錄 ### Costs - 每個步驟需要額外 DB write(journal upsert) - `saga_steps` JSONB 欄位隨執行步驟增長(長 run 可能超過 1MB) - 緩解:步驟執行結果只存必要欄位,大型 result 存 S3/GCS 只存 reference ### Risks - 補償執行失敗(compensation_failed):操作已部分執行,系統進入不一致狀態 - 緩解:audit_log + Telegram 告警,需要人工介入 - K8s exec 等不可逆操作無法補償:設計上明確標記,並在 HIGH risk tool 加 approval gate --- ## 驗收標準 - [ ] `awooop_run_state` 新增 `saga_steps JSONB` 欄位(Phase 1 migration) - [ ] Worker crash 重試後跳過已完成步驟(模擬測試) - [ ] Approval reject → k8s_scale 補償命令執行(整合測試) - [ ] `compensation_failed` → audit_log 寫入 + Telegram 告警(整合測試) - [ ] `saga_steps` JSONB 格式驗證(Phase 3 schema validation) ## 關聯 - ADR-114(worker lease,stale reaper 觸發入口) - ADR-106(Run State contract,run 狀態機) - ADR-116(approval_token,reject 觸發 SAGA 補償) - ADR-117(MCP tool 執行,補償命令也走 MCP Gateway)