六月底主力 Mac 送修,我把整套環境切到備用機:dotfiles 有 curated allowlist、install.sh 是八步 orchestrator、記憶系統用 rsync 同步、31 個 launchd job 一鍵掛上。自檢通過,cron 交棒乾淨,當天我以為遷移完成了。
接下來五天,八個依賴陸續炸開。每一個都是「遷移當下不報錯,排程跑到那條路徑才死,而且死得很安靜」。這篇整理完整時間線、四層盲區分類,以及最後真正有用的驗收方法。
八連發時間線
| # | 發現日 | 缺的東西 | 症狀 | 潛伏 |
|---|---|---|---|---|
| 1 | Day 1 | 自建 CLI binary + GNU timeout | 交易 bot cron exit 127 | 數小時 |
| 2 | Day 1 | 交易用 pip 套件 + ~/.config/ 錢包設定檔 | 餘額查到 $0,bot 靜默跳過進場 14.6h | 半天 |
| 3 | Day 4 | anthropic pip 套件 | 新聞掃描每輪回報「0 signals」,被當正常噪音 | 4 天 |
| 4 | Day 5 | jieba + rank_bm25 | 記憶混合搜尋靜默降級成 legacy 關鍵字模式 | 5 天 |
| 5 | Day 5 | 備份加密 passphrase 檔 | 異地 secrets 備份每晚 exit 1,無告警 | 4 天 |
| 6 | Day 5 | bun | Telegram bridge 的 poller 每天重啟失敗,收訊聾了五天 | 5 天 |
| 7 | Day 5 | messaging plugin 的 pairing 狀態檔 | bot 把主人當陌生人,要求重新配對 | 5 天 |
| 8 | Day 5 | codex CLI + auth | 寫這篇文章要產封面圖時當場發現 | 5 天 |
第 8 發是在整理這篇文章的過程中發現的。這說明一件事:你永遠列不完,只能改變驗收方法。
四層盲區
事後把八發歸類,發現它們落在四個層,而且我的遷移工具鏈每層各漏一塊。
第一層:套件與 binary
install.sh 裝的是「機器層 + 記憶系統」的最小依賴集。但 ~/projects/ 下每個獨立專案有自己的 runtime 依賴:自建的 Mach-O binary(不在 brew 也不在 pip)、專案限定的 pip 套件、還有像 bun 這種「只有某個 plugin 用到」的 runtime。
最陰的變體是第 6 發:launchd plist 的 PATH 裡有 ~/.bun/bin——PATH 搬過來了,binary 沒搬。設定殘影會製造「依賴存在」的錯覺,你 grep 設定檔會以為一切就緒。
第二層:機密與狀態的 hand-carry
有一類檔案被刻意排除在所有同步機制之外:錢包憑證、備份加密的 passphrase。它們不在 dotfiles、不在 rsync、不在 git——這是對的,但遷移 checklist 裡也沒有「hand-carry 清單」,所以它們就消失了。
第 7 發教了我新的一課:plugin 的 runtime 狀態檔也屬於這層。Telegram plugin 的 pairing/allowlist 存在 ~/.claude/channels/telegram/access.json,不是機密、但也不在任何同步範圍。新機把我當陌生人,是狀態遺失,不是程式壞掉。
第三層:機器行為設定
git config --global(commit identity 變成機器預設)、pmset -c sleep 0(cron 主機必設,備用機預設 1 分鐘就睡)、WiFi 自動連線。這些不是檔案,是「機器的行為」,dotfiles 和套件清單都碰不到。
sleep 那條特別值得說:備用機之前沒睡死,純粹是因為某個常駐 app 掛著 audio assertion 碰巧撐住。「碰巧會動」是最危險的狀態,因為它看起來像「設定正確」。
第四層:驗證
前三層是「漏了什麼」,第四層是「為什麼五天都沒發現」。三個結構性原因:
1. set -e + 2>/dev/null + command substitution 的組合技。
RESULT=$(bash inner-script.sh 2>/dev/null) # inner 死掉 → set -e 直接殺整支
echo "[job] 已發送" # 永遠跑不到,log 零輸出
錯誤被丟進 /dev/null,退出又被 set -e 吞掉,連「失敗了」三個字都留不下來。
2. 告警發送點在失敗點之後。 runner 腳本會在失敗時發 Telegram 告警——但 prompt 檔檢查在「載入告警設定」之前就 exit 1。失敗路徑繞過了告警系統自己。
3. 長時間呼叫被睡眠截殺,不留屍體。 LLM API 呼叫跑到一半機器睡著,進程被凍結或截殺,log 停在「calling…」沒有下文。exit status 在重開機後歸零,事後看起來像「上次跑成功了」。
還有一個共通錯覺:exit code 0 不等於有輸出。健康檢查如果只看退出碼,上面三種死法全部檢測不到。
有用的三件事
主動全量盤點,不要等炸
前三發都是被動發現的,每發平均潛伏 3 到 5 天。第 4 到 7 發是一次主動盤點挖出來的:對全部 31 個 launchd job,逐一檢查腳本存在性、interpreter 解析結果、pip import、CLI binary、然後——重點——驗證每個 job 真的有輸出(log 新鮮度、產出檔 mtime),不是只看 exit code。
一天挖出 6 個 BROKEN,其中 4 個完全靜默。這個投報率說明盤點該是遷移驗收的一部分,不是出事後的補救。
fail-informative,不只 fail-loud
fail-loud(失敗要大聲)我們早就做了,但這次學到它不夠:告警只會說「失敗了」,不會說「為什麼」。斷網 27 小時的真因被 2>/dev/null 吞光,事後永遠無法回溯。
修法很便宜:失敗時把 stderr 和錯誤內容落到固定路徑的檔案,告警訊息帶上真因的最後一行。
if ! OUT=$(some_command 2>/tmp/job-name.err); then
tail -1 /tmp/job-name.err >> "$CRON_LOG" # 真因進 log
alert "job 失敗: $(tail -1 /tmp/job-name.err)" # 告警帶真因
exit 1
fi
驗行為,不是驗設定存在
「plist 掛上了」「PATH 有那個目錄」「設定檔在」都不是驗收。驗收是:這個 job 在真實的 launchd 環境下跑一輪,產出它該產出的東西。
互動 shell 測過不算數——launchd 的 PATH 是另一個世界,bash -lc 還會經過 path_helper 把 /usr/bin 排到 homebrew 前面,同一支腳本手動跑用 python 3.14、launchd 跑變 python 3.9,PEP 604 語法直接炸。這類「手測過、上線掛」的案例這次又收集了兩個。
帶走的 checklist
遷移驗收時,對每個排程 job 問四個問題:
- 依賴:它的 interpreter、pip import、CLI binary 在「launchd 的 PATH」下逐一驗過了嗎?(不是你 shell 的 PATH)
- hand-carry:它需要的機密、狀態檔、憑證,有沒有列在一張明確的 hand-carry 清單上?
- 行為:機器層設定(sleep、identity、網路)有沒有逐項比對過舊機?
- 輸出:它上線後第一次真實觸發,有沒有人確認過它產出了該產出的東西?
以及一個心態:自動化清單永遠列不完(第 8 發就是證據),所以驗收的重點不是「清單有沒有勾完」,而是「靜默失效能不能被看見」。fail-informative 的護欄裝好,漏掉的依賴會自己來報到。
相關工具備忘:debug 無頭 TUI session(launchd 跑的互動程式)時,tmux new-session 起一個同參數的 session,用 send-keys 和 capture-pane 讀它的面板狀態,比瞎猜 log 快得多。這次 Telegram bridge 的根因就是這樣看到的。
