昨天我們把 TaskFlow 這個編排層看完了:它有目標、有目前步驟、有狀態、有版本號,而且跨 gateway 重啟存活。
看起來很完整。但那是流程「一切順利」時的樣子。
真實世界不是這樣。真實世界是:
這三件事,OpenClaw 各有一個設計在處理。而且我認為這三個設計,是整個 src/tasks/ 裡最值得抄的部分。
尤其是中間那一個。我第一次看到那行程式碼的時候愣了一下:
if (task.status === "succeeded") {
return task.terminalOutcome === "blocked" ? "blocked" : "succeeded";
}
任務成功了,流程卻是 blocked。
今天就從這一行開始。
lost 這個狀態代表什麼?系統為什麼要承認自己會弄丟東西?我把今天的內容分成三個場景:
terminalOutcome: "blocked"
cancelRequestedAt 與 pending 判定lost、寬限期與對帳這三個場景有一個共同點:系統不能只看表面狀態就下判斷。
succeeded 不一定是好消息,running 不一定真的在跑,cancelled 不一定已經停了。
一個成熟的流程系統,要能分辨「標籤」和「事實」。
先看那個 task 狀態怎麼翻譯成 flow 狀態的函式。
📄 原始碼:
src/tasks/task-flow-registry.ts:210-227
task: Pick<TaskRecord, "status" | "terminalOutcome">,
): TaskFlowStatus {
if (task.status === "queued") {
return "queued";
}
if (task.status === "running") {
return "running";
}
if (task.status === "succeeded") {
return task.terminalOutcome === "blocked" ? "blocked" : "succeeded";
}
if (task.status === "cancelled") {
return "cancelled";
}
if (task.status === "lost") {
return "lost";
}
return "failed";
}
大部分是一對一翻譯,只有 succeeded 那一行分岔了。
官方文件對 blocked 這個狀態的定義只有一句話,但講得比我任何解釋都準:
📄 文件:
docs/automation/taskflow.md
| `blocked` | A step finished without a usable result; `blockedTaskId`/summary say which |
某一步跑完了,但沒有產出可用的結果。
要理解它怎麼被算出來,先看 terminalOutcome 的定義:
📄 原始碼:
src/tasks/task-registry.types.ts:34
/** Semantic success detail for required-completion task outcomes. */
export type TaskTerminalOutcome = "succeeded" | "blocked";
所以一個 task 結束的時候,除了「有沒有跑完」,還要回答「跑完之後事情有沒有往前」。
這兩件事真的不一樣。
舉個例子。你叫子代理去把某個設定改掉,它跑完了,沒有報錯,回報:「我檢查過了,這個設定需要管理員權限,我沒有權限改。」
task 成功了——它完整執行、正確回報、沒有崩潰。以一次執行來說,它表現完美。
但 flow 卡住了——事情一步都沒有往前。
如果你只有 succeeded 這一個狀態,這兩種情況會被歸成同一類。然後上層看到一片綠色的成功,以為一切順利,實際上流程早就停在那裡不動了。
我覺得這是整個模組裡最漂亮的一個設計,因為它承認了一件事:
「做完」和「有進展」是兩件事,而且系統必須同時知道這兩件事。
flow 記錄的欄位裡有一個 cancelRequestedAt。
注意那個字:Requested。不是 cancelledAt,是「取消被請求的時間」。
為什麼?看這一段:
📄 原始碼:
src/tasks/task-cancellation-state.ts:15-21
export function isTaskFlowCancellationPending(
task: Pick<TaskRecord, "runtime" | "status" | "error">,
): boolean {
return (
task.status === "queued" || task.status === "running" || isProvisionalSubagentKillTask(task)
);
}
這個函式在回答:「這條 flow 的取消,完成了沒?」
只要底下還有 task 是 queued 或 running,取消就還在進行中。
這很符合現實。你按下取消的那一刻,可能有一個子代理正在寫檔案,有一個 API 呼叫已經送出去了。你不能假裝那些事情沒發生。
文件把這個行為描述得更完整,而且多了一個我沒從 source 看出來的重點:
`openclaw tasks flow cancel` sets a sticky cancel intent on the flow, cancels its
active child tasks, and refuses new managed child tasks. ... The intent is
persisted, so a cancelled flow stays cancelled even if the gateway restarts
before all child tasks have terminated.
三件事:sticky(黏著的)、refuses new(拒絕新的子任務)、persisted(寫進資料庫)。
最後那個尤其重要。取消意圖是存下來的,所以就算 gateway 在子任務還沒全部結束時重開,那條 flow 醒來還是 cancelled。
否則你會遇到最糟的情況:你取消了,系統重開,然後它若無其事地繼續跑。
更細的是第三個條件:
export function isProvisionalSubagentKillTask(
task: Pick<TaskRecord, "runtime" | "status" | "error">,
): boolean {
return (
task.runtime === "subagent" &&
task.status === "cancelled" &&
task.error === SUBAGENT_KILL_TASK_ERROR
);
}
一個已經標成 cancelled 的子代理 task,還是算「取消尚未完成」。
因為那個 cancelled 是我們單方面貼上去的標籤——我們發了 kill,但還沒確認對面真的停了。所以它叫 provisional,暫定的。
這個區分很有價值:「我已經下令了」和「它已經停了」是兩件事。
這是最難的一個。
一個 task 在資料庫裡寫著 running。它可能是:
系統要怎麼分辨?OpenClaw 的做法是定期跑維護掃描,而且它不用單一條件判斷,而是列舉了一整組理由:
📄 原始碼:
src/tasks/task-registry.maintenance.ts:176-181
| "active_cli_run"
| "backing_session_missing"
| "backing_session_present"
| "cron_runtime_not_authoritative"
| "lost_grace_pending"
| "subagent_recovery_wedged";
這裡要先說明一件事:這段掃描是在 task registry 這一層跑的,不是 flow 那一層。
昨天說過,cli 和 cron 不會自動被包成 flow,但它們一樣是 task,一樣會被追蹤、一樣會變殭屍。所以這道掃描要同時應付四種 runtime,理由列表才會這麼雜。
這幾個理由把「為什麼這個 task 看起來卡住」分得很細:
active_cli_run —— 有真的在跑的 CLI,所以它是活的,別動它backing_session_missing —— 它掛靠的 session 不見了,這才是真孤兒cron_runtime_not_authoritative —— 這是 cron 任務,它的狀態不由這裡說了算subagent_recovery_wedged —— 子代理復原卡死了lost_grace_pending —— 還在寬限期內,再等等
最後那個我特別喜歡。
系統發現一個任務看起來死了,第一反應不是立刻判死,而是給它一段寬限期。
這是從痛苦裡學來的設計。太快判死,你會把只是跑得比較慢的任務砍掉;太晚判死,那些殭屍任務會一直佔著資源。所以中間要有一個緩衝。
再看真的判死之後會怎樣。文件對 lost 的定義是 Flow lost its authoritative backing state——流程失去了它的權威狀態來源。
這就是 lost 這個狀態存在的理由——系統公開承認「我弄丟了一個正在跑的任務」。
不是 failed(它失敗了),不是 cancelled(我們停掉它),而是 lost:我不知道它怎麼了。
大部分系統不願意有這個狀態,因為它承認了自己的無知。但沒有它的話,那些任務只能被硬塞進 failed,然後你永遠分不清「真的失敗」和「失去追蹤」。
最後這一段是我覺得最溫柔的設計。
📄 原始碼:
src/tasks/task-registry.maintenance.ts:307-313
function isRecoverableLostCronTask(task: TaskRecord): boolean {
if (task.status !== "lost") {
return false;
}
const error = task.error?.trim().toLowerCase();
return Boolean(error?.includes("backing session missing"));
}
一個已經被標成 lost 的 cron 任務,如果它 lost 的原因是「backing session missing」,那它還可以被救回來。
因為 session 不見不代表任務失敗,只代表我們暫時找不到它的記錄。session 重建之後,狀態可以重新對上。
這其實是整個 src/tasks/ 最一致的態度:只要還有機會對上,就不要急著蓋棺。
lost 在這個系統裡不是墓碑,是一個待確認的標記。
今天講的三個場景,可以濃縮成一句話:
狀態欄位寫的是「系統目前相信的事」,不是「事實」。
succeeded 是「這次執行跑完了」,不是「事情解決了」cancelled 是「我們發出了取消」,不是「它停了」running 是「我們最後一次聽到它還活著」,不是「它現在活著」一旦你接受這件事,很多設計就自然浮出來:你會需要 terminalOutcome、需要 pending 判定、需要寬限期和對帳。
這些東西不是過度設計,是分散式系統的基本禮貌。你的 Agent 一旦會開子行程、會排程、會跨機器,它就是個分散式系統了,不管你想不想承認。
我再多講一句這個,因為它對自己寫 Agent 的人最有用。
模型回報「我做完了」的時候,它說的通常是「我這一輪的輸出產生完了」。它不一定在說「你要的事情達成了」。
這兩者的落差,就是無數 Agent demo 看起來很順、上線就崩掉的原因。
所以你的任務結果,不能只有成功/失敗兩個值。至少要有第三個:「我完整執行了,但事情沒有往前」。
有了這個值,上層才有機會做對的事——換策略、問人、或是老實告訴使用者卡住了。
前面講的都是「怎麼判斷還活著」。但還有一個反方向的問題:什麼時候可以把它忘掉?
文件給了一個數字:
`openclaw tasks maintenance` (finalizes stuck cancels, prunes terminal flows after 7 days)
七天。這個數字本身不重要,重要的是它承認了一件事:持久化的東西需要有人負責清掉。
很多人做狀態持久化的時候只想到「要存起來」,沒想到「什麼時候可以刪」。結果跑半年之後,資料庫裡躺著幾十萬筆沒人看的終結流程。
而刪除的條件很嚴格:
📄 原始碼:
src/tasks/task-flow-registry.maintenance.ts:54-60
function shouldPruneFlow(flow: TaskFlowRecord, now: number): boolean {
if (!isTerminalFlow(flow)) {
return false;
}
if (hasActiveLinkedTasks(flow.flowId)) {
return false;
}
return now - resolveTerminalAt(flow) >= TASK_FLOW_RETENTION_MS;
三個條件同時成立才能刪:流程本身已經終結、底下沒有還活著的 task、而且已經過了保留期。
中間那個條件特別重要。一條 flow 可以已經標成 cancelled,但底下還有 task 沒停——這時候刪掉 flow,那些 task 就真的變孤兒了。
所以「清理」這件事,跟前面三個場景是同一個道理:終結的標籤,不代表底下真的都停了。
lost 可以被救回terminalOutcome: "blocked" 讓「跑完了」和「有進展」分開,這是整個模組最該抄的一行cancelRequestedAt 的 Requested 代表取消是請求不是命令,要等底下 task 真的停cancelled 的子代理 task 仍算 pending,因為那只是暫定標籤lost 不是墓碑而是待確認,backing session missing 造成的 lost 還能救回來到這裡,OpenClaw 我想看的都看完了。
從第 1 天的「這是什麼」,到中段的記憶、子代理、失敗處理,到 plugin 與 ClawHub,再到這兩天的調度層。
但這 30 天我一直在做同一件事:讀別人已經蓋好的系統。
最後一天我想換個方向。與其再整理一次 OpenClaw 有什麼,不如拿另一套完全不同血統的框架當建材,自己動手設計一次——然後看看這 30 天讀到的東西,有哪些是我一定會搬過去的。
用 OpenAI Agents SDK 設計一支蜂群