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: "生成今日报告" }
})

而且 sessionTargetpayload.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...`);
}

文件权限

📊 完整数据流


用户创建 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 就是我们日常使用的调度系统——提醒、定时检查、后台任务都靠它。理解源码意味着:

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