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