-
-
Notifications
You must be signed in to change notification settings - Fork 52.7k
Description
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
-
Create a one-shot Cron job:
Tell AI: "1 minute later, remind me to eat" -
AI creates a job like:
{ "schedule": { "kind": "at", "atMs": ... }, "payload": { "kind": "systemEvent", "text": "🍽️ Reminder: Time to eat!" }, "sessionTarget": "main", "wakeMode": "now", "enabled": true } -
When the Cron job triggers, the user receives
HEARTBEAT_OKinstead 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 !== falseIssue 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.