Cron:定期タスク
責務
CronService(CronService:15-83) は OpenClaw の内蔵スケジューリング (scheduling) サービスです:cron 表を読み、次回トリガー時間を計算し、計画に従って agent を起こし、結果をチャネルに投递し,すべての状態を SQLite に永続化 (persistence) します。外部 cron デーモンとは無関係です——CronService 自身が gateway プロセス内で走り,1 つの NodeJS.Timeout タイマーと croner 式で駆動され,システム cron に依存しません。
サポートするスケジューリング形態は 3 種(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 をそのまま使わないのか?3 つの理由:
- session 帰属:cron タスクの出力は単純に「シェルを一段走らせる」のではなく,ある agent session のコンテキストに入り,記憶、ツールポリシー、auth profile を伴います。システム cron は session を知りませんが,
CronServiceはresolveSessionStorePathとdefaultAgentIdを直接持ち,schedule トリガー時に sessionTarget で適切な session store パスを選べます。 - catch-up と in-process restart:gateway 再起動時に
CronService.start()は 2 つをやります——前回未完了の missed jobs を補完し,実行中の active job を「前世代」とマークして新 generation が引き継げるように(getCronActiveJobState:22-56)。システム cron には generation 概念がなく,「再起動時に旧 run を自然に失効させつつ状態は診断に残す」ができません。 - wake 協調:多くの main-session タスクはコールドスタートでフロントエンドのアクティブ session に衝突したくありません。
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— 状態保持のサービス facade,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 timeout,核心実行器。computeNextRunAtMs:55-119— 3 種スケジューリング(at/every/cron)の次回トリガー時間計算,croner 年ロールバック workaround 含む。CronActiveJobMarker:14-56— プロセスレベル active job 表,symbol-keyed globalThis singleton,モジュール再読込跨ぎ共有。cron store:35-79— SQLite-backed 永続化,loadCronJobsStoreWithConfigJobsが起動時に jobs をメモリに一括ロード。isolated-agent run.ts:1-170— 隔離 (isolated) agent の 1 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 と現在時刻を受け取り,次にトリガーすべき絶対ミリ秒タイムスタンプを返します。3 種のセマンティクスは異なります:
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)は同時に 3 つの race を掛けます:
export async function executeJobCoreWithTimeout(state, job, opts) {
const runAbortController = new AbortController();
const operatorCancellationMarker = Symbol("cron-operator-cancelled");
// ... registerActiveCronTaskRun が controller をプロセスレベル表に登録,
// gateway 再起動時に全 active run を一括 abort 可能
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 を追加,3 者 race
}operatorCancellationPromise は自力では resolve しない Promise で,外部が registerActiveCronTaskRun で登録した onCancel が resolveOperatorCancellation(marker) を呼んだときにだけ settle します——これが「gateway 再起動時に活発な cron run を打断する」統一チャンネルです。
起動 catch-up は missed jobs を 2 批に分け(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 回トリガー間は最低 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:2 つの独立した打断パス。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 分遅らせ,channel 接続ウィンドウにモデル/ツール bootstrap を譲ります。
- store lock:
lockedはstorePathで直列化(locked:13)し,state.opとstoreLocks.get(storePath)を同時にPromise.allchain に入れ,state-local ordering(同一 state 上の操作は呼び出し順にキューイング)と cross-state 並行(異なる storePath は並行可)を保証します。
まとめ
CronService はスケジューリング、実行、永続化、投递の 4 件を 1 つの facade に圧縮しますが,具体実装は層ごとに分かれます:schedule.ts は時間計算だけ,active-jobs.ts はプロセスレベル活発表だけ,timer.ts は実行+timeout+キャンセル,isolated-agent/ は隔離 agent 1 turn の立ち上げ,store.ts は SQLite 永続化。この分層で cron は「定期スクリプト実行」ではなく「計画で agent を起こし + 結果をチャネルに投递」になります——これは Tasks:永続化タスク の上流で,実際に agent をトリガーするメカニズムは Agent 主ループ,設定項目(timeout/retry/missedJobStagger)は openclaw.json を参照。