Cron:定時任務
職責
CronService(CronService:15-83) 是 OpenClaw 的內建排程 (scheduling) 服務:讀 cron 表、計算下一次觸發時間、按計畫喚醒 agent、把結果投遞回通道,以及把所有狀態持久化 (persistence) 到 SQLite。它和外部 cron 背景服務無關——CronService 自己跑在 gateway 進程裡,靠一個 NodeJS.Timeout 定時器和 croner 運算式驅動,不依賴系統 cron。
支援的排程形態有三類(computeNextRunAtMs:55-119):at(單次絕對時間)、every(固定間隔 + anchor 錨點)、cron(標準 5 段運算式 + 可選 tz/staggerMs)。每個 job 還附帶 sessionTarget(CronSessionTarget:21),決定它跑到哪個 session 上:main(共享主工作階段)、isolated(每次拉一個獨立 (isolated) session)、current(當前工作階段)、session:xxx(命名工作階段)。
設計動機
為什麼不直接用系統 cron / systemd timer?三個理由:
- session 歸屬:cron 任務的輸出不是簡單「跑一段 shell」,而是要進入某個 agent session 的情境,帶著記憶、工具策略、auth profile。系統 cron 不認識 session,而
CronService直接持有resolveSessionStorePath和defaultAgentId,可以在 schedule 觸發時按 sessionTarget 選合適的 session store 路徑。 - catch-up 與 in-process restart:gateway 重啟時
CronService.start()要做兩件事——把上一次沒跑完的 missed jobs 補上,並且把正在跑的 active job 標記成「上一代」以便新 generation 接管(getCronActiveJobState:22-56)。系統 cron 沒有 generation 概念,做不到「重啟時讓舊 run 自然失效但保留狀態供診斷」。 - wake 協同:很多 main-session 任務不希望冷啟動直接撞上 user 在前端的活躍工作階段,wakeMode 設成
next-heartbeat就能等下一次心跳 (heartbeat) 才觸發(CronWakeMode:23)。這是 agent 系統特有的語意,外部 cron 無法表達。
另一個關鍵設計是LRU 快取 cron 運算式(resolveCachedCron:10-41):croner 解析 5 段運算式是相對貴的操作,但 cron job 可以被頻繁增刪改,所以快取上限 CRON_EVAL_CACHE_MAX = 512,LRU 逐出最舊條目,保證記憶體可控的同時讓熱運算式命中。
關鍵檔案
CronService facade:15-83— 有狀態的服務門面,持有CronServiceState,所有操作轉給service/ops.js。CronServiceDeps:62-184— 依賴注入面:nowMs/log/storePath/cronEnabled/defaultAgentId/runIsolatedAgentJob/runCommandJob/requestHeartbeat/sendCronFailureAlert等。ops.ts:42-92— 公共 CRUD/list/manual run 操作,轉給locked序列化後呼叫timer.ts。locked:13-25— 按storePath序列化所有寫操作,保留 state-local ordering。timer.ts 頂部:94-153—MAX_TIMER_DELAY_MS=60_000、MIN_REFIRE_GAP_MS=2_000、startup catch-up 常數。executeJobCoreWithTimeout:162-220—AbortController+operatorCancellationPromise+ 可選 wall-clock 逾時,核心執行器。computeNextRunAtMs:55-119— 三類排程(at/every/cron)的下一次觸發時間計算,含 croner 年份回退 workaround。CronActiveJobMarker:14-56— 進程級 active job 表,symbol-keyed globalThis singleton,跨模組 reload 共享。cron store:35-79— SQLite-backed 持久化,loadCronJobsStoreWithConfigJobs在啟動時一次性把 jobs 載入到記憶體。isolated-agent run.ts:1-170— 隔離 (isolated) agent 的一次 turn 的編排:session 解析、模型選擇、auth profile、preflight、執行、delivery、清理。resolveCronAgentSessionKey:7-26— canonicalize main key 別名,避免agent:xxx:main與設定的mainKey不匹配導致 session 被孤立。cron-task-cancel:12-77— 進程級 active cron task run 的取消控制代碼和 settlement grace。
資料流
排程循環的核心是 computeNextRunAtMs(computeNextRunAtMs:55):它接受 CronSchedule 和當前時間,回傳下一次應該觸發的絕對毫秒時間戳。三類的語意不同:
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
if (schedule.kind === "at") {
const atMs = parseAbsoluteTimeMs(schedule.at);
if (atMs === null) return undefined;
return atMs > nowMs ? atMs : undefined; // 過去時間不觸發
}
if (schedule.kind === "every") {
const everyMs = Math.max(1, Math.floor(everyMsRaw));
const anchor = Math.max(0, Math.floor(anchorMs ?? nowMs));
if (nowMs < anchor) return anchor;
const elapsed = nowMs - anchor;
const steps = Math.floor(elapsed / everyMs) + 1;
return anchor + steps * everyMs; // 按 anchor 對齊,避免 drift
}
// cron 運算式走 croner,帶 LRU 快取
const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz));
const next = cron.nextRun(new Date(nowMs));
// ... 含 year-rollback workaround(見下文)
}every 排程的 anchor 設計是關鍵:不寫 anchor 就用 nowMs 當錨,任務第一次建立後立即觸發一次,之後每 everyMs 觸發;寫了 anchor 就按 anchor 對齊,即使進程中途重啟也能算出「本應在 t1 觸發,現在已過 t2,下一次應該是 t3」,配合 catch-up 不會重複觸發。
執行器 executeJobCoreWithTimeout(executeJobCoreWithTimeout:162)同時掛三個 race:
export async function executeJobCoreWithTimeout(state, job, opts) {
const runAbortController = new AbortController();
const operatorCancellationMarker = Symbol("cron-operator-cancelled");
// ... registerActiveCronTaskRun 把 controller 註冊到進程級表裡,
// gateway 重啟時可以一次性 abort 所有 active run
if (typeof jobTimeoutMs !== "number") {
const corePromise = executeJobCore(state, job, runAbortController.signal);
trackActiveCronTaskRunSettlement(corePromise);
const first = await Promise.race([corePromise, operatorCancellationPromise]);
// ... operator 取消回傳 cancelled outcome,否則回傳 core 結果
}
// 有 timeout 時再加一個 timeoutPromise,三者 race
}operatorCancellationPromise 是個永不自行 resolve 的 Promise,只有外部呼叫 registerActiveCronTaskRun 註冊的 onCancel 觸發 resolveOperatorCancellation(marker) 才會 settle——這是「gateway 重啟時打斷活躍 cron run」的統一通道。
啟動 catch-up 把 missed jobs 分成兩批(catch-up 常數:106-108):立即觸發最多 DEFAULT_MAX_MISSED_JOBS_PER_RESTART=5 個,其餘按 DEFAULT_MISSED_JOB_STAGGER_MS=5_000 ms 錯峰;其中需要拉 agent 的(非 command-only)missed job 會再延遲 DEFAULT_STARTUP_DEFERRED_MISSED_AGENT_JOB_DELAY_MS=2*60_000 ms,避免在 channel connect 視窗擠占模型/工具 bootstrap 資源。
邊界與失敗
- LRU 快取邊界:
cronEvalCache上限 512(CRON_EVAL_CACHE_MAX:10),命中時刪除再 set 維持 LRU 順序。運算式增刪改後舊條目自然逐出,不會讀到過期 Cron 物件。 - croner year-rollback bug(
year-rollback workaround:93-116):某些時區(如 Asia/Shanghai)下nextRun會回傳過去的年份。程式碼先按nowMs算,如果結果<= nowMs,先按「下一秒」重試,再按「UTC 明天 0 點」重試,兩次都失敗才回傳undefined。 - MIN_REFIRE_GAP_MS = 2_000(
MIN_REFIRE_GAP_MS:104):同一個 job 兩次觸發之間至少 2 秒,防止computeJobNextRunAtMs回傳同秒時間戳導致 spin-loop(#17821)。 - active job marker generation:
markCronJobActive會帶上當前generation;gateway 重啟時state.generation++,所有上一代 marker 失效(isCronActiveJobMarkerCurrent回傳 false),executeJobCoreWithTimeout入口檢查 marker 不匹配就立即abort("Gateway restarting.")回傳 cancelled outcome。mainsession 的 job 會preserveAcrossGenerationAdvance: true,因為主工作階段的 run 應該跨 generation 繼續而不是被打斷。 - operator cancellation vs timeout:兩條獨立的打斷路徑。operator cancellation 走
abortActiveCronTaskRuns(abortActiveCronTaskRuns:50),timeout 走setTimeoutrace。兩者都透過同一個runAbortController.abort(reason)終止 core 執行,差異在回傳的status:cancelledvstimed_out。 - session key canonicalize:
resolveCronAgentSessionKey把agent:xxx:main重寫成agent:xxx:<configuredMainKey>,否則當cfg.session.mainKey !== "main"時 cron 寫入的 session 會和讀路徑的 key 不一致,session 被「孤立」讀不到(#29683)。 - startup catch-up 錯峰:missed jobs 不一次性全跑,避免 gateway 剛啟動就被一窩 agent run 撞崩;agent 類 missed job 還額外延後 2 分鐘,把模型/工具 bootstrap 讓位給 channel 連線視窗。
- store lock:
locked按storePath序列化(locked:13),state.op和storeLocks.get(storePath)同時加入Promise.allchain,保證 state-local ordering(同一 state 上的操作按呼叫順序排隊)和 cross-state 並發(不同 storePath 可以並發)。
小結
CronService 把排程、執行、持久化、投遞四件事壓在一個 facade 裡,但具體實作按層拆開:schedule.ts 只算時間、active-jobs.ts 只管進程級活躍表、timer.ts 負責執行+逾時+取消、isolated-agent/ 負責拉起一次隔離 agent turn、store.ts 負責 SQLite 持久化。這套分層讓 cron 不再是「定時跑指令碼」,而是「按計畫喚醒 agent + 把結果投遞回通道」——這是 Tasks:持久化任務 的上游,實際觸發 agent 的機制看 Agent 主循環,設定項(逾時/重試/missedJobStagger)見 openclaw.json。