Summary
executeMainSessionCronJob in src/cron/service/timer.ts has an early-exit path that
returns status: ok after fire-and-forgetting a heartbeat wake when:
wakeMode === "now"
- The first
runHeartbeatOnce call returns skipped with a retryable busy
reason (requests-in-flight, cron-in-progress, or lanes-busy)
- The job is recurring (
schedule.kind !== "at")
In that case the cron records status: ok with durationMs ~16ms even though the
agent never processed the cron event. This is functionally a "ghost run."
This is distinct from #73189 (closed): that fix made buildCronEventPrompt work
when the heartbeat does run. This bug is about the case where the heartbeat is
skipped and a follow-up wake is fire-and-forgotten — requestHeartbeatNow is
not awaited, and the queued cron event is not guaranteed to be consumed.
Environment
- OpenClaw
2026.4.29 (also reproduces on 4.27, 4.28 per code inspection)
- Linux, Node v25.6.1
- Cron job:
sessionTarget: "main", payload.kind: "systemEvent",
wakeMode: "now", schedule.kind: "cron" (i.e., recurring)
Evidence (real workload)
Daily 8 PM PT debrief cron, run history (last ~3 weeks). Config has been stable
the whole time (main + systemEvent + wakeMode:"now").
| Date |
Status |
dur (ms) |
delivery |
notes |
| Apr 12 (config switched to main+systemEvent) |
ok |
12314 |
not-requested |
first run on new shape |
| Apr 19 20:35 |
ok |
14 |
not-requested |
LATE-FIRE recovery (different) |
| Apr 20–22 |
ok |
8–11k |
not-requested |
OK |
| Apr 23 20:20 |
ok |
17 |
not-requested |
LATE-FIRE recovery (different) |
| Apr 24–30 |
ok |
5–9k |
not-requested |
OK |
| May 01 20:00 |
ok |
16 |
not-requested |
on-time GHOST — this bug |
18 of 19 on-time runs since Apr 12 produced full model turns. The May 01 ghost
fired exactly on schedule, so it is not a late-fire-recovery skip (those legitimately
short-circuit). The session trajectory at 20:00:27 shows the heartbeat ran with
prompt [OpenClaw heartbeat poll] (i.e., the bare-poll fallback), not the
expected cron-event provider with the payload text.
Steps to reproduce
- Configure a recurring cron with
sessionTarget: "main", payload.kind: "systemEvent",
wakeMode: "now", schedule.kind: "cron".
- Arrange for another heartbeat to be in-flight at the cron's scheduled time
(e.g., a 6h cadence main-agent heartbeat that lands within the same window;
common in real deployments).
- The first
runHeartbeatOnce call from executeMainSessionCronJob returns
skipped with reason: "requests-in-flight" (or "lanes-busy").
- The early-exit fires:
requestHeartbeatNow(...) (fire-and-forget) +
return { status: "ok", summary: text }.
- The follow-up heartbeat may or may not actually run before the queued cron
event is consumed by something else; if it does not, the cron status: ok
but no agent turn occurred.
Code analysis
Offending branch (4.29 dist/hook-client-ip-config-BCNYpeHn.js,
the bundled executeMainSessionCronJob):
if (heartbeatResult.status !== "skipped" || !isRetryableHeartbeatBusySkipReason(heartbeatResult.reason)) break;
if (isRecurringJob || heartbeatResult.reason === "cron-in-progress") {
state.deps.requestHeartbeatNow({ ... });
return { status: "ok", summary: text }; // ← ghost
}
The isRecurringJob || short-circuit means recurring jobs never enter the
retry-with-wait loop that one-shot jobs use. They always fire-and-forget on
busy.
Proposed fix
Drop the isRecurringJob || from the condition so recurring jobs use the same
retry loop as one-shot jobs. The existing 2-minute cap
(wakeNowHeartbeatBusyMaxWaitMs ?? 2 * 6e4) preserves the fire-and-forget
fallback for the genuinely-stuck case. Worst case = same as today's behavior;
best case = the busy heartbeat finishes within the cap and the cron actually
delivers.
- if (isRecurringJob || heartbeatResult.reason === "cron-in-progress") {
+ if (heartbeatResult.reason === "cron-in-progress") {
state.deps.requestHeartbeatNow({ ... });
return { status: "ok", summary: text };
}
Related
Summary
executeMainSessionCronJobinsrc/cron/service/timer.tshas an early-exit path thatreturns
status: okafter fire-and-forgetting a heartbeat wake when:wakeMode === "now"runHeartbeatOncecall returnsskippedwith a retryable busyreason (
requests-in-flight,cron-in-progress, orlanes-busy)schedule.kind !== "at")In that case the cron records
status: okwithdurationMs ~16mseven though theagent never processed the cron event. This is functionally a "ghost run."
This is distinct from #73189 (closed): that fix made
buildCronEventPromptworkwhen the heartbeat does run. This bug is about the case where the heartbeat is
skipped and a follow-up wake is fire-and-forgotten —
requestHeartbeatNowisnot awaited, and the queued cron event is not guaranteed to be consumed.
Environment
2026.4.29(also reproduces on 4.27, 4.28 per code inspection)sessionTarget: "main",payload.kind: "systemEvent",wakeMode: "now",schedule.kind: "cron"(i.e., recurring)Evidence (real workload)
Daily 8 PM PT debrief cron, run history (last ~3 weeks). Config has been stable
the whole time (
main + systemEvent + wakeMode:"now").18 of 19 on-time runs since Apr 12 produced full model turns. The May 01 ghost
fired exactly on schedule, so it is not a late-fire-recovery skip (those legitimately
short-circuit). The session trajectory at 20:00:27 shows the heartbeat ran with
prompt
[OpenClaw heartbeat poll](i.e., the bare-poll fallback), not theexpected
cron-eventprovider with the payload text.Steps to reproduce
sessionTarget: "main",payload.kind: "systemEvent",wakeMode: "now",schedule.kind: "cron".(e.g., a 6h cadence main-agent heartbeat that lands within the same window;
common in real deployments).
runHeartbeatOncecall fromexecuteMainSessionCronJobreturnsskippedwithreason: "requests-in-flight"(or"lanes-busy").requestHeartbeatNow(...)(fire-and-forget) +return { status: "ok", summary: text }.event is consumed by something else; if it does not, the cron
status: okbut no agent turn occurred.
Code analysis
Offending branch (4.29
dist/hook-client-ip-config-BCNYpeHn.js,the bundled
executeMainSessionCronJob):The
isRecurringJob ||short-circuit means recurring jobs never enter theretry-with-wait loop that one-shot jobs use. They always fire-and-forget on
busy.
Proposed fix
Drop the
isRecurringJob ||from the condition so recurring jobs use the sameretry loop as one-shot jobs. The existing 2-minute cap
(
wakeNowHeartbeatBusyMaxWaitMs ?? 2 * 6e4) preserves the fire-and-forgetfallback for the genuinely-stuck case. Worst case = same as today's behavior;
best case = the busy heartbeat finishes within the cap and the cron actually
delivers.
Related
retryable-busy-skip code path)