一句话结论
定时任务完全跑在 Gateway 进程内部,调度、执行、投递、落库是一条闭环,任何一环断了都只在进程里表现为「本次 run 的状态」。
分工是明确的:worker 线程负责「哪个槽位到期了」(调度、队列容量、持久化结果),主线程负责「启动已提交的请求」。代码注释写得很直白:“The worker decides which slots are due; the host only launches committed requests.”(src/cron/service/scheduler-actor.ts:178)
先记住这一条:任务定义和运行状态现在全在共享 SQLite(~/.openclaw/state/openclaw.sqlite 的 cron_jobs、cron_run_receipts 等表)。cron/jobs.json 这个路径还在,但只是一个分区键 —— 去磁盘上找它、手改它,都没有意义。
四层组件,一条调用链
从协议定义到实际投递,「定时任务」被拆成四层,每层各自有专职文件。
| 层 | 职责 | 关键文件 |
|---|---|---|
| 协议 / 数据模型 | TypeBox wire schema,定义 job、schedule、payload、delivery、state | packages/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 / stop | src/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 job | src/cron/heartbeat-monitor.ts、heartbeat-task.ts |
← 表格可横向滚动 →
调用链:从启动到一次触发
- Gateway 启动 →
server-cron.ts:464new CronService(deps) - CronService.start()
service.ts:41→ ops-lifecycle.start()ops-lifecycle.ts:9 - ensureLoaded + recoverCronRuns()
重启恢复,见 §05 - startCronScheduler()
service/scheduler-actor.ts:76→ 新建 BroadcastChannel("openclaw.cron.<uuid>") - worker 执行 cron.schedulerStart → runCronSchedulerInWorker()
scheduler-actor.worker.ts:401 - boundedCatchup()
启动补偿→ requestAndDrainCronRunsInWorker()提交请求 / 认领 receipt→ armActor()setTimeout ≤60s - 定时器到点(worker) → postMessage
scheduler-actor.worker.ts:179-206 - 主线程 tickCronScheduler()
service/scheduler-actor.ts:115-125, 179→ worker 选出到期任务、提交请求、重新 arm - launchCronRunQueueResult() → executeJobCoreWithTimeout()
timer-job-runner.ts:497 - executeJobCore()
timer-execution.ts:39按 payload 分派 → 系统事件 / heartbeat / command / script / agentTurn - deliverPrimaryWebhook()
timer-job-runner.ts:61, 416(webhook 模式) - worker 记录结果 + 写历史 + 算 nextRunAtMs → 重新 arm
回到第 1 步
一个 job 长什么样
权威定义是 CronJobSchema(packages/gateway-protocol/src/schema/cron.ts:463-499),运行时类型 CronJob(src/cron/types.ts:362-382)。
| 字段 | 类型 | 必填 | 默认值 / 说明 |
|---|---|---|---|
| id | string | 是 | 服务端生成;内部调用方可预留 |
| name | string | 是 | 不传时按 schedule + payload 推断(normalize.ts:411-425) |
| declarationKey | string ≤200 | 否 | 声明式 upsert 的键,heartbeat monitor 也靠它 |
| owner | {agentId?, sessionKey?, accountId?} | 否 | 创建者身份 |
| enabled | boolean | 是 | 创建时默认 true(有意的安全选择,见 §07-15) |
| deleteAfterRun | boolean | 否 | schedule.kind="at" 时默认 true |
| schedule | 见下 | 是 | |
| pacing | {min?, max?} | 否 | 动态节奏上下界;script 返回 nextCheck 时必须设置 |
| trigger | {script ≤64KB, once?} | 否 | 跑 payload 之前先跑的条件脚本 |
| sessionTarget | main / isolated / current / session:<id> | 是 | 创建时按 payload 推断(见 §03) |
| wakeMode | now / next-heartbeat | 是 | 创建时默认 now(normalize.ts:405-407) |
| payload | 见下 | 是 | |
| delivery | 见 §04 | 否 | 部分组合默认 {mode:"announce"} |
| failureAlert | false / {after?, channel?, to?, cooldownMs?, …} | 否 | 失败告警路由 |
| state | CronJobState | 是 | 运行时状态,见下 |
只在存储层存在、不对外暴露的字段在 CronStoredJob(types.ts:409-430):inferenceBinding、sourceConversation(创建时绑定的结果回落会话)、skillLibrarySelections、createdActor、toolsAllowProvenance、runtimeAuthority 等。
schedule 的 kind:一共 6 种
注意:没有叫 interval 的 kind,固定间隔叫 every。
| kind | 字段 | 语义 |
|---|---|---|
| at | at: string | 一次性,绝对时间 |
| every | everyMs(≥1), anchorMs? | 固定间隔:优先 lastRunAtMs + everyMs,否则按锚点对齐(jobs-scheduling.ts:363-392);锚点默认 createdAtMs |
| cron | expr, tz?, staggerMs? | cron 表达式,croner 计算。不填 tz 用宿主机时区(schedule.ts:16-22) |
| on-exit | command, cwd? | 事件驱动:ProcessSupervisor 监视命令退出后触发一次 |
| stream | command[], mode?, match?, batchMs, maxBatchBytes | 子进程每输出一行(或每次 match)触发一批 |
| event | source, 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 种
| kind | 关键字段 | 说明 |
|---|---|---|
| systemEvent | text, toolsAllow? | 往主会话注入一条系统事件 |
| agentTurn | message, model?, fallbacks?, thinking?, timeoutSeconds?, lightContext?, toolsAllow? | 跑一轮 agent |
| command | argv[], cwd?, env?, input?, timeoutSeconds?, noOutputTimeoutSeconds?, outputMaxBytes? | 直接执行命令 |
| script | script ≤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) |
|---|---|---|
| 默认 deleteAfterRun | true | 不设置 |
| 成功后 | completion = succeeded 时删除;保留模式下改为 disabled | 算下一次 nextRunAtMs |
| 失败后 | 瞬时错误按 30s / 60s / 5min 退避,最多重试 3 次;永久错误或重试用尽 → disabled 但保留,方便排查 | max(下一个自然槽位, 结束时间 + 退避);连续 10 次失败自动禁用 |
| 重启补偿 | 已经跑过(有 lastRunStatus)的不重跑,除非是显式安排的重试 | 只补最近一个错过的槽位 |
| 错误退避 | 不走通用退避 | 走 30s→60m 退避序列 |
它怎么醒过来,又在哪里跑
触发: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)。 - 到期后向所有订阅的
BroadcastChannelpostMessage,并额外 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)。
到期之后的六步
- 筛:
isRunnableJob()(timer-runnable.ts:69-166)排除已在 queued / running 的、在错误退避窗口内的、以及已跑过的一次性任务。 - 签发确定性 receipt:
cron-slot-v1.+ base64url([storeKey, jobId, slotMs])(run-request-id.ts:3-12)。同一个槽位重复提交会被拒,理由already-requested。 - 队列校验(
run-queue.worker.ts:143-225):job 存在、不在 queued/running、configRevision未变、已启用(force 模式除外)、scheduledSlotMs等于当前nextRunAtMs、owner agent 可用、delivery mode 合法 —— 全部通过才认领 receipt。 - 并发容量:同时运行的 run 上限 8(
src/config/cron-limits.ts:3),超出的留在队列。resolveCronMaxConcurrentRuns()直接返回常量,不可配置。 - 分派:
executeJobCoreWithTimeout→executeJobCore(timer-execution.ts:39-231)。顺序是:有trigger先跑条件脚本(fire:false记 ok 但不执行 payload)→script→ heartbeat / heartbeat-task →sessionTarget=main→ 其余走 detached(command 或 agentTurn)。 - 落库与重排:worker 写结果、写历史、算
nextRunAtMs,重新 arm。
三种跑法:任务落在哪个会话
| 目标 | 怎么跑 | 上下文 |
|---|---|---|
| main | enqueueSystemEvent(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 已经回复。
结果往哪儿送
| 字段 | 说明 |
|---|---|
| mode | none / announce / webhook |
| channel | 渠道 id,或 "last"(用会话里记录的上一个渠道);announce 且 to 带前缀(如 slack:C123)时可从前缀推出渠道 |
| to | 收件人;webhook 模式下是 URL,必填 |
| threadId | 线程 / 话题 id |
| accountId | 多账号时指定(多个 Telegram bot 之类) |
| bestEffort | true 时投递失败不影响完成状态 |
| completionDestination | announce 之外再附加一个 webhook |
| failureDestination | 失败通知的单独去向 |
默认值:agentTurn / command / script 配 isolated / current / session:* 且没写 delivery 时,默认 {mode:"announce"},channel 为 "last"(delivery-defaults.ts:9-25)。main + systemEvent 默认 none,输出走 heartbeat 自己的投递。
目标解析的五个判断
- 有
sourceConversation且没显式目标时,先确认创建它的会话这一代还存在;不存在直接失败,错误是 “Creating conversation unavailable”。 - 用会话条目里记录的 last route 解析 channel 和 to;没有 last channel 时回落到已配置的渠道。
accountId优先级:job 显式值 → 会话 lastAccountId → agent bindings 的第一个绑定账号。- implicit 模式下,目标不在渠道 allowFrom 里时改发给 allowFrom 的第一个对象。
- 拒绝从共享 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 逐层覆盖,和主投递目标相同时不重复发。
超时、重试、补偿、去重
timeoutSeconds 到底管多长
| payload | 不填 | 填 > 0 | 填 0 |
|---|---|---|---|
| agentTurn | 60 分钟安全上限 | 用指定值 | 不设墙钟超时 |
| command | 10 分钟 | 用指定值 | 不设超时 |
| script | 300s,上限 900s | 夹到 1–900 | 规范化后不会出现 0 |
| systemEvent / heartbeat | 10 分钟 | 不适用 | 不适用 |
这是调度器的墙钟预算,和模型单次尝试的超时(agents.defaults.timeoutSeconds)是两套东西,fallback 换模型不会重置这个预算。对 isolated agentTurn,墙钟超时推迟到 runner 启动后才开始计时;启动前另有阶段看门狗:setup 60s,pre-execution 取 min(60s, jobTimeout/2)、最少 1s。超时后 abort,等清理最多 20s,然后强制清掉会话占用。
timeoutSeconds: 0 表示不限时,不是「立刻超时」(timeout-policy.ts:30)。
失败退避与自动禁用
退避从「运行结束时间」开始算,不是从原定槽位(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 线程的同步事务里。
它和 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 | heartbeat |
|---|---|---|
| 谁定时 | cron worker actor | 同一个 worker actor(以 heartbeat monitor job 的形式) |
| 执行入口 | executeJobCore 按 payload 分派 | 同一入口;payload.kind==="heartbeat" 或 heartbeat-task 走 requestHeartbeatAndWait(source:"interval") |
| 实际执行者 | isolated agent runner;command / script 用各自 runner | src/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 次。
十七个容易踩的地方
全部来自代码与注释,不是经验之谈。每条都附了可以自己去核的行号。
- cron 表达式默认用宿主机时区
容器里 TZ 往往是 UTC,
0 9 * * *会在 UTC 9 点触发。建议总是写tz。schedule.ts:16-22 - 整点任务会被自动错开最多 5 分钟
0 * * * *、0 9 * * *这类表达式创建时会被写入staggerMs=300000,偏移由 jobId 的 sha256 决定。要准点必须显式写staggerMs: 0。stagger.ts:10-11normalize.ts:469-478 - 重启补偿只补一次,agent 任务还要再推迟 2 分钟
容器停了一整天,每个 job 也只补跑一次;agentTurn 和 heartbeat 类在启动后约 2 分钟才跑;一次最多立即跑 5 个,其余每 5s 一个。开
cron.skipMissedJobs后周期任务完全不补。scheduler-actor.worker.ts:253-260, 347-374 - 崩溃时正在跑的任务不会重放
会记 interrupted,投递状态 Unknown。中断的一次性任务保持 disabled,需要手动处理。
run-recovery.kernel.ts:202-223 - 一次性任务失败不会自动删除
deleteAfterRun只在 completion=succeeded 时生效;投递失败(必需投递 not-delivered)也会让一次性任务以 disabled 状态留在存储里。timer-outcomes.ts:215-220, 355-362 - 投递失败不计入 consecutiveErrors
run 仍是 ok,只记
lastDeliveryError,所以投递一直失败也不会触发自动禁用。只有配了独立失败告警路由才会收到 “delivery failed”。排查看lastDeliveryStatus。failure-alerts.ts:575-614 channel:"last"的隐式投递可能被拒绝用共享 agent-main 会话的 last 收件人兜底时会直接报错,防止发到别的群(#91613)。稳妥做法是显式写
delivery.channel+delivery.to,多账号再加accountId。delivery-target.ts:323-343sessionTarget:"main"只会进 agent 的主会话代码没有把
job.sessionKey传给enqueueSystemEvent,它只用来取 deliveryContext。想在指定会话里跑,用session:<id>,但它对应的 payload 应该是 agentTurn。timer-execution.ts:262-269wakeMode默认是nownext-heartbeat下 run 立即记 ok,但真正执行要等下一次 heartbeat,最长约一个间隔(默认 30 分钟)。now下 heartbeat busy 时最多等 2 分钟,然后「交接」并记为 ok。所以 ok 不代表 agent 已经回复。timer-execution.ts:289-316timeoutSeconds: 0表示不限时不是「立刻超时」。不设时 agentTurn 有 60 分钟兜底。
timeout-policy.ts:30- isolated 的 systemEvent 会被跳过
isolated / current 目标只接受 agentTurn 或 command;配了 systemEvent 会记 skipped,错误信息是
isolated job requires payload.kind="agentTurn" or "command"。timer-execution.ts:400-410 jobs.json只是分区键状态全在 SQLite。
resolveCronJobsStorePath仍返回<configDir>/cron/jobs.json,但它只作为 SQLite 的store_key;磁盘上不一定有这个文件,找它或手改它没有意义。store/paths.ts:7-13store/key.ts:4-7- 并发上限 8 和瞬时重试策略都写死了
resolveCronMaxConcurrentRuns()直接返回常量;retryOn也是写死的。没有配置入口。cron-limits.ts:3-9timer-trigger.ts:32, 170 - 改 schedule 不会产生错过的槽位
scheduleActivatedAtMs会屏蔽修改前的槽位(#91944)。这是有意设计,不是 bug。timer-runnable.ts:59-66 - 默认创建为 enabled 是有意的安全选择
文档的解释:disabled 的任务不受任何监督,会「静默不执行」,比运行后报错更糟。
docs/automation/cron-jobs/how-it-works.md - 整个调度依赖 Gateway 进程在运行
OPENCLAW_SKIP_CRON=1或cron.enabled:false会关掉调度。外部用系统 cron 调openclaw agent时,文档建议加硬杀兜底,例如timeout -k 60 600 …。server-cron.ts:158-159 - 架构还在演进
docs/refactor/cron-worker-owned.md是「worker 持有调度」的分阶段重构提案,行号基于旧基线7517981b。当前代码已有scheduler-actor.worker.ts这条路径,但提案逐项落地到什么程度:未逐项确认。docs/refactor/cron-worker-owned.md - 「现在到底跑没跑」要四处对齐看
lastRunStatus说执行,lastDeliveryStatus说送达,consecutiveErrors说健康度,autoDisabled说是否已被停用 —— 四者可以互相矛盾(例如 ok + not-delivered)。只看一个字段就会误判。types.ts:299-334completion-status.ts:18-62
附:关键常量速查
| 常量 | 值 | 位置 |
|---|---|---|
| 单次 timer 最长 | 60s | timer-execution-timeout.ts:24 |
| 最小重触发间隔 | 2s | timer-execution-timeout.ts:33 |
| 重启立即补偿上限 | 5 | timer-execution-timeout.ts:37 |
| 补偿错开间隔 | 5s | timer-execution-timeout.ts:35 |
| agent 类启动推迟 | 2min | timer-execution-timeout.ts:39 |
| 最大并发 run | 8 | src/config/cron-limits.ts:3 |
| agentTurn 默认墙钟超时 | 60min | timeout-policy.ts:16 |
| 其他 payload 默认墙钟超时 | 10min | timeout-policy.ts:10 |
| script 默认 / 上限超时 | 300s / 900s | script-payload.ts:3-4 |
| setup / pre-exec 看门狗 | 60s / min(60s, T/2) | agent-watchdog.ts:17-18, 226-231 |
| 超时清理等待 | 20s | agent-watchdog.ts:16 |
| 错误退避序列 | 30s, 1m, 5m, 15m, 60m | jobs-scheduling.ts:74-80 |
| 瞬时重试次数 | 3 | timer-trigger.ts:32 |
| 运行失败自动禁用阈值 | 10 | auto-disable.ts:15 |
| schedule 错误禁用阈值 | 3 | jobs-scheduling.ts:413 |
| 失败告警 after / cooldown | 2 / 1h | failure-alerts.ts:38-39 |
| 投递瞬时重试间隔 | 5s, 10s, 20s | delivery-dispatch-policy.ts:332 |
| 整点默认 stagger | 5min | stagger.ts:11 |
| run 会话保留 | 24h | session-reaper.ts:34 |