跳转至

Cron 与 Heartbeat 的 quiet-hours 交互机制分析

源码版本:openclaw@2026.5.7,分析日期 2026-05-25

结论先行

Setsuna 的说法大方向正确,但有一个关键细节不准确。

Setsuna 说:"cron 触发了(runAtMs 有值、nextRunAtMs 也正常推进了),只是 systemEvent 注入主 session 后,心跳因为处于非活跃时段就直接跳过了处理"。

实际上:cron 本身确实有独立的定时器(setTimeout 循环),但它执行 main-session 任务时会主动调用 runHeartbeatOnce(),而这个调用会被 heartbeat 的 activeHours 门卫拦截。不是"注入后被动跳过",而是"主动调用被拒"。

架构总览

┌─────────────────────────────────────┐
│          Cron Scheduler             │
│  (独立 setTimeout 定时器循环)         │
│  src/cron/service/timer.ts          │
└──────────┬──────────────────────────┘
           │ 触发
┌─────────────────────────────────────┐
│    executeMainSessionCronJob()      │
│  1. enqueueSystemEvent() ← 内存队列  │
│  2. runHeartbeatOnce()   ← 主动调用  │
└──────────┬──────────────────────────┘
           │ 调用
┌─────────────────────────────────────┐
│      Heartbeat Runner               │
│  src/infra/heartbeat-runner.ts      │
│                                     │
│  ① isWithinActiveHours() ← 门卫     │
│     不在活跃时段 → return "skipped"  │
│     在活跃时段 → 继续执行            │
│  ② 消费 systemEvent 队列             │
│  ③ 调用 LLM agent                   │
│  ④ 投递结果                         │
└─────────────────────────────────────┘

详细代码路径

1. Cron 有独立定时器

Cron scheduler 有自己的 setTimeout 循环(armTimer()),不依赖 heartbeat ticks 来触发:

// src/cron/service/timer.ts — armTimer()
const clampedDelay = Math.min(flooredDelay, MAX_TIMER_DELAY_MS); // max 60s
state.timer = setTimeout(() => {
  void onTimer(state).catch(...);
}, clampedDelay);

onTimer 触发时: 1. collectRunnableJobs() 收集到期的任务 2. markCronJobActive(job.id) 标记活跃 3. executeJobCoreWithTimeout() 执行 4. clearCronJobActive() 清除标记 5. 重新 armTimer()

2. Main-session 任务执行路径

当 main-session cron 任务到期时,调用 executeMainSessionCronJob()

// src/cron/service/timer.ts — executeMainSessionCronJob()
// 第一步:先把 systemEvent 送入内存队列
state.deps.enqueueSystemEvent(text, {
  agentId: job.agentId,
  sessionKey: cronRunSessionKey,
  contextKey: `cron:${job.id}`,
});

// 第二步:主动调用 heartbeat
if (job.wakeMode === "now") {
  heartbeatResult = await state.deps.runHeartbeatOnce({
    source: "cron", intent: "immediate", ...
  });
} else {
  state.deps.requestHeartbeat({ source: "cron", intent: "event", ... });
}

关键点enqueueSystemEvent 发生在 runHeartbeatOnce 之前

3. Heartbeat 的 activeHours 门卫

runHeartbeatOnce() 在最开头就检查 activeHours:

// src/infra/heartbeat-runner.ts — runHeartbeatOnce()
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
if (!isWithinActiveHours(cfg, heartbeat, startedAt)) {
  return { status: "skipped", reason: "quiet-hours" };
}

此时 heartbeat 还没做任何事——没消费 systemEvent、没调用 LLM、没投递消息。直接返回 skipped

4. systemEvent 队列:内存存储,非持久化

// src/infra/system-events.ts
// Lightweight in-memory queue for human-readable system events that should be
// prefixed to the next prompt. We intentionally avoid persistence to keep
// events ephemeral. Events are session-scoped and require an explicit key.

⚠️ 重要风险:systemEvent 仅存在于内存中。如果 gateway 进程在下次活跃时段的 heartbeat 之前重启,这些 systemEvent 会丢失

5. quiet-hours skip 后的重试逻辑

Cron 任务不会无限重试。当 runHeartbeatOnce 返回 quiet-hours

// 可重试的原因列表(heartbeat-wake.ts)
const RETRYABLE_BUSY_SKIP_REASONS = new Set([
  "requests-in-flight",
  "cron-in-progress",
  "lanes-busy",
]);
// "quiet-hours" 不在此列表中 → 不重试,立即 break

结果:cron 任务状态记为 skipped,然后按正常调度计算下次运行时间。

6. Heartbeat 调度器的主动前跳

除了运行时门卫,heartbeat 调度器还会主动跳过非活跃时段:

// heartbeat-schedule.ts — seekNextActivePhaseDueMs()
// 计算下次心跳时间时,会向前搜索直到找到一个落在 activeHours 内的槽位
// 最多搜索 10,080 次(7天 × 1440分钟/天)

这意味着 heartbeat 定时器本身就不会在 quiet hours 期间触发(正常情况下)。

实际影响分析

以当前配置为例:

配置项
Cron schedule 20 3,4,10,12,14,18,23 * * *
activeHours 07:00-23:59 Asia/Shanghai
Cron target main (main-session)
时间槽 在 activeHours 内? 实际行为
03:20 cron 触发 → enqueue systemEvent → runHeartbeatOnce 被拒 → systemEvent 留在内存 → 等 07:00 后的 heartbeat 消费
04:20 同上,但 03:20 的 systemEvent 还在队列里(MAX_EVENTS=20/session)
10:20 正常执行
12:20 正常执行
14:20 正常执行
18:20 正常执行
23:20 正常执行

03:20 和 04:20 的 systemEvent 会积压,等到 07:00 之后的第一次 heartbeat 执行时一起消费。如果 07:00 之前 gateway 重启,积压的 systemEvent 丢失

三种解决方案

方案 A:扩展 activeHours

"activeHours": {
  "start": "03:00",
  "end": "23:59",
  "timezone": "Asia/Shanghai"
}
  • ✅ 最简单,03:20 和 04:20 的 cron 能正常执行
  • ⚠️ heartbeat 在 03:00-07:00 期间也会活跃(不只是 cron)

方案 B:改为 isolated session cron

将记忆整理改为 isolated session,绕过 heartbeat:

openclaw cron edit <job-id> --session isolated
  • ✅ isolated 任务直接由 cron scheduler 执行,不经过 heartbeat 的 activeHours 检查
  • ⚠️ isolated session 没有主 session 的上下文,prompt 需要自包含

方案 C:接受延迟执行,移除凌晨槽位

从 schedule 中移除 34

cron 20 10,12,14,18,23 * * *
  • ✅ 最稳妥,没有凌晨执行的风险
  • ⚠️ 凌晨发生的事要等到 10:20 才会被整理

源码参考

文件 作用
src/cron/service/timer.ts Cron 定时器循环 + executeMainSessionCronJob
src/infra/heartbeat-runner.ts Heartbeat 执行器,activeHours 门卫在此
src/infra/heartbeat-active-hours.ts isWithinActiveHours() 纯时间窗口判断
src/infra/heartbeat-schedule.ts seekNextActivePhaseDueMs() 调度前跳
src/infra/system-events.ts 内存 systemEvent 队列
src/infra/heartbeat-wake.ts RETRYABLE_BUSY_SKIP_REASONS
src/cron/heartbeat-policy.ts shouldEnqueueCronMainSummary 重入队逻辑