OpenClaw Cron 调度器源码深度解析:9,640 行代码如何让 AI 代理自主行动
> 源码: github.com/openclaw/openclaw (src/cron/)
> 代码规模: ~9,640 行 TypeScript(不含测试),~30 个源文件
> 研究方式: 直接 clone 源码逐文件阅读
> 研究时间: 2026-03-28
🎯 一句话版本
OpenClaw 的 Cron 调度器是一个完全运行在 Node.js 单进程内的任务系统——不依赖系统 crontab,用 setTimeout 驱动,JSON 文件持久化。它能定时让 AI 代理自己醒来执行任务、自动重试失败、错峰避免过载,还能把执行结果推送到 Discord/Telegram 等任何渠道。
🏗️ 整体架构
┌──────────────────────────────────────────────────────┐
│ CronService(单例) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ schedule │ │ store │ │ timer (armTimer) │ │
│ │ 计算下次 │ │ jobs.json│ │ setTimeout 驱动 │ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ executeJobCore │ │
│ │ ├── main → enqueueSystemEvent + heartbeat │ │
│ │ └── isolated → runCronIsolatedAgentTurn │ │
│ └───────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ delivery │ │ run-log (JSONL) │ │
│ │ 结果投递 │ │ 执行历史 │ │
│ └──────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────┘
关键设计选择
| 选择 | 方案 | 理由 |
|---|---|---|
| 调度引擎 | Node.js setTimeout | 不依赖外部服务,Gateway 进程即调度器 |
| 持久化 | JSON 文件(jobs.json) | 无需数据库,自托管友好 |
| 并发控制 | locked() 串行化 | 避免竞态,简单可靠 |
| Cron 表达式解析 | croner 库 | 支持时区,轻量级 |
| 执行引擎 | 复用 Agent 运行时 | 不需要另建执行层 |
🤖 LLM 如何调用 Cron?
Cron 以 tool(函数调用)的形式暴露给 LLM,和搜索、发消息等工具一样。LLM 看到的是一个叫 cron 的工具,支持以下 action:
| Action | 说明 |
|---|---|
| `status` | 查看调度器状态 |
| `list` | 列出所有任务 |
| `add` | 创建任务 |
| `update` | 修改任务 |
| `remove` | 删除任务 |
| `run` | 立即触发一次 |
| `runs` | 查看历史执行记录 |
| `wake` | 发送唤醒事件 |
完整调用链示例
以"提醒我明天早上 9 点开会"为例:
用户:"提醒我明天早上 9 点开会"
↓
LLM 理解意图,生成 tool call:
cron(action="add", job={
name: "开会提醒",
schedule: { kind: "at", at: "2026-03-29T09:00:00+08:00" },
payload: { kind: "systemEvent", text: "提醒:你有一个会议" },
sessionTarget: "main"
})
↓
OpenClaw Gateway 收到 tool call
→ CronService.add() 创建 job
→ 写入 jobs.json
→ armTimer() 设置 setTimeout
↓
(中间等待——不消耗任何 LLM token)
↓
时间到
→ onTimer() 触发
→ enqueueSystemEvent("提醒:你有一个会议")
→ requestHeartbeatNow()
↓
LLM 在心跳时收到这条系统事件
→ 回复用户:"你有一个会议要开了"
→ 通过 Discord/Telegram 发给用户
关键设计
LLM 本身不需要一直运行——它只在两个时刻被调用:
1. 创建任务时(处理用户请求,生成 tool call)
2. 任务触发时(处理定时事件,生成回复)
中间的等待完全由 Gateway 进程的 setTimeout 处理,零 token 消耗。这是一个优雅的设计——把"定时"这个确定性任务交给传统编程,把"理解意图"和"生成回复"交给 LLM。
两种执行路径:心跳 vs 独立
任务触发时,根据 sessionTarget 有两条完全不同的路径:
| Main(心跳模式) | Isolated(独立模式) | |
|---|---|---|
| 触发方式 | 注入 systemEvent → 心跳唤醒 LLM | 直接启动独立 Agent 会话 |
| 是否走心跳 | ✅ 是 | ❌ 否,直接执行 |
| 有对话上下文 | ✅ 是,LLM 能看到之前的对话历史 | ❌ 每次 forceNew,全新 session |
| 能读 workspace | ✅ | ✅(MEMORY.md、SOUL.md 等文件仍可读) |
| payload 类型 | 必须 `systemEvent` | 必须 `agentTurn` |
| 适合场景 | 提醒、跟进讨论、需要上下文的任务 | 后台数据处理、定时报告、独立任务 |
// 源码中的关键判断
if (job.sessionTarget === "main") {
// 注入事件 + 触发心跳(在主对话上下文中执行)
state.deps.enqueueSystemEvent(text);
state.deps.requestHeartbeatNow();
} else {
// 直接启动独立 Agent 执行(全新 session,无对话历史)
await state.deps.runIsolatedAgentJob({ job, message });
}
心跳模式的优势:LLM 醒来时能看到"你是谁、之前聊了什么、你的偏好"——所以提醒类任务的回复更自然、更个性化。
独立模式的优势:不受主会话上下文长度限制,可以用不同的模型(payload 里可指定 model),执行完直接投递结果到任意渠道。
走哪条路径?创建时就决定了
完全由创建 job 时的 sessionTarget 参数决定,创建后就固定(可用 update 修改,但一般不会):
// 走心跳(主会话,有上下文)
cron(action="add", job={
sessionTarget: "main",
payload: { kind: "systemEvent", text: "提醒:你有个会议" }
})
// 不走心跳(独立执行,无上下文)
cron(action="add", job={
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "生成今日报告" }
})
而且 sessionTarget 和 payload.kind 是强绑定的——源码里有硬校验,选错直接报错:
// main 只能用 systemEvent,isolated 只能用 agentTurn
if (sessionTarget === "main" && payload.kind !== "systemEvent")
throw new Error('main cron jobs require payload.kind="systemEvent"');
if (sessionTarget === "isolated" && payload.kind !== "agentTurn")
throw new Error('isolated cron jobs require payload.kind="agentTurn"');
这个设计防止了误用:你不会不小心在主会话里跑一个独立 Agent turn,也不会在独立模式里注入一条没人处理的系统事件。
📅 三种调度类型
1. `at`(一次性任务)
{ kind: "at", at: "2026-03-28T10:00:00+08:00" }
执行完成后自动禁用(deleteAfterRun: true 时直接删除)。失败后支持瞬态错误重试(最多 3 次,带退避)。
2. `every`(固定间隔)
{ kind: "every", everyMs: 3600000, anchorMs: 1711612800000 }
基于锚点时间 + 间隔计算下次执行。anchorMs 默认为任务创建时间,保证间隔从创建时刻开始。
3. `cron`(Cron 表达式)
{ kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai", staggerMs: 5000 }
标准 cron 表达式 + 时区 + 确定性错峰(stagger)。
⏱️ Timer 机制详解
这是整个调度器的心脏——armTimer() 函数:
function armTimer(state) {
// 找到最近一个需要执行的 job 的 nextRunAtMs
const nextAt = nextWakeAtMs(state);
// 计算延迟
const delay = Math.max(nextAt - now, 0);
// 防止零延迟死循环(MIN_REFIRE_GAP_MS = 2s)
const flooredDelay = delay === 0 ? MIN_REFIRE_GAP_MS : delay;
// 最长 60 秒唤醒一次(防止时钟漂移)
const clampedDelay = Math.min(flooredDelay, MAX_TIMER_DELAY_MS);
state.timer = setTimeout(() => onTimer(state), clampedDelay);
}
关键细节:
1. 每次只设一个 timer——到期后执行所有到期任务,然后重新计算下一个唤醒时间
2. 最大 60 秒间隔——即使下一个任务在 1 小时后,也每分钟检查一次(防止进程挂起后时钟跳跃)
3. 最小 2 秒间隔——防止 croner 库的时区 bug 导致无限循环(issue #17821)
4. 任务执行期间继续 recheck——长任务不会阻塞调度器
🔄 执行流程
Main Session Target
Job 到期
→ enqueueSystemEvent(text) // 注入文本到主会话
→ requestHeartbeatNow() // 触发心跳
→ Agent 在下一次心跳时处理 // 就像收到一条消息
适合:提醒、定时通知、需要在主对话上下文中执行的任务。
Isolated Session Target
Job 到期
→ resolveCronSession() // 创建/复用独立 session
→ resolveCronModelSelection() // 选择模型(支持 fallback)
→ runEmbeddedPiAgent() // 启动独立 Agent 执行
→ dispatchCronDelivery() // 投递结果
适合:后台数据处理、定时报告、webhook 触发的任务。
执行 + 投递流水线(Isolated)
// 1. 解析投递目标
const { resolvedDelivery } = await resolveCronDeliveryContext({ cfg, job, agentId });
// 2. 构建 prompt(外部来源自动加安全边界)
commandBody = shouldWrapExternal
? buildSafeExternalPrompt({ content: message, source: hookType })
: `${base}\n${timeLine}`;
// 3. 执行 Agent(支持 model fallback)
await runWithModelFallback({
run: async (provider, model) => await runEmbeddedPiAgent({ ... })
});
// 4. 检查是否只是"收到了"(interim ack 检测)
if (isLikelyInterimCronMessage(outputText)) {
await runPrompt("Complete the original task now."); // 自动续跑
}
// 5. 投递结果
await dispatchCronDelivery({ ... });
亮点:如果 Agent 只回了"收到,我来处理"这种话,调度器会自动追加一轮让它真正完成任务。
🛡️ 容错机制
指数退避
const BACKOFF_SCHEDULE_MS = [
30_000, // 第 1 次失败 → 30 秒
60_000, // 第 2 次 → 1 分钟
5 * 60_000, // 第 3 次 → 5 分钟
15 * 60_000,// 第 4 次 → 15 分钟
60 * 60_000 // 第 5 次+ → 60 分钟(封顶)
];
瞬态错误检测
const TRANSIENT_PATTERNS = {
rate_limit: /(rate limit|too many requests|429|resource exhausted)/i,
overloaded: /\b529\b|overloaded|high demand|capacity exceeded/i,
network: /(network|econnreset|econnrefused|fetch failed|socket)/i,
timeout: /(timeout|etimedout)/i,
server_error: /\b5\d{2}\b/,
};
一次性任务(at)默认重试 3 次瞬态错误。重复任务用退避保护。
卡死检测
const STUCK_RUN_MS = 2 * 60 * 60 * 1000; // 2 小时
// 如果一个 job 标记为 running 超过 2 小时,自动清除
if (nowMs - runningAtMs > STUCK_RUN_MS) {
job.state.runningAtMs = undefined;
}
重启后补执行
// 重启时检查哪些任务错过了执行
const missed = collectRunnableJobs(state, now, {
allowCronMissedRunByLastRun: true // 比较 lastRunAtMs 和 previousRunAtMs
});
// 最多立即执行 5 个,其余错峰
const immediate = sorted.slice(0, maxImmediate); // 立即执行
const deferred = sorted.slice(maxImmediate); // 每 5 秒一个
调度错误自动禁用
const MAX_SCHEDULE_ERRORS = 3;
// cron 表达式解析连续失败 3 次 → 自动禁用 + 通知用户
失败告警
// 连续失败 N 次后通知(默认 2 次)
const DEFAULT_FAILURE_ALERT_AFTER = 2;
const DEFAULT_FAILURE_ALERT_COOLDOWN_MS = 60 * 60_000; // 1 小时冷却
// 支持通知到不同渠道
failureAlert: {
after: 3,
channel: "telegram",
to: "-1001234567890",
cooldownMs: 3600000
}
💾 持久化层
原子写入
// 1. 写入临时文件
const tmp = `${storePath}.${process.pid}.${randomBytes(8)}.tmp`;
await writeFile(tmp, json, { mode: 0o600 });
// 2. 备份(仅在非运行时字段变化时)
if (previous !== null && !skipBackup) {
await copyFile(storePath, `${storePath}.bak`);
}
// 3. 原子替换(rename 带重试)
await renameWithRetry(tmp, storePath);
为什么备份有条件:每次 timer tick 都会更新 state(nextRunAtMs, lastRunAtMs 等运行时字段),如果每次都备份,磁盘 IO 太重。所以只在"真正的"变化(schedule/payload/enabled 等)时才创建 .bak。
运行日志
~/.openclaw/cron/runs/{jobId}.jsonl
每行一个 JSON 对象:
{"ts":1711612800000,"jobId":"abc","action":"finished","status":"ok","summary":"...","durationMs":1234}
自动裁剪:文件超过 2MB 时保留最新 2000 行。
🔀 Stagger(确定性错峰)
同一个 cron 表达式(如 0 )下挂了 10 个 job,不希望它们在同一秒全部触发:
function resolveStableCronOffsetMs(jobId: string, staggerMs: number) {
// SHA-256(jobId) → 取前 4 字节 → mod staggerMs
const digest = crypto.createHash("sha256").update(jobId).digest();
const offset = digest.readUInt32BE(0) % staggerMs;
return offset;
}
确定性:同一个 jobId 的 offset 跨重启不变。
均匀分布:SHA-256 保证不同 jobId 均匀分散。
🔐 安全机制
外部内容安全边界
来自 webhook/Gmail 等外部来源的内容会被自动包裹:
if (shouldWrapExternal) {
commandBody = buildSafeExternalPrompt({
content: message,
source: hookType, // "gmail" | "webhook" | ...
jobName: job.name,
jobId: job.id,
timestamp: formattedTime
});
}
这防止外部恶意邮件/webhook 通过 prompt injection 操控 Agent。
可疑内容检测
const suspiciousPatterns = detectSuspiciousPatterns(params.message);
if (suspiciousPatterns.length > 0) {
logWarn(`[security] Suspicious patterns detected...`);
}
文件权限
- 所有配置文件:
0o600(仅 owner 读写) - 所有目录:
0o700(仅 owner 访问)
📊 完整数据流
用户创建 Job(cron tool / API)
↓
CronService.add()
→ createJob(): 生成 UUID, 计算 nextRunAtMs, 校验规则
→ persist(): 原子写入 jobs.json
→ armTimer(): 设置 setTimeout
↓
setTimeout 到期
↓
onTimer()
→ locked(): 获取锁
→ ensureLoaded(forceReload): 重新从磁盘读取(防止多进程冲突)
→ collectRunnableJobs(): 找到所有到期任务
→ 标记 runningAtMs + persist()
→ 释放锁
↓
executeJobCoreWithTimeout()
→ [main] enqueueSystemEvent + heartbeat
→ [isolated] runCronIsolatedAgentTurn()
→ resolveCronSession()
→ resolveCronModelSelection()
→ runWithModelFallback()
→ runEmbeddedPiAgent() / runCliAgent()
→ dispatchCronDelivery()
↓
locked(): 再次获取锁
→ ensureLoaded(forceReload): 重新读取(执行期间可能有其他写入)
→ applyJobResult(): 更新状态(连续错误/退避/下次执行时间)
→ appendCronRunLog(): 追加 JSONL 日志
→ persist()
→ armTimer(): 重新设置下一次唤醒
💡 与我们的关联
1. 我们正在用它
OpenClaw 的 cron 就是我们日常使用的调度系统——提醒、定时检查、后台任务都靠它。理解源码意味着:
- 知道为什么有时候任务延迟执行(最大 60 秒检查间隔)
- 知道失败后的退避策略(30s → 1m → 5m → 15m → 1h)
- 知道重启后漏掉的任务会被补执行(最多 5 个立即执行)
2. 设计哲学值得学习
零外部依赖:不需要 Redis、不需要 PostgreSQL、不需要 RabbitMQ——一个 JSON 文件搞定。这是"自托管友好"的极致体现。
防御性编程:看看代码里有多少 edge case 处理——croner 的时区 bug、Windows 的 EPERM、进程挂起的时钟跳跃、同一秒重复触发……每一个都来自真实的 bug report(代码注释里有 issue 编号)。
安全意识:外部内容自动包裹安全边界、文件权限 600、可疑模式检测——这在开源项目里不常见。
3. 可以改进的方向
| 方向 | 现状 | 可改进 |
|---|---|---|
| 分布式 | 单进程单文件 | 多 Gateway 场景需要分布式锁 |
| 监控 | JSONL 日志 | 接入 Prometheus/Grafana |
| Web UI | 仅 CLI/API | 可视化管理界面 |
| 持久化 | JSON 文件 | SQLite 更可靠(但可能违背零依赖原则) |
⚠️ 值得注意的限制
| 限制 | 说明 |
|---|---|
| 单进程 | 不支持多 Gateway 实例分布式调度 |
| 文件锁 | 用内存锁(locked()),不是系统 flock |
| 精度 | 最大 60 秒检查间隔,不适合秒级精度需求 |
| 内存 | 所有 jobs 加载到内存,超大量任务可能有问题 |
| 无 DAG | 不支持任务依赖图(A 完成后执行 B) |
📊 评分
| 维度 | 评分(/10) |
|---|---|
| 代码质量 | 9.0 — 防御性编程典范,大量 edge case 处理 |
| 架构设计 | 8.5 — 零依赖 + 自托管友好,trade-off 合理 |
| 容错能力 | 9.0 — 退避/重试/补执行/卡死检测/失败告警 |
| 安全性 | 8.5 — 外部内容安全边界 + 文件权限 + 可疑模式检测 |
| 可扩展性 | 7.0 — 单进程限制,不支持分布式 |
| 文档/可读性 | 8.0 — 代码注释丰富(带 issue 编号),但无独立文档 |
| **综合** | **8.5** |
报告由深度研究助手自动生成 | 2026-03-28
来源: 直接源码阅读 github.com/openclaw/openclaw