事件鏈:一台筆電關機換來整個交易日的監控空缺
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.log、bot_20260527.log、bot_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。