Skip to content

Bug: Cron job system events are ignored during heartbeat execution #7065

@i-smile

Description

@i-smile

Bug: Cron job system events are ignored during heartbeat execution

Summary

When a Cron job with sessionTarget: "main" and wakeMode: "now" triggers, the system event injected via enqueueSystemEvent() is completely ignored by the heartbeat runner. Instead of processing the Cron reminder, the AI reads HEARTBEAT.md and responds with HEARTBEAT_OK, causing the user to never receive the scheduled reminder.

Environment

  • OpenClaw version: (latest main branch)
  • OS: Windows 11
  • Node.js: 22.x

Steps to Reproduce

  1. Create a one-shot Cron job:

    Tell AI: "1 minute later, remind me to eat"
    
  2. AI creates a job like:

    {
      "schedule": { "kind": "at", "atMs": ... },
      "payload": { "kind": "systemEvent", "text": "🍽️ Reminder: Time to eat!" },
      "sessionTarget": "main",
      "wakeMode": "now",
      "enabled": true
    }
  3. When the Cron job triggers, the user receives HEARTBEAT_OK instead of the reminder

Root Cause Analysis

Issue 1: Cron events skipped when HEARTBEAT.md is empty

File: src/infra/heartbeat-runner.ts (lines 506-520)

const isExecEventReason = opts.reason === "exec-event";
// ...
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !isExecEventReason) {
  return { status: "skipped", reason: "empty-heartbeat-file" };  // ← Cron events skipped!
}

The code only exempts exec-event from the empty file check, but Cron events (cron:xxx) are not exempted.

Issue 2: Cron system events are never read (CRITICAL)

File: src/infra/heartbeat-runner.ts (lines 544-545)

const isExecEvent = opts.reason === "exec-event";
const pendingEvents = isExecEvent ? peekSystemEvents(sessionKey) : [];
//                                  ↑ Only exec-event reads system events!

When reason is cron:xxx, pendingEvents is always an empty array, so the system event injected by enqueueSystemEvent() is completely ignored.

Issue 3: AI uses standard heartbeat prompt instead of Cron prompt

Because pendingEvents is empty for Cron events, the AI receives the standard HEARTBEAT_PROMPT:

"Read HEARTBEAT.md if it exists... If nothing needs attention, reply HEARTBEAT_OK."

Instead of being told about the reminder, the AI reads HEARTBEAT.md and responds with HEARTBEAT_OK.

Suggested Fix

// src/infra/heartbeat-runner.ts

// Line ~509: Add Cron check for empty file exemption
const isExecEventReason = opts.reason === "exec-event";
const isCronReason = opts.reason?.startsWith("cron:");

// Line ~514: Exempt Cron events from empty file skip
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !isExecEventReason && !isCronReason) {
  // ...
}

// Line ~544-545: Read system events for Cron too
const isExecEvent = opts.reason === "exec-event";
const isCronEvent = opts.reason?.startsWith("cron:");
const shouldCheckEvents = isExecEvent || isCronEvent;
const pendingEvents = shouldCheckEvents ? peekSystemEvents(sessionKey) : [];

// Line ~548: Use appropriate prompt for Cron events
const hasCronEvents = isCronEvent && pendingEvents.length > 0;
const CRON_EVENT_PROMPT = 
  `A scheduled reminder has been triggered. The reminder message is shown in the system events above. ` +
  `Please relay this reminder to the user in a helpful way.`;

const prompt = hasExecCompletion 
  ? EXEC_EVENT_PROMPT 
  : hasCronEvents 
    ? CRON_EVENT_PROMPT
    : resolveHeartbeatPrompt(cfg, heartbeat);

Additional Issues Found

Issue 4: enabled defaults to undefined instead of true

File: src/cron/service/jobs.ts (line 97)

The tool description says "enabled defaults to true", but the code doesn't apply this default:

enabled: input.enabled,  // ← Should be: input.enabled !== false

Issue 5: wakeMode defaults to next-heartbeat for one-shot jobs

File: src/cron/normalize.ts (lines 121-124)

For reminder-type one-shot jobs (schedule.kind: "at"), wakeMode should default to "now" to ensure immediate delivery:

if (!next.wakeMode) {
  const isOneShot = isRecord(next.schedule) && next.schedule.kind === "at";
  next.wakeMode = isOneShot ? "now" : "next-heartbeat";
}

Impact

  • Severity: High
  • Affected Feature: Cron reminders with sessionTarget: "main"
  • User Impact: Scheduled reminders never reach the user

Workaround

Use sessionTarget: "isolated" with payload.kind: "agentTurn" instead, which bypasses the heartbeat mechanism entirely.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions