Skip to content

[Bug]: Recurring main-session cron jobs ghost (status:ok, durationMs ~16ms) when first heartbeat hits a retryable busy skip — early-exit fire-and-forgets without consuming queued cron event #75964

Description

@kshetrajna12

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:

  1. wakeMode === "now"
  2. The first runHeartbeatOnce call returns skipped with a retryable busy
    reason (requests-in-flight, cron-in-progress, or lanes-busy)
  3. 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

  1. Configure a recurring cron with sessionTarget: "main", payload.kind: "systemEvent",
    wakeMode: "now", schedule.kind: "cron".
  2. 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).
  3. The first runHeartbeatOnce call from executeMainSessionCronJob returns
    skipped with reason: "requests-in-flight" (or "lanes-busy").
  4. The early-exit fires: requestHeartbeatNow(...) (fire-and-forget) +
    return { status: "ok", summary: text }.
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions