← 返回 Tony's Lab网页由小虾生成 🦐
OpenClaw 内部机制 / cron · automations · worker thread

定时任务:一个住在 Gateway 里的调度室

模型不参与调度。真正决定「哪个任务该跑」的,是 worker 线程里一个每 60 秒醒一次的 actor:它把任务和运行状态写进共享 SQLite,到期时提交一条带确定性 ID 的 run request,再由主线程按 payload 类型分派执行、投递结果、落库、算出下一次唤醒。

代码基线 bbd73bc442f 仓库 openclaw/openclaw · main 研究日期 2026-10-18
发布于 2026-10-18
调度循环 · armActor() → setTimeout(单次 ≤ 60s)
  1. arm取最近的 nextRunAtMs,武装定时器
  2. sleep分段睡眠,醒来复查墙钟
  3. tick筛出到期且 runnable 的槽位
  4. commit提交确定性 receipt
  5. dispatch主线程启动已提交请求
  6. record落库 + 算 nextRunAtMs
receipt → cron-slot-v1.…
00结论

一句话结论

定时任务完全跑在 Gateway 进程内部,调度、执行、投递、落库是一条闭环,任何一环断了都只在进程里表现为「本次 run 的状态」。

分工是明确的:worker 线程负责「哪个槽位到期了」(调度、队列容量、持久化结果),主线程负责「启动已提交的请求」。代码注释写得很直白:“The worker decides which slots are due; the host only launches committed requests.”(src/cron/service/scheduler-actor.ts:178)

60s单次 timer 上限,超长等待分段
8全局并发 run 上限(写死)
5 / 5s重启后立即补偿上限 / 错开间隔
+2minagent 类补偿再推迟
5min整点任务的默认 stagger
24hisolated run 会话保留

先记住这一条:任务定义和运行状态现在全在共享 SQLite(~/.openclaw/state/openclaw.sqlite 的 cron_jobs、cron_run_receipts 等表)。cron/jobs.json 这个路径还在,但只是一个分区键 —— 去磁盘上找它、手改它,都没有意义。

01架构

四层组件,一条调用链

从协议定义到实际投递,「定时任务」被拆成四层,每层各自有专职文件。

组件与源文件
层职责关键文件
协议 / 数据模型TypeBox wire schema,定义 job、schedule、payload、delivery、statepackages/gateway-protocol/src/schema/cron.ts、cron-shared.ts;运行时类型在 src/cron/types.ts
入参规范化创建时补默认值;patch 时不补src/cron/normalize.ts:402-488、delivery-defaults.ts、session-target.ts
Service 门面主线程门面:add / update / remove / run / list / wake、start / stopsrc/cron/service.ts:31-287、service/ops-*.ts
调度器(worker)算到期、启动补偿、并发容量、算下一次唤醒、定时器src/cron/store/scheduler-actor.worker.ts(核心)、service/scheduler-actor.ts(主线程连接端)
调度数学every / at / cron 的下一次时间、stagger、错误退避、可运行判定schedule.ts(croner)、jobs-scheduling.ts、timer-runnable.ts、stagger.ts
运行队列 / 去重run request 入队、receipt 认领、并发上限store/run-queue.worker.ts、store/run-request-id.ts、store/run-receipt-store.ts
执行超时看门狗,按 payload 分派service/timer-job-runner.ts、timer-execution.ts、agent-watchdog.ts、timeout-policy.ts
隔离 agent 执行解析会话、选模型、跑 embedded agent、收尾src/cron/isolated-agent/run.ts、run-prepare.ts、session.ts、run-finalize.ts
投递投递计划、目标解析、发送与重试、失败告警delivery-plan.ts、isolated-agent/delivery-target.ts、delivery-dispatch.ts、delivery.ts、service/failure-alerts.ts
结果落库 / 恢复按结果改状态、退避、自动禁用;重启恢复service/timer-outcomes.ts、service/run-recovery.ts、store/run-recovery.kernel.ts
Gateway 装配构造 CronService,注入系统事件 / heartbeat / isolated agent / command / script / webhook 等依赖src/gateway/server-cron.ts:464-711
Heartbeat 桥接把 heartbeat 配置投射成系统自有的 cron jobsrc/cron/heartbeat-monitor.ts、heartbeat-task.ts

← 表格可横向滚动 →

调用链:从启动到一次触发

  1. Gateway 启动 → server-cron.ts:464 new CronService(deps)
  2. CronService.start() service.ts:41 → ops-lifecycle.start() ops-lifecycle.ts:9
  3. ensureLoaded + recoverCronRuns() 重启恢复,见 §05
  4. startCronScheduler() service/scheduler-actor.ts:76 → 新建 BroadcastChannel("openclaw.cron.<uuid>")
  5. worker 执行 cron.schedulerStart → runCronSchedulerInWorker() scheduler-actor.worker.ts:401
  6. boundedCatchup() 启动补偿 → requestAndDrainCronRunsInWorker() 提交请求 / 认领 receipt → armActor() setTimeout ≤60s
  7. 定时器到点(worker) → postMessage scheduler-actor.worker.ts:179-206
  8. 主线程 tickCronScheduler() service/scheduler-actor.ts:115-125, 179 → worker 选出到期任务、提交请求、重新 arm
  9. launchCronRunQueueResult() → executeJobCoreWithTimeout() timer-job-runner.ts:497
  10. executeJobCore() timer-execution.ts:39 按 payload 分派 → 系统事件 / heartbeat / command / script / agentTurn
  11. deliverPrimaryWebhook() timer-job-runner.ts:61, 416(webhook 模式)
  12. worker 记录结果 + 写历史 + 算 nextRunAtMs → 重新 arm 回到第 1 步
02任务模型

一个 job 长什么样

权威定义是 CronJobSchema(packages/gateway-protocol/src/schema/cron.ts:463-499),运行时类型 CronJob(src/cron/types.ts:362-382)。

CronJob 主要字段
字段类型必填默认值 / 说明
idstring是服务端生成;内部调用方可预留
namestring是不传时按 schedule + payload 推断(normalize.ts:411-425)
declarationKeystring ≤200否声明式 upsert 的键,heartbeat monitor 也靠它
owner{agentId?, sessionKey?, accountId?}否创建者身份
enabledboolean是创建时默认 true(有意的安全选择,见 §07-15)
deleteAfterRunboolean否schedule.kind="at" 时默认 true
schedule见下是
pacing{min?, max?}否动态节奏上下界;script 返回 nextCheck 时必须设置
trigger{script ≤64KB, once?}否跑 payload 之前先跑的条件脚本
sessionTargetmain / isolated / current / session:<id>是创建时按 payload 推断(见 §03)
wakeModenow / next-heartbeat是创建时默认 now(normalize.ts:405-407)
payload见下是
delivery见 §04否部分组合默认 {mode:"announce"}
failureAlertfalse / {after?, channel?, to?, cooldownMs?, …}否失败告警路由
stateCronJobState是运行时状态,见下

只在存储层存在、不对外暴露的字段在 CronStoredJob(types.ts:409-430):inferenceBinding、sourceConversation(创建时绑定的结果回落会话)、skillLibrarySelections、createdActor、toolsAllowProvenance、runtimeAuthority 等。

schedule 的 kind:一共 6 种

注意:没有叫 interval 的 kind,固定间隔叫 every。

schedule.kind(cron.ts:138-180)
kind字段语义
atat: string一次性,绝对时间
everyeveryMs(≥1), anchorMs?固定间隔:优先 lastRunAtMs + everyMs,否则按锚点对齐(jobs-scheduling.ts:363-392);锚点默认 createdAtMs
cronexpr, tz?, staggerMs?cron 表达式,croner 计算。不填 tz 用宿主机时区(schedule.ts:16-22)
on-exitcommand, cwd?事件驱动:ProcessSupervisor 监视命令退出后触发一次
streamcommand[], mode?, match?, batchMs, maxBatchBytes子进程每输出一行(或每次 match)触发一批
eventsource, options外部事件源(webhook、gmail 等)

只有 at / every / cron 是按时间调度的(jobs-scheduling.ts:350-356);其余三种由 watcher 驱动,不参与定时器 arm。

整点任务会被自动错开。cron 表达式在每小时整点触发(分钟字段为 0、小时字段带通配)时,创建时会自动写入 staggerMs = 5 分钟(stagger.ts:10-11, 63-66;normalize.ts:469-478)。每个 job 的偏移由 sha256(jobId) % staggerMs 决定,是确定的;要准点必须显式写 staggerMs: 0。

payload 的 kind:5 种,客户端只能建 4 种

payload.kind
kind关键字段说明
systemEventtext, toolsAllow?往主会话注入一条系统事件
agentTurnmessage, model?, fallbacks?, thinking?, timeoutSeconds?, lightContext?, toolsAllow?跑一轮 agent
commandargv[], cwd?, env?, input?, timeoutSeconds?, noOutputTimeoutSeconds?, outputMaxBytes?直接执行命令
scriptscript ≤64KB, timeoutSeconds?(默认 300 / 上限 900), toolBudget?(默认 50 / 上限 200)无头脚本
heartbeat—系统自有,只有 Gateway 会生成;客户端 create/patch 会被拒绝(types.ts:256-273)

sessionTarget 的默认值

  • systemEvent / heartbeat → main
  • agentTurn → current;调用方没有会话上下文时降级为 isolated(session-target.ts:39-48)
  • command / script → isolated

state:运行时状态

公开字段见 CronJobStateSchema(cron.ts:413-447),内部扩展在 types.ts:299-334。分四组:

  • 调度:nextRunAtMs、scheduleActivatedAtMs(防止改 schedule 后凭空产生「错过」槽位)、startupCatchupAtMs、pacedNextRunAtMs
  • 运行标记:queuedAtMs、runningAtMs、runningReceiptId
  • 结果:lastRunAtMs、lastRunStatus(ok / error / skipped)、lastError、lastDurationMs、lastDiagnostics
  • 计数:consecutiveErrors、consecutiveSkipped、scheduleErrorCount、autoDisabled{reason, atMs, consecutiveErrors}
  • 投递:lastDelivered、lastDeliveryStatus(delivered / not-delivered / unknown / not-requested)、lastDeliveryError、deliverySuppressionReason

一次性 vs 周期

at 与 every / cron 的行为差异
方面一次性(at)周期(every / cron)
默认 deleteAfterRuntrue不设置
成功后completion = succeeded 时删除;保留模式下改为 disabled算下一次 nextRunAtMs
失败后瞬时错误按 30s / 60s / 5min 退避,最多重试 3 次;永久错误或重试用尽 → disabled 但保留,方便排查max(下一个自然槽位, 结束时间 + 退避);连续 10 次失败自动禁用
重启补偿已经跑过(有 lastRunStatus)的不重跑,除非是显式安排的重试只补最近一个错过的槽位
错误退避不走通用退避走 30s→60m 退避序列
03调度与执行

它怎么醒过来,又在哪里跑

触发:worker 里的 setTimeout,单次最长 60s

  • worker actor 每次处理完命令后调用 armActor()(scheduler-actor.worker.ts:208-251):取所有 enabled、按时间调度、没有活动 run 的 job 中最小的 nextRunAtMs,然后 setTimeout。
  • 单次 timer 最长 60s(timer-execution-timeout.ts:24)。更长的等待分段:醒来检查是否到 deadlineAtMs,没到继续睡。注释说明了原因:“Native timers alone cannot notice a clock step or suspend”(:247-248)。
  • 到期后向所有订阅的 BroadcastChannel postMessage,并额外 arm 一个 60s 兜底 timer(:196-203)。
  • 主线程 tickCronScheduler 串行执行(service/scheduler-actor.ts:115-125, 179-189),并先取 beginGatewayRootWorkAdmissionWhenOpen("cron:worker-tick");Gateway 在 draining 时直接放弃本轮。
  • 墙钟前跳检测:墙钟流逝与单调时钟流逝差超过 2s、或已过 wakeAtMs + 2s,标记 catchupNeeded 走补偿路径(:446-452, 471)。
  • 防自旋:MIN_REFIRE_GAP_MS = 2_000。算出的 delay 为 0 时,wake 时间按 now + 2s 计(:245)。

到期之后的六步

  1. 筛:isRunnableJob()(timer-runnable.ts:69-166)排除已在 queued / running 的、在错误退避窗口内的、以及已跑过的一次性任务。
  2. 签发确定性 receipt:cron-slot-v1. + base64url([storeKey, jobId, slotMs])(run-request-id.ts:3-12)。同一个槽位重复提交会被拒,理由 already-requested。
  3. 队列校验(run-queue.worker.ts:143-225):job 存在、不在 queued/running、configRevision 未变、已启用(force 模式除外)、scheduledSlotMs 等于当前 nextRunAtMs、owner agent 可用、delivery mode 合法 —— 全部通过才认领 receipt。
  4. 并发容量:同时运行的 run 上限 8(src/config/cron-limits.ts:3),超出的留在队列。resolveCronMaxConcurrentRuns() 直接返回常量,不可配置。
  5. 分派:executeJobCoreWithTimeout → executeJobCore(timer-execution.ts:39-231)。顺序是:有 trigger 先跑条件脚本(fire:false 记 ok 但不执行 payload)→ script → heartbeat / heartbeat-task → sessionTarget=main → 其余走 detached(command 或 agentTurn)。
  6. 落库与重排:worker 写结果、写历史、算 nextRunAtMs,重新 arm。

三种跑法:任务落在哪个会话

sessionTarget 与执行会话
目标怎么跑上下文
mainenqueueSystemEvent(text, {contextKey:"cron:<jobId>"}) 把事件塞进 agent 主会话的事件队列,再发 heartbeatWake 让 heartbeat runner 在主会话里跑一轮来消费(timer-execution.ts:233-335)共享主会话
isolated执行会话 key 固定为 cron:<jobId> → 规范成 agent:<agentId>:cron:<jobId>;每次 run 用 forceNew 新建 sessionId,实际跑在隐藏行 …:run:<runSessionId>每次都是全新上下文
current同 isolated(detached),但额外把创建时的 job.sessionKey 作为 sourceSessionKey,结果默认回投到创建它的会话(run-prepare.ts:134-157)是否继承来源会话上下文:未确认
session:<id>会话 key 就是该 id;不是 detached、不以 cron: 开头,所以复用这个持久命名会话,不 forceNew、不建 :run: 子行持久会话
command / script按 detached 处理;script 返回 notify / wake 时可往主会话注入事件并唤醒 heartbeat不进主会话

isolated run 结束后会尽力清理 cron:<jobId> 会话里的浏览器 tab 和 MCP runtime;已完成的 run 会话按 cron.sessionRetention 清理,默认 24h,false 或 0h 表示关闭(src/cron/session-reaper.ts:34, 81-102)。

main 任务不认 job.sessionKey。enqueueSystemEvent 只收到 agentId,Gateway 用 toAgentStoreSessionKey + canonicalizeMainSessionAlias 解析到该 agent 的主会话;job.sessionKey 只用来读 deliveryContext(timer-execution.ts:262-269)。想在指定会话里跑,用 session:<id>,但它配的 payload 应该是 agentTurn。

另外,main + wakeMode="now" 会同步等 heartbeat 跑完;如果因为 busy 或 guard 被推迟,最多等 2 分钟就「交接」给 heartbeat 队列自己重试,此时本次 run 记为 ok(timer-execution.ts:279-316)。所以 ok 不代表 agent 已经回复。

04投递

结果往哪儿送

delivery 字段(types.ts:55-82;cron.ts:302-350)
字段说明
modenone / announce / webhook
channel渠道 id,或 "last"(用会话里记录的上一个渠道);announce 且 to 带前缀(如 slack:C123)时可从前缀推出渠道
to收件人;webhook 模式下是 URL,必填
threadId线程 / 话题 id
accountId多账号时指定(多个 Telegram bot 之类)
bestEfforttrue 时投递失败不影响完成状态
completionDestinationannounce 之外再附加一个 webhook
failureDestination失败通知的单独去向

默认值:agentTurn / command / script 配 isolated / current / session:* 且没写 delivery 时,默认 {mode:"announce"},channel 为 "last"(delivery-defaults.ts:9-25)。main + systemEvent 默认 none,输出走 heartbeat 自己的投递。

目标解析的五个判断

  1. 有 sourceConversation 且没显式目标时,先确认创建它的会话这一代还存在;不存在直接失败,错误是 “Creating conversation unavailable”。
  2. 用会话条目里记录的 last route 解析 channel 和 to;没有 last channel 时回落到已配置的渠道。
  3. accountId 优先级:job 显式值 → 会话 lastAccountId → agent bindings 的第一个绑定账号。
  4. implicit 模式下,目标不在渠道 allowFrom 里时改发给 allowFrom 的第一个对象。
  5. 拒绝从共享 agent-main 会话继承 last 收件人:没有自己的会话 key、没有显式 to、又用了共享 main 兜底时直接报错(delivery-target.ts:323-343,见 #91613)。

发送、重试与幂等

  • 实际发送走持久化出站队列 sendDurableMessageBatchCore(src/cron/delivery.ts:51-208)。幂等键 buildDirectCronDeliveryIdempotencyKey({jobId, runStartedAt, delivery}):重试或恢复时发现已发送完成就不再重发。
  • durability = bestEffort ? "best_effort" : "required"。
  • 瞬时失败重试:5s / 10s / 20s,共 4 次尝试。只重试「可证明没送达」的错误;一旦可能已到达收件人就不再重试(shouldRetryError: () => !recipientReached)。
  • webhook 模式由 deliverPrimaryWebhook 处理,鉴权用 cron.webhookToken(Bearer),SSRF 策略用 cron.webhookSsrfPolicy;结果分 delivered / not-delivered / unknown。

最容易误判的一点:投递失败不改 run 状态。投递目标类错误(errorKind:"delivery-target")下 run 仍记 ok,只写 deliveryError 和 lastDeliveryStatus。完成状态由 resolveAdmittedCronCompletionStatus 判定:必需投递且结果为 not-delivered → completion failed;unknown → unknown;有意静默算 succeeded。后果是:一次性任务不会被删除(只有 completion=succeeded 才删),会以 disabled 状态留着;consecutiveErrors 不增加,所以投递一直失败也不会触发自动禁用。排查时必须看 lastDeliveryStatus,不能只看 lastRunStatus。

失败告警默认连续 2 次才发、冷却 1h、默认不含 skipped;路由按 全局 cron.failureAlert → delivery.failureDestination → job failureAlert 逐层覆盖,和主投递目标相同时不重复发。

05可靠性

超时、重试、补偿、去重

timeoutSeconds 到底管多长

墙钟超时(service/timeout-policy.ts:10-31)
payload不填填 > 0填 0
agentTurn60 分钟安全上限用指定值不设墙钟超时
command10 分钟用指定值不设超时
script300s,上限 900s夹到 1–900规范化后不会出现 0
systemEvent / heartbeat10 分钟不适用不适用

这是调度器的墙钟预算,和模型单次尝试的超时(agents.defaults.timeoutSeconds)是两套东西,fallback 换模型不会重置这个预算。对 isolated agentTurn,墙钟超时推迟到 runner 启动后才开始计时;启动前另有阶段看门狗:setup 60s,pre-execution 取 min(60s, jobTimeout/2)、最少 1s。超时后 abort,等清理最多 20s,然后强制清掉会话占用。

timeoutSeconds: 0 表示不限时,不是「立刻超时」(timeout-policy.ts:30)。

失败退避与自动禁用

30s第 1 次
1m第 2 次
5m第 3 次
15m第 4 次
60m封顶

退避从「运行结束时间」开始算,不是从原定槽位(timer-runnable.ts:132-136;jobs-scheduling.ts:74-80, 130-156)。

  • 瞬时错误快重试:rate_limit / overloaded / network / timeout / server_error 可重试,最多 3 次,退避取上面序列的前 3 档。retryOn 写死为 undefined(五类全重试),没有用户配置入口。
  • 会话生命周期竞争类错误只在 executionStarted !== true 时重试 —— 执行开始后,工具可能已经产生了不可重放的副作用。
  • 周期任务:重试时间早于下一个自然槽位时,安排一次额外重试;否则取 max(自然槽位, 结束时间 + 退避)。
  • 自动禁用:运行失败连续 10 次(auto-disable.ts:11-15);schedule 计算出错连续 3 次(jobs-scheduling.ts:412-450)。禁用后会通知 owner,用 openclaw automations enable <id> 恢复。
  • 被取消(errorClassification.kind === "aborted")的 run 不计入失败,也不告警。

错过的任务:重启后怎么补

boundedCatchup(scheduler-actor.worker.ts:262-399)只在 cron.schedulerStart 或检测到墙钟前跳时运行。

  • 每个 job 最多补一次。判定看「最近一个应触发的槽位」是否晚于上次运行(hasMissedCronSlotSinceLastRun),不会把重启期间错过的多个槽位逐个重放。
  • 槽位早于 scheduleActivatedAtMs(即修改 schedule 之前)的不算错过(#91944)。
  • 按 nextRunAtMs 从早到晚排序:最多立即补 5 个,其余每个间隔 5s 错开,并且要等立即批次跑完后才开始。
  • agent 类工作(agentTurn、heartbeat、heartbeat-task、main+systemEvent+wakeMode=now)启动时不立即执行,统一推迟 2 分钟;推迟点写进 startupCatchupAtMs,重启后仍然有效。
  • cron.skipMissedJobs: true 时周期任务直接跳到下一个槽位,不补;一次性任务不受影响。
  • 5 个 / 5s / 2min 这三个常量只能通过 CronServiceDeps 注入,Gateway 没有暴露成用户配置。

跑到一半崩溃:不重放

recoverCronRuns(service/run-recovery.ts:10-34)处理三种死状态:

  • 有 runningAtMs 且能找到本次 run 已落库的终态 → 直接恢复这个结果,不重新执行 payload,也不重投。
  • 找不到终态 → 记为 interrupted,投递状态 Unknown,历史里显示失败。中断的一次性任务保持 disabled,需要人工检查后手动重跑;周期任务继续按后续槽位调度。
  • 只有 queuedAtMs(还没开跑)时,带槽位 ID 的定时请求可以恢复;manual 或 event 请求丢了瞬时上下文的,记 skipped,错误是 queued request lost its launch context。

这是有意的取舍:宁可在崩溃窗口里丢一次执行,也不重放「命令 / 工具 / 投递」这类不确定是否已生效的副作用。

并发、去重与存储

  • 同一个 job 同时最多一个 run:状态层有 queuedAtMs 或 runningAtMs 时不再入队;数据库层 cron_run_receipts 上有唯一部分索引 (store_key, job_id) WHERE status='running'。
  • 同一个槽位不会重复:receipt ID 由 (storeKey, jobId, slotMs) 确定性生成,已存在的会被拒绝并修复已消费的 nextRunAtMs。
  • 全局并发 8;投递去重靠出站队列的 intent id。
  • 存储:任务定义、运行状态、历史全在共享 SQLite ~/.openclaw/state/openclaw.sqlite。表:cron_jobs、cron_run_receipts、cron_job_runtime_authorities、cron_job_scratch、cron_run_trigger_state_retirements;run 历史在 task_runs 里 runtime='cron' 的行。写入发生在 worker 线程的同步事务里。
06对比

它和 heartbeat 是什么关系

当前版本里,heartbeat 本身就是一个 cron job,由同一个调度器定时。

resolveHeartbeatMonitorPlan 为每个启用 heartbeat 的 agent 声明式 upsert 一个系统自有 job(heartbeat-monitor.ts:73-121):declarationKey="<HEARTBEAT_PREFIX><agentId>"、schedule={kind:"every", everyMs, anchorMs:按种子算的相位}、payload={kind:"heartbeat"}、sessionTarget:"main"、wakeMode:"next-heartbeat",默认间隔 30 分钟。旧版 heartbeat 的 tasks 会被 doctor 迁移成 systemEvent + main 的 cron job。

用户 cron job vs heartbeat
方面用户 cron jobheartbeat
谁定时cron worker actor同一个 worker actor(以 heartbeat monitor job 的形式)
执行入口executeJobCore 按 payload 分派同一入口;payload.kind==="heartbeat" 或 heartbeat-task 走 requestHeartbeatAndWait(source:"interval")
实际执行者isolated agent runner;command / script 用各自 runnersrc/infra/heartbeat-runner-*.ts,在主会话里跑一轮
防扰策略cron 自己的并发上限、退避、自动禁用heartbeat runner 管合并、quiet hours、cooldown、flood 检测、busy 时延后
会话isolated 每次新建;main 共享主会话主会话
输出delivery(announce / webhook / none)heartbeat 自己的投递(target:"last");没东西可说时静默
客户端能否创建能不能,heartbeat payload 是系统自有

两者的交叉点:sessionTarget="main" 的 systemEvent 任务依赖 heartbeat runner 来消费事件。heartbeat 被禁用或延后,main 任务就会记 skipped;一次性 main + now 任务遇到「heartbeat disabled」时有专门的退避重试,最多 3 次。

07已知坑

十七个容易踩的地方

全部来自代码与注释,不是经验之谈。每条都附了可以自己去核的行号。

  1. cron 表达式默认用宿主机时区

    容器里 TZ 往往是 UTC,0 9 * * * 会在 UTC 9 点触发。建议总是写 tz。

    schedule.ts:16-22
  2. 整点任务会被自动错开最多 5 分钟

    0 * * * *、0 9 * * * 这类表达式创建时会被写入 staggerMs=300000,偏移由 jobId 的 sha256 决定。要准点必须显式写 staggerMs: 0。

    stagger.ts:10-11 normalize.ts:469-478
  3. 重启补偿只补一次,agent 任务还要再推迟 2 分钟

    容器停了一整天,每个 job 也只补跑一次;agentTurn 和 heartbeat 类在启动后约 2 分钟才跑;一次最多立即跑 5 个,其余每 5s 一个。开 cron.skipMissedJobs 后周期任务完全不补。

    scheduler-actor.worker.ts:253-260, 347-374
  4. 崩溃时正在跑的任务不会重放

    会记 interrupted,投递状态 Unknown。中断的一次性任务保持 disabled,需要手动处理。

    run-recovery.kernel.ts:202-223
  5. 一次性任务失败不会自动删除

    deleteAfterRun 只在 completion=succeeded 时生效;投递失败(必需投递 not-delivered)也会让一次性任务以 disabled 状态留在存储里。

    timer-outcomes.ts:215-220, 355-362
  6. 投递失败不计入 consecutiveErrors

    run 仍是 ok,只记 lastDeliveryError,所以投递一直失败也不会触发自动禁用。只有配了独立失败告警路由才会收到 “delivery failed”。排查看 lastDeliveryStatus。

    failure-alerts.ts:575-614
  7. channel:"last" 的隐式投递可能被拒绝

    用共享 agent-main 会话的 last 收件人兜底时会直接报错,防止发到别的群(#91613)。稳妥做法是显式写 delivery.channel + delivery.to,多账号再加 accountId。

    delivery-target.ts:323-343
  8. sessionTarget:"main" 只会进 agent 的主会话

    代码没有把 job.sessionKey 传给 enqueueSystemEvent,它只用来取 deliveryContext。想在指定会话里跑,用 session:<id>,但它对应的 payload 应该是 agentTurn。

    timer-execution.ts:262-269
  9. wakeMode 默认是 now

    next-heartbeat 下 run 立即记 ok,但真正执行要等下一次 heartbeat,最长约一个间隔(默认 30 分钟)。now 下 heartbeat busy 时最多等 2 分钟,然后「交接」并记为 ok。所以 ok 不代表 agent 已经回复。

    timer-execution.ts:289-316
  10. timeoutSeconds: 0 表示不限时

    不是「立刻超时」。不设时 agentTurn 有 60 分钟兜底。

    timeout-policy.ts:30
  11. isolated 的 systemEvent 会被跳过

    isolated / current 目标只接受 agentTurn 或 command;配了 systemEvent 会记 skipped,错误信息是 isolated job requires payload.kind="agentTurn" or "command"。

    timer-execution.ts:400-410
  12. jobs.json 只是分区键

    状态全在 SQLite。resolveCronJobsStorePath 仍返回 <configDir>/cron/jobs.json,但它只作为 SQLite 的 store_key;磁盘上不一定有这个文件,找它或手改它没有意义。

    store/paths.ts:7-13 store/key.ts:4-7
  13. 并发上限 8 和瞬时重试策略都写死了

    resolveCronMaxConcurrentRuns() 直接返回常量;retryOn 也是写死的。没有配置入口。

    cron-limits.ts:3-9 timer-trigger.ts:32, 170
  14. 改 schedule 不会产生错过的槽位

    scheduleActivatedAtMs 会屏蔽修改前的槽位(#91944)。这是有意设计,不是 bug。

    timer-runnable.ts:59-66
  15. 默认创建为 enabled 是有意的安全选择

    文档的解释:disabled 的任务不受任何监督,会「静默不执行」,比运行后报错更糟。

    docs/automation/cron-jobs/how-it-works.md
  16. 整个调度依赖 Gateway 进程在运行

    OPENCLAW_SKIP_CRON=1 或 cron.enabled:false 会关掉调度。外部用系统 cron 调 openclaw agent 时,文档建议加硬杀兜底,例如 timeout -k 60 600 …。

    server-cron.ts:158-159
  17. 架构还在演进

    docs/refactor/cron-worker-owned.md 是「worker 持有调度」的分阶段重构提案,行号基于旧基线 7517981b。当前代码已有 scheduler-actor.worker.ts 这条路径,但提案逐项落地到什么程度:未逐项确认。

    docs/refactor/cron-worker-owned.md
  18. 「现在到底跑没跑」要四处对齐看

    lastRunStatus 说执行,lastDeliveryStatus 说送达,consecutiveErrors 说健康度,autoDisabled 说是否已被停用 —— 四者可以互相矛盾(例如 ok + not-delivered)。只看一个字段就会误判。

    types.ts:299-334 completion-status.ts:18-62

附:关键常量速查

常量与位置
常量值位置
单次 timer 最长60stimer-execution-timeout.ts:24
最小重触发间隔2stimer-execution-timeout.ts:33
重启立即补偿上限5timer-execution-timeout.ts:37
补偿错开间隔5stimer-execution-timeout.ts:35
agent 类启动推迟2mintimer-execution-timeout.ts:39
最大并发 run8src/config/cron-limits.ts:3
agentTurn 默认墙钟超时60mintimeout-policy.ts:16
其他 payload 默认墙钟超时10mintimeout-policy.ts:10
script 默认 / 上限超时300s / 900sscript-payload.ts:3-4
setup / pre-exec 看门狗60s / min(60s, T/2)agent-watchdog.ts:17-18, 226-231
超时清理等待20sagent-watchdog.ts:16
错误退避序列30s, 1m, 5m, 15m, 60mjobs-scheduling.ts:74-80
瞬时重试次数3timer-trigger.ts:32
运行失败自动禁用阈值10auto-disable.ts:15
schedule 错误禁用阈值3jobs-scheduling.ts:413
失败告警 after / cooldown2 / 1hfailure-alerts.ts:38-39
投递瞬时重试间隔5s, 10s, 20sdelivery-dispatch-policy.ts:332
整点默认 stagger5minstagger.ts:11
run 会话保留24hsession-reaper.ts:34