真正麻煩的工作,通常不是一開始就失敗。
而是它看起來快要成功了,卻在最後一刻卡住。
像這樣:
這種狀況最煩。因為表面上看起來「事情有在動」,實際上結果卻沒有真的落地。
第 15 天我想講的就是這個很現實的問題:
失敗不是結束,Agent 怎麼處理錯誤?
如果把 OpenClaw 想成一個工作流系統,那錯誤處理不是額外功能,而是它能不能活下去的核心。
abortSignal 不是單純的中斷,而是流程控制的一部分?failed-retryable 這種狀態到底代表什麼?OpenClaw 處理錯誤時,不是只有「成功」和「失敗」兩種結果。
它更像是在看一條狀態鏈:
這代表 OpenClaw 的錯誤處理不是單一判斷式,而是一組層層收斂的機制。
你可以把它理解成:
這一篇就是把這幾個層次串起來看。
先看 session lifecycle 最重要的一條防線:舊事件不能改新 session。
📄 原始碼:
src/gateway/session-lifecycle-state.ts(本機版本行號已變動,僅標到檔案)
function isStaleLifecycleEventForSession(params) {
return Boolean(params.owningSessionId && params.currentSessionId && params.owningSessionId !== params.currentSessionId);
}
這個判斷很短,但意思非常重。
如果一個 run 的 sessionId 跟現在 session row 裡的 sessionId 不一樣,那它就是舊帳。
舊帳不能回頭改新帳。
再看 lifecycle event 怎麼真的寫進 session。
📄 原始碼:
src/gateway/session-lifecycle-state.ts:138-167
function deriveGatewaySessionLifecycleSnapshot(params) {
const phase = resolveLifecyclePhase(params.event);
if (!phase) return {};
const existing = params.session ?? void 0;
if (phase === "start") {
const startedAt = resolveLifecycleStartedAt(existing?.startedAt, params.event);
return {
updatedAt: startedAt ?? existing?.updatedAt,
status: "running",
startedAt,
endedAt: void 0,
runtimeMs: void 0,
abortedLastRun: false
};
}
const startedAt = resolveLifecycleStartedAt(existing?.startedAt, params.event);
const endedAt = resolveLifecycleEndedAt(params.event);
return {
updatedAt: endedAt ?? existing?.updatedAt,
status: resolveTerminalStatus(params.event),
startedAt,
endedAt,
runtimeMs: resolveRuntimeMs({
startedAt,
endedAt,
existingRuntimeMs: existing?.runtimeMs
}),
abortedLastRun: resolveTerminalStatus(params.event) === "killed"
};
}
這裡可以看到 OpenClaw 不是只記「完成或失敗」,它還記:
running
done
timeout
killed
failed
也就是說,它看錯誤不是二元,而是狀態機。
再看執行層怎麼處理 abort。
📄 原始碼:
extensions/discord/src/monitor/message-handler.process.ts:74-74
function isProcessAborted(abortSignal) {
return Boolean(abortSignal?.aborted);
}
const { cfg, discordConfig, accountId, runtime, botUserId, mediaMaxBytes, discordRestFetch, abortSignal, guildHistories, historyLimit, replyToMode, message, author, sender, canonicalMessageId, data, client, channelInfo, channelName, messageChannelId, isGuildMessage, isDirectMessage, isGroupDm, messageText, shouldRequireMention, canDetectMention, effectiveWasMentioned, shouldBypassMention, channelConfig, threadBindings, route, abortSignal: abortSignal2 } = ctx;
if (isProcessAborted(abortSignal)) return;
這很像一個現場工作守則:
OpenClaw 不把 abort 當成例外,而是當成正常控制訊號。
接著看比較完整的失敗處理,像 Telegram 傳遞結果時怎麼收口。
📄 原始碼:
extensions/telegram/src/bot-message-dispatch.ts:2959-2961
if (!isRoomEvent && !suppressFailureFallback && !finalAnswerDelivered && (dispatchError || deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0)) {
const fallbackText = dispatchError ? "Something went wrong while processing your request. Please try again." : EMPTY_RESPONSE_FALLBACK;
sentFallback = (await (telegramDeps.deliverReplies ?? deliverReplies)({
replies: [{ text: fallbackText }],
...deliveryBaseOptions,
silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip),
mediaLoader: telegramDeps.loadWebMedia
})).delivered;
}
這段很實際。
如果主流程失敗了,但又不能什麼都不回,OpenClaw 就會送一個保底訊息。
這不是裝懂,而是避免使用者看到空白。
再看它怎麼把失敗區分成可重試和不可重試。
📄 原始碼:
extensions/telegram/src/telegram-ingress-spool.ts(本機版本行號已變動,僅標到檔案)
const deliveryFailureWithoutFinalResponse = !finalAnswerDelivered && (deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0);
const retryableDispatchFailure = dispatchError ?? (deliveryFailureWithoutFinalResponse ? new Error(`Telegram reply delivery failed without a final response (failed=${deliverySummary.failedNonSilent}, skipped=${deliverySummary.skippedNonSilent})`) : null);
if (retryableDispatchFailure && retryDispatchErrors && (dispatchError != null && !hasFinalResponse || dispatchError == null && deliveryFailureWithoutFinalResponse && !hasVisibleResponse)) return {
kind: "failed-retryable",
error: retryableDispatchFailure
};
這一段很值得看。
它不是只問「有沒有失敗」,而是問:
這就是 failed-retryable 的意義。
不是所有失敗都一樣。有些失敗只是這一輪送出沒成功,系統還可以再撐一次。
再看 abort 跟 retry 的配合。
📄 原始碼:
extensions/telegram/src/bot-message.ts:291-479
if (turnAbortSignal.aborted && !participant.abortSignal.aborted) {
const abortResult = turnAbortSignal.reason === "skipped" ? { kind: "skipped" } : {
kind: "failed-retryable",
error: turnAbortSignal.reason
};
participant.settle(abortResult);
}
這裡很清楚地表達一件事:
OpenClaw 並不把所有中斷都視為悲劇,它會看中斷的原因,再決定要怎麼結案。
最後看 announce 路徑如果活不過去,系統怎麼收尾。
📄 原始碼:
src/agents/subagent-announce-delivery.ts:1593-1593
if (params.expectsCompletionMessage && isCronRunSessionKey(canonicalRequesterSessionKey) && !resolveRequesterSessionActivity(canonicalRequesterSessionKey).isActive && !agentMediatedCompletion) {
const generatedMediaDelivery = await tryGeneratedMediaDirectDelivery();
if (generatedMediaDelivery) return generatedMediaDelivery;
if (!agentMediatedCompletion) return {
delivered: true,
path: "none"
};
}
這表示如果 requester 不在了,OpenClaw 不會硬等。
它會嘗試 direct delivery,實在不行也要讓流程有一個保底結束點。
不然背景任務就會卡在「理論上做完了,但沒地方送」的狀態。
很多系統一碰到錯誤,就直接丟 exception。
OpenClaw 比較像在說:
錯誤本來就是流程的一部分,只是我們要分辨它是哪一種錯誤。
所以它會先看:
這種做法比較麻煩,但比較能活。
abortSignal 不是取消按鈕,而是流程控制訊號很多人看到 abort 會直覺想成「失敗了」。
但在 OpenClaw 裡,abort 比較像是:
也就是說,abort 不一定代表壞掉,它可能只是「這條路先別走了」。
這種設計很重要,因為工作流裡最怕的是:
那就會開始產生重複、衝突、過期回覆。
failed-retryable 是系統留給自己的一次呼吸不是所有失敗都要立刻宣告結束。
有些失敗只是:
這時候 failed-retryable 就像系統說:
我先不判死刑,給自己再試一次的機會。
這很像實際工作。
郵件寄送失敗,不代表內容錯了。
它可能只是郵件系統剛剛剛好掛了。
如果主路徑壞了,最怕的不是錯誤訊息,而是什麼都沒有。
因為什麼都沒有,使用者只會懷疑:
所以 OpenClaw 會在必要時補一個 fallback text。
這不是最完美的答案,但至少是可理解的答案。
reset 之後,舊 lifecycle event 還是可能晚到。
如果系統不檢查 sessionId,它就可能把舊 run 的狀態寫到新 session 上。
那就會出現很奇怪的現象:
所以 isStaleLifecycleEventForSession 的存在很像防止快遞送錯地址。
OpenClaw 的錯誤處理很少是「神奇修好」。
它比較像:
這才是工程上真的有用的錯誤處理。
因為工作流系統最怕的不是失敗,而是失敗之後還留下半套狀態,下一輪接著亂。
failed-retryable 讓某些暫時性問題有機會修復如果換成最簡單的錯誤處理,就是失敗直接 throw。
那樣寫起來很省,但你會得到一個很脆的系統:
OpenClaw 顯然不是要那種系統。
它要的是在錯誤裡仍然能保持秩序。
abortSignal 是控制訊號,不只是單純取消failed-retryable 表示系統保留重試空間,不急著宣判失敗isStaleLifecycleEventForSession 防止舊事件污染新 session第 16 天我想接著看更進一步的事情:
重試、回退、補救,系統怎麼撐住現場?
因為第 15 天只是把錯誤分類清楚,下一天才是重點:
當真的要 retry、要回退、要補救時,OpenClaw 是怎麼把整個現場撐住的。