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。