macOSlaunchdAutomationAI AgentDevOpsCLI

排程跑 AI Agent,我踩過的三個坑:exit 0 不代表成功

···閱讀約 6 分鐘

把 AI agent 掛上排程是很自然的想法:每天早上自動整理資料、產報告、跑摘要,醒來看結果就好。

我在 macOS 上用 launchd 做這件事。腳本在終端機手動跑,一百次一百次過;掛上排程的第一個晚上,它死了。

更糟的是我隔天早上才發現——因為它死得很安靜。

坑一:排程環境碰不到你以為碰得到的目錄

macOS 有一層叫 TCC 的隱私保護:~/Documents、~/Desktop、~/Downloads 這些目錄,每個 App 要經過使用者同意才能讀。你在終端機裡沒感覺,是因為終端機 App 已經被授權過了。

但 launchd 直接拉起來的 process 不繼承任何授權。它去讀 ~/Documents 下的專案目錄,拿到的是 EPERM——或者更迷惑的行為:卡住不動。

對 AI agent 這件事特別致命,因為 headless 模式的 agent CLI 通常以工作目錄為中心讀寫檔案。工作目錄放在受保護路徑下,等於整個 agent 泡在權限錯誤裡。

環境讀 ~/Documents原因
終端機手動跑正常終端機 App 已被 TCC 授權,子 process 繼承
launchd 排程跑EPERM 或卡死launchd process 沒有 TCC 授權可繼承
同一個腳本,兩種環境的檔案存取

解法不是想辦法給 launchd 授權(做得到,但脆弱——授權綁定二進位的 hash,工具一更新就悄悄失效),而是繞開整個問題:

排程任務的工作目錄和所有讀寫檔,一律放在中性路徑,例如 ~/.local/share/<任務名>/。真的需要讓結果出現在 Documents 裡,用 symlink 指過去——互動環境讀寫 symlink 完全無感。

同場加映:排程環境也不載入你的 shell profile。PATH 裡沒有你裝的工具,在腳本裡明確 export,不要假設。

坑二:非 TTY 的 stdin 會讓 CLI 永遠等下去

第二個坑的症狀最詭異:agent CLI 在排程裡啟動之後什麼都不做。不報錯、不輸出、不結束,連 session 檔都沒建。

原因是 stdin。互動環境裡 stdin 是你的鍵盤。我遇到的是 codex exec:在非互動的背景 shell 裡沒給 stdin,它會一直等 EOF。加 < /dev/null 就好。排程工具預設給什麼 stdin,說明書不一定寫,拿不準就自己指定。

bash
# 錯:沒給 stdin,codex exec 在背景 shell 裡一直等 EOF
codex exec "整理今天的資料" > log 2>&1

# 對:給它 /dev/null,stdin 立刻 EOF
codex exec "整理今天的資料" < /dev/null > log 2>&1

通則是:任何 CLI 在非 TTY 環境下行為都可能改變——讀 stdin、關掉顏色輸出、要求互動確認。背景跑任何東西之前,先想清楚它的 stdin 是什麼;拿不準就給 /dev/null。

輸出端同理:寫到檔案再事後解析,不要接 pipeline——pipeline 下游退出時的 SIGPIPE 是另一窩坑。

坑三:exit code 會說謊

第三個坑最貴,因為它壞的方式是「看起來一切正常」。

我的排程腳本原本用 exit code 判斷成敗:工具回 0 就記成功。直到有一天我發現連續好幾天的「成功」裡,產出檔一個字都沒更新。那個工具是 headless 的 Claude Code(claude -p)。macOS 的 TCC 擋住它讀檔,它拿到 EPERM,把錯誤寫進輸出。整次執行仍算完成,所以回傳 exit 0。

對一般程式,exit code 是可靠的契約。對 AI agent 這種「內部吞掉錯誤繼續嘗試」的工具,它常常只代表「process 正常結束」,不代表「任務完成」。

所以我後來給每個排程任務都上了四件套,原則是壞的時候必須看得到:

#做法防什麼
1驗證結果本身:檢查產出檔有今天的日期與預期內容,而不是看 exit codeexit 0 但實際失敗
2watchdog:子 process 設時限,超時 kill(macOS 沒有 timeout 指令,用 kill -0 輪詢)卡死佔著排程,永不結束也永不報錯
3失敗主動通知:系統通知 + 狀態檔 + log 裡醒目的 FAILED + exit 非 0安靜地失敗好幾天沒人發現
4用真排程觸發做端對端驗證,不只手動測手動過、排程掛的所有環境差異
Fail-visibly 四件套

第四點值得展開:手動測過不等於排程會過,這正是前面兩個坑教的事。macOS 上可以強制觸發一次真的排程執行:

bash
launchctl kickstart gui/$(id -u)/<你的任務label>

這一行跑出來的環境,才是你的任務每天晚上真正面對的環境。上線前用它做一次端對端,比手動跑十次都有用。

實例:Claude Code 文件怎麼定義「失敗」

Claude Code 是 Anthropic 的 AI agent 工具,可以在終端機跑。它的官方文件有一頁講非互動模式,裡面寫了退出碼的規則(2026-11-05 讀)。

文件寫:成功時退出碼是 0,這次執行失敗時是非 0,腳本可以照退出碼分流。

同一段還分了兩種失敗。旗標打錯時,錯誤印到 stderr,執行根本沒開始。執行中出錯時,例如缺少驗證,失敗內容會當成結果印到 stdout。

SDK 文件另外定義了結果訊息。它的 subtype 有 success,也有 error_max_turns、error_during_execution 等錯誤類型。另有一個 is_error 欄位。

示意圖:一次排程執行的三層訊號。第 1 層退出碼,回答這次執行有沒有失敗;第 2 層結果訊息的 subtype 與 is_error,回答失敗屬於哪一類,權限被拒的呼叫另列在 permission_denials;第 3 層是作者自己檢查產出檔,回答任務有沒有完成
第 1、2 層依 Claude Code 官方文件的 headless 頁與 Agent SDK TypeScript 參考頁(2026-11-05 讀)整理;第 3 層是作者自己的檢查,不是文件內容。KeepOnFirst 自繪示意圖。

這兩層都是好訊號,我會一起看。但它們回答的是「這次執行有沒有失敗、失敗屬於哪一類」。

排程真正要問的是另一件事:今天的產出檔有沒有寫出預期的內容。

這兩件事可能不一致。坑三那次就是:macOS 擋住 Claude Code 讀檔,它把 EPERM 寫進輸出,這次執行照常完成,退出碼是 0。

這和文件「失敗才非 0」不衝突。讀檔失敗不是執行失敗。

文件也寫了相近的情況:Claude Code 自己的權限機制拒絕某個工具呼叫時,執行會繼續。用 stream-json 輸出時,最後的 result 訊息會在 permission_denials 欄位列出被拒的呼叫。

macOS 擋讀檔不是這套權限機制。它會不會列進 permission_denials,文件沒寫。

所以四件套的第 1 項還是放在最後把關:直接檢查產出檔。

診斷心法:先懷疑環境,不是邏輯

三個坑收斂成一句話:互動跑得過、排程跑不過,第一個該懷疑的是環境差異——TCC、PATH、stdin、keychain、HOME——而不是程式邏輯。

有效的隔離法是建一個一次性的 probe 任務,在真排程 context 下分別測最小動作:能不能讀那個目錄、工具能不能最小執行。二分定位,比盯著自己的程式碼猜快得多。

無人值守的自動化,難的從來不是自動化本身,是接受一個事實:那個環境不是你的 shell。權限、輸入、失敗回報,全部要按它的規則重新想一遍。

參考資料