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¶
- ✅ 最简单,03:20 和 04:20 的 cron 能正常执行
- ⚠️ heartbeat 在 03:00-07:00 期间也会活跃(不只是 cron)
方案 B:改为 isolated session cron¶
将记忆整理改为 isolated session,绕过 heartbeat:
- ✅ isolated 任务直接由 cron scheduler 执行,不经过 heartbeat 的 activeHours 检查
- ⚠️ isolated session 没有主 session 的上下文,prompt 需要自包含
方案 C:接受延迟执行,移除凌晨槽位¶
从 schedule 中移除 3 和 4:
- ✅ 最稳妥,没有凌晨执行的风险
- ⚠️ 凌晨发生的事要等到 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 重入队逻辑 |