定时任务与心跳

一句话概括

定时服务管理计划任务(提醒、周期性任务)和心跳 — 后者是隐性的 token 消耗源,每 10 分钟发送完整会话上下文只为得到一个 "HEARTBEAT_OK" 回复。

职责

  • 调度和执行周期性任务(cron 表达式、间隔式、一次性)
  • 管理心跳 — 在主会话上定期执行健康检查 LLM 调用
  • 在隔离 agent 会话或主会话中执行任务
  • 处理错误退避、卡住运行检测和漏执行追赶
  • 持久化任务状态并追踪执行遥测(包括 token 用量)

架构图

关键源码文件

文件行数角色
src/cron/service.ts57公共 API:CronService 类,start/stop/list/add/update/remove/run/wake
src/cron/service/timer.ts877计时器引擎onTimer()runDueJobs()executeJobCore()、心跳执行、错误退避
src/cron/service/jobs.ts634调度computeJobNextRunAtMs()isJobDue()、时间表解析、下次运行计算
src/cron/service/ops.ts459CRUD 操作:增删改查带验证和状态管理
src/cron/service/state.ts141状态与依赖CronServiceDeps 接口、CronServiceState、心跳函数
src/cron/types.ts143类型CronJobCronSchedule(at/every/cron)、CronPayload(systemEvent/agentTurn)、CronRunTelemetry
src/cron/service/store.ts任务持久化(JSON 文件)
src/cron/isolated-agent.ts计划任务的隔离 agent 执行
src/cron/delivery.ts投递计划解析
src/cron/session-reaper.ts任务完成后的会话清理

数据流

计时器循环

每 MAX_TIMER_DELAY_MS (60秒) 或下一个任务到期时:

onTimer()

runDueJobs() — 检查所有任务

对每个到期任务:

executeJobCore()

  ├── sessionTarget = "main":
  │     ├── enqueueSystemEvent() — 注入主会话
  │     └── 如果 wakeMode = "now":
  │           ├── runHeartbeatOnce() — 完整 LLM 调用
  │           ├── "requests-in-flight" 时重试(250ms 间隔,最多 2 分钟)
  │           └── 降级:requestHeartbeatNow()(异步)

  └── sessionTarget = "isolated":
        ├── runIsolatedAgentJob() — 独立会话
        ├── 在 CronRunTelemetry 中追踪 token 用量
        └── 可选地向主会话发送摘要

applyJobResult() — 更新任务状态

emit("finished") — 带遥测(model、provider、usage)

armTimer() — 调度下次 tick

心跳流程(高 token 消耗路径)

心跳定时任务触发(默认:每 600 秒)

executeJobCore(),sessionTarget = "main",wakeMode = "now"

enqueueSystemEvent() — 将心跳提示词注入主会话队列

runHeartbeatOnce()

完整 LLM 调用包含:
  - 完整 system prompt(约 3,000-5,000 tokens)
  - 完整会话历史(40,000-150,000+ tokens)
  - 工具定义(约 2,000-4,000 tokens)
  - Skills 目录(约 2,000 tokens)

Agent 响应:"HEARTBEAT_OK"(无需关注时)
  或:告警文本(需要用户关注时)

关键常量

常量位置影响
MAX_TIMER_DELAY_MS60,000 ms (60秒)timer.ts防止漂移的最大计时器间隔
MIN_REFIRE_GAP_MS2,000 ms (2秒)timer.ts同一任务连续触发的最小间隔
DEFAULT_JOB_TIMEOUT_MS600,000 ms (10分钟)timer.ts每个任务的默认执行超时
STUCK_RUN_MS7,200,000 ms (2小时)timer.ts卡住运行标记的阈值
默认心跳间隔600秒 (10分钟)configcron.heartbeat.enabled: true

错误退避时间表(timer.ts:108-114)

第 1 次错误 →  30 秒
第 2 次错误 →  1 分钟
第 3 次错误 →  5 分钟
第 4 次错误 →  15 分钟
第 5+ 次错误 →  60 分钟

连续 3 次调度错误 → 自动禁用任务(MAX_SCHEDULE_ERRORS = 3)。

Token 优化影响

心跳是 OpenClaw 中最低效的 token 消费者

场景输入 tokens/次心跳成本($3/M 输入)日成本(6次/小时)
轻量会话(5 轮)~15,000$0.045$6.48
中等会话(15 轮)~50,000$0.15$21.60
重度会话(30+ 轮)~150,000$0.45$64.80
满上下文(200k)~200,000$0.60$86.40

为什么心跳昂贵

  1. 发送完整上下文:心跳使用与普通消息相同的代码路径 — runHeartbeatOnce() 触发完整 LLM 调用,包含完整 system prompt、会话历史、工具定义和 skills 目录
  2. 响应极少:Agent 通常只回复 HEARTBEAT_OK(约 2 个输出 token),而输入 50,000+ tokens
  3. 随会话增长:心跳成本与会话历史长度线性增长
  4. 默认 6 次/小时:每 10 分钟,为 2 个 token 的响应消耗大量 token

优化机会

  • 最小心跳上下文:只发送身份行 + "有待处理事项吗?" 而非完整上下文
  • 自适应心跳频率:会话空闲时增加间隔,活跃时减少
  • 最近活跃时跳过:如果用户在最近 N 分钟内发过消息,跳过心跳
  • 心跳专用 prompt 模式:使用 PromptMode = "none"(15 tokens)而非 "full"(5,000 tokens)

时间表类型

CronSchedule =
  | { at: string }    // 一次性:ISO 日期时间或相对时间("in 30m")
  | { every: string }  // 间隔:10m、2h、1d
  | { cron: string }   // Cron 表达式:"0 */6 * * *"

任务 payload 类型

CronPayload =
  | { systemEvent: string }   // 注入文本到主会话
  | { agentTurn: string }     // 在隔离会话中执行 agent 轮次

与其他模块的关系

  • 依赖

    • auto-reply/runHeartbeatOnce()、隔离 agent 执行
    • agents/pi-embedded-runner/ — 心跳和 agent 轮次的 LLM 执行
    • sessions/ — 会话定向、key 解析
    • config/ — 心跳设置、cron 配置
    • channels/ — 投递到消息平台
  • 被依赖

    • gateway/ — 随 gateway 生命周期启停定时服务
    • System prompt — 包含心跳提示词指令章节

我的认知盲区

  • isolated-agent.ts — 隔离 agent 任务 vs 主会话的精确执行流程
  • delivery.ts — 多渠道投递的投递计划如何工作
  • session-reaper.ts — 何时以及如何清理旧的 cron 会话
  • 心跳是否使用 prompt caching(Anthropic)— 这会大幅降低心跳成本
  • 精确的心跳提示词文本以及是否可按 agent 自定义
  • stagger.ts — 避免惊群效应的错开窗口逻辑

相关贡献

  • 暂无

变更频率

  • timer.ts:中 — 退避逻辑、心跳重试行为、计时常量演进
  • jobs.ts:中 — 调度逻辑随新时间表类型变更
  • ops.ts:中 — CRUD 操作随新功能扩展(分页、过滤)
  • service.ts:低 — 薄包装层,很少变更
  • types.ts:低 — 类型添加是增量的且少见