Skip to content

Cron:定期タスク

源码版本v2026.6.11

責務

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 つの理由:

  1. session 帰属:cron タスクの出力は単純に「シェルを一段走らせる」のではなく,ある agent session のコンテキストに入り,記憶、ツールポリシー、auth profile を伴います。システム cron は session を知りませんが,CronServiceresolveSessionStorePathdefaultAgentId を直接持ち,schedule トリガー時に sessionTarget で適切な session store パスを選べます。
  2. catch-up と in-process restart:gateway 再起動時に CronService.start() は 2 つをやります——前回未完了の missed jobs を補完し,実行中の active job を「前世代」とマークして新 generation が引き継げるように(getCronActiveJobState:22-56)。システム cron には generation 概念がなく,「再起動時に旧 run を自然に失効させつつ状態は診断に残す」ができません。
  3. wake 協調:多くの main-session タスクはコールドスタートでフロントエンドのアクティブ session に衝突したくありません。wakeModenext-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-25storePath で全書き込み操作を直列化,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 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 種のセマンティクスは異なります:

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)は同時に 3 つの race を掛けます:

typescript
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 で登録した onCancelresolveOperatorCancellation(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 を返します。main session の job は preserveAcrossGenerationAdvance: true で,主セッションの run は generation を跨いで継続すべきで打断されるべきではないからです。
  • operator cancellation vs timeout:2 つの独立した打断パス。operator cancellation は abortActiveCronTaskRuns(abortActiveCronTaskRuns:50)へ,timeout は setTimeout race へ。両者とも同じ runAbortController.abort(reason) で core 実行を終了させますが,返される status が異なります:cancelled vs timed_out
  • session key canonicalize:resolveCronAgentSessionKeyagent:xxx:mainagent: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:lockedstorePath で直列化(locked:13)し,state.opstoreLocks.get(storePath) を同時に Promise.all chain に入れ,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 を参照。