Skip to content

Cron:定時任務

源码版本v2026.6.11

職責

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?三個理由:

  1. session 歸屬:cron 任務的輸出不是簡單「跑一段 shell」,而是要進入某個 agent session 的情境,帶著記憶、工具策略、auth profile。系統 cron 不認識 session,而 CronService 直接持有 resolveSessionStorePathdefaultAgentId,可以在 schedule 觸發時按 sessionTarget 選合適的 session store 路徑。
  2. catch-up 與 in-process restart:gateway 重啟時 CronService.start() 要做兩件事——把上一次沒跑完的 missed jobs 補上,並且把正在跑的 active job 標記成「上一代」以便新 generation 接管(getCronActiveJobState:22-56)。系統 cron 沒有 generation 概念,做不到「重啟時讓舊 run 自然失效但保留狀態供診斷」。
  3. 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-153MAX_TIMER_DELAY_MS=60_000MIN_REFIRE_GAP_MS=2_000、startup catch-up 常數。
  • executeJobCoreWithTimeout:162-220AbortController + 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 和當前時間,回傳下一次應該觸發的絕對毫秒時間戳。三類的語意不同:

typescript
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:

typescript
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。main session 的 job 會 preserveAcrossGenerationAdvance: true,因為主工作階段的 run 應該跨 generation 繼續而不是被打斷。
  • operator cancellation vs timeout:兩條獨立的打斷路徑。operator cancellation 走 abortActiveCronTaskRuns(abortActiveCronTaskRuns:50),timeout 走 setTimeout race。兩者都透過同一個 runAbortController.abort(reason) 終止 core 執行,差異在回傳的 status:cancelled vs timed_out
  • session key canonicalize:resolveCronAgentSessionKeyagent: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:lockedstorePath 序列化(locked:13),state.opstoreLocks.get(storePath) 同時加入 Promise.all chain,保證 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