事件鏈:一台筆電關機換來整個交易日的監控空缺

5/28 14:30 正常停止後,Mac 直接關機。5/29 09:23 開機, sysctl kern.boottime 顯示開機時間比 8:00 的 launchd 排程晚。 macOS launchd 的 StartCalendarInterval 不會補跑錯過的觸發—— 這跟 Linux 上的 anacron 不同。排程不是失敗、是從來沒觸發。 整個交易日,Streamlit 沒跑,監看靜默,沒有任何地方留下「今天沒啟動」的紀錄。

補法很直接:在 menubar app 的 __init__ 末尾加一個 _maybe_catch_up_start()——menubar 設定了 KeepAlive=true + RunAtLoad=true, 是開機後第一個活著的觸發點。只要 menubar 活著,它就能代替錯過的排程補跑。 決策邏輯抽成純函式 _decide_catch_up(now, is_running),方便 unit test。

launchd_stop.log 永遠 0 bytes,不是 bug

排查時發現 launchd_stop.log 一直是 0 bytes,直覺反應是「服務沒跑到」。 花了時間才意識到,這是 stop_bot.sh 設計使然:所有 echo 都 redirect 到 bot_YYYYMMDD.log,零 stdout,launchd 自然收不到任何輸出、log 檔永遠空。

但進一步查 bot_20260526.logbot_20260527.logbot_20260528.log, 連續三天 14:30 都有觸發紀錄——launchd stop 機制完全健康,問題在診斷工具,不在排程本身。 修法:在 stop_bot.sh 頭尾各加一行 stdout echo(「invoked」/「exited」), 未來 launchd_stop.log 就會留下排程觸發證據,不再是 0 bytes 誤導視線。

教訓:判斷 launchd 服務是否觸發,先看腳本有沒有輸出到 stdout,再看 log 大小。 空 log 不等於服務沒跑,可能只是腳本不輸出到 stdout。

比「沒跑」更危險:跑了但什麼都沒做

更隱蔽的問題藏在 Streamlit 的 fragments.py 第 88-91 行:

cfg = st.session_state.get(f"grid_cfg_{iid}")
if not cfg:
    st.info("尚未佈署網格...")
    return

session_state 是 process-lifetime。任何 process restart——menubar 補跑、 Streamlit crash、手動重啟——都會清空它。cfg 不存在 → fragment 第 3 行就 return → 下面的事件 drain 跳過 → 自動補格完全停止。後端委託還在,本地監看消失, 兩邊靜默地不同步。

這比「Streamlit 沒跑」還糟:Streamlit 活著、UI 正常, 只是什麼事都不做,而且看不出來。

cfg fallback:從持久化紀錄重建狀態

治本的方法是讓 fragment 在 cfg 不存在時,從持久化來源重建,而不是直接 return。

關鍵觀察:grid_anchor_* 已經是 file-backed JSON(user_settings.json), 包含 cfg 需要的所有欄位(interval / mode / shares_per_grid / min_reserve)。 唯一缺的 deploy_id 在資料庫的最新 PENDING row 查得到。 零 schema 改動,零新檔,純組合既有資源。

純函式 _build_cfg_from_anchor(anchor, deploy_id) 邊界條件清楚: anchor 缺 → None、deploy_id 缺 → None、interval 損壞 → None(寧可 fail-safe 不誤建)。 mode / shares_per_grid / min_reserve 缺則走 default。 這個 fallback 讓 process restart 後,fragment 自動恢復追蹤, 而不需要人工 Cancel+Deploy。

關鍵教訓

launchd StartCalendarInterval 不補跑:筆電關機再開,錯過的觸發就是錯過了。給筆電設計的 daily schedule 必須有 fallback 觸發點(開機時檢查 / 事後乾跑 / 兩個觸發點疊加)。

空 log 不等於服務沒跑:先看腳本有沒有 stdout 輸出,再下結論。診斷工具不正確等於排查一開始就走錯方向。

session_state 不是資料庫:任何重要狀態放 session_state,等於每次 process restart 就會消失。重要狀態應放 file 或 db,並寫 fallback 邏輯從持久化來源重建。

比「沒跑」更危險的是「跑了但靜默失效」:UI 正常、服務活著、但關鍵邏輯提早 return——這種靜默失效比崩潰更難察覺,也更難修復。設計時要思考 restart 後狀態如何回來。

零 schema 改動的 fallback:治本不一定需要新欄位或新架構。先盤點現有持久化資源,往往可以零改動地組合出 fallback。

來源:個人開發日誌 2026-06-03 · stock_grid_bot · 2 commits(acd02b5, d101e14)· 32/32 tests PASS