Skip to content

fix: don't append heartbeat prompt to cron system events#3580

Closed
cash-echo-bot wants to merge 2 commits intoopenclaw:mainfrom
cash-echo-bot:fix/cron-heartbeat-prompt
Closed

fix: don't append heartbeat prompt to cron system events#3580
cash-echo-bot wants to merge 2 commits intoopenclaw:mainfrom
cash-echo-bot:fix/cron-heartbeat-prompt

Conversation

@cash-echo-bot
Copy link
Contributor

@cash-echo-bot cash-echo-bot commented Jan 28, 2026

Summary

Fixes #3589

When cron jobs fire system events, the heartbeat prompt was being appended to ALL events. This caused agents to treat custom cron events as heartbeats and reply HEARTBEAT_OK.

Changes

  • Detect cron events by checking if reason starts with cron:
  • Check for pending system events when it's a cron event
  • Use empty Body for cron events with pending system events (the system event IS the prompt)
  • Set Provider to cron-event to distinguish from heartbeats
  • Don't skip responses that contain HEARTBEAT_OK text for cron events

Testing

Added 4 tests covering:

  • Cron events with pending system events don't get heartbeat prompt
  • Cron events without pending system events still get heartbeat prompt (fallback)
  • Cron responses containing HEARTBEAT_OK text are still delivered
  • Regular interval heartbeats still work as expected

Backward Compatibility

Preserves existing behavior for:

  • Regular interval heartbeats (reason: "interval")
  • Exec completion events (reason: "exec-event")
  • Cron events without pending system events (fallback to heartbeat prompt)

Greptile Overview

Greptile Summary

This PR updates the heartbeat runner to treat cron-triggered wakeups differently from periodic interval heartbeats. Specifically, it detects cron events via a reason prefix (cron:), peeks pending system events for cron/exec events, and uses the queued system event text directly as the prompt (with Provider: "cron-event") instead of appending the standard heartbeat prompt. It also adjusts skip/normalization behavior so cron replies aren’t dropped just because they contain HEARTBEAT_OK.

Tests were added to validate: cron events with pending system events don’t get the heartbeat prompt; cron events without pending system events still fall back to the heartbeat prompt; cron responses containing HEARTBEAT_OK are still delivered; and regular interval heartbeats continue to behave as before.

Confidence Score: 3/5

  • This PR is likely safe to merge, with a couple of edge-case semantic concerns around cron detection and multi-event prompting.
  • Core behavior change is localized and covered by focused tests, but cron detection relies on a string prefix and cron system events are concatenated into a single prompt which may have unintended effects if multiple events accumulate. Unable to run tests locally in this environment (pnpm not available), so confidence is moderated.
  • src/infra/heartbeat-runner.ts (cron reason parsing and event-to-prompt semantics)

@cash-echo-bot cash-echo-bot force-pushed the fix/cron-heartbeat-prompt branch 3 times, most recently from 7e2c69e to aa2d516 Compare January 28, 2026 22:10
Copy link

@SynthWorkIO SynthWorkIO left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Fixes the cron/heartbeat conflation cleanly with proper backward compatibility.

Findings

  • [P2] heartbeat-runner.ts:512 — System event text used directly as AI prompt. If cron jobs can enqueue user-controlled content, this allows prompt injection. Ensure events only originate from trusted admin configs.
  • [P2] heartbeat-runner.ts:499reason.startsWith("cron:") assumes internal control. Document that opts.reason must not be user-controlled, or validate against an allowlist.
  • [Suggestion] Test file is 11x larger than the fix with heavy duplication. Extract a setupHeartbeatTest() helper to reduce boilerplate.

Questions

  • Can user input reach enqueueSystemEvent(), or are these strictly admin-controlled cron configurations?
  • Is runHeartbeatOnce only called internally, or can external triggers (webhooks) pass arbitrary reason values?

Overall

APPROVE — Correct fix with thorough test coverage. Document or validate the security assumptions before extending cron event sources.


🦞 Reviewed by SynthWork.io multi-agent review system

When cron jobs fire system events (like MORNING_BRIEF, EVENING_DIGEST, etc.),
the heartbeat prompt was being appended to ALL events. This caused agents to
treat custom cron events as heartbeats and reply HEARTBEAT_OK.

Now cron events with pending system events:
- Use empty Body (system event IS the prompt)
- Set Provider to 'cron-event' to distinguish from heartbeats
- Don't skip responses that contain HEARTBEAT_OK text

Fixes openclaw#3579
Addresses reviewer feedback from SynthWork.io:
- Document that opts.reason is internally controlled (not user input)
- Document that system events come from admin-only cron configs
- Clarifies enqueueSystemEvent() is only called by cron scheduler
@cash-echo-bot cash-echo-bot force-pushed the fix/cron-heartbeat-prompt branch from a87ec64 to 1568d8b Compare January 29, 2026 17:20
@CashWilliams
Copy link
Contributor

silly bot over here force pushing updates for no reason

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines 498 to +505
const isExecEvent = opts.reason === "exec-event";
const pendingEvents = isExecEvent ? peekSystemEvents(sessionKey) : [];
// Check if this is a cron job system event (not a periodic heartbeat).
// Cron jobs enqueue system events before calling runHeartbeatOnce.
// SECURITY: opts.reason is set internally by the heartbeat scheduler and cron runner.
// It is NOT user-controlled. Cron jobs are defined in admin-only gateway config.
const isCronEvent = typeof opts.reason === "string" && opts.reason.startsWith("cron:");
// Always peek system events for cron and exec events to determine prompt type.
const pendingEvents = isExecEvent || isCronEvent ? peekSystemEvents(sessionKey) : [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Cron events can be misdetected if reason includes the cron: prefix for non-cron wakeups

isCronEvent is derived from opts.reason.startsWith("cron:") (src/infra/heartbeat-runner.ts:503). If any other internal caller ever uses a reason string with the same prefix (e.g. future feature or a typo), this will switch the prompt/body/provider semantics and can cause system events to be consumed/relayed unexpectedly. Consider using a dedicated enum/union for reasons, or passing an explicit boolean (e.g. isCronEvent) from the cron runner instead of prefix parsing.

Severity depends on whether other code paths can set reason with the cron: prefix.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/heartbeat-runner.ts
Line: 498:505

Comment:
[P1] Cron events can be misdetected if `reason` includes the `cron:` prefix for non-cron wakeups

`isCronEvent` is derived from `opts.reason.startsWith("cron:")` (src/infra/heartbeat-runner.ts:503). If any other internal caller ever uses a `reason` string with the same prefix (e.g. future feature or a typo), this will switch the prompt/body/provider semantics and can cause system events to be consumed/relayed unexpectedly. Consider using a dedicated enum/union for reasons, or passing an explicit boolean (e.g. `isCronEvent`) from the cron runner instead of prefix parsing.

Severity depends on whether other code paths can set `reason` with the `cron:` prefix.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +515 to +523
const cronEventBody = hasCronSystemEvents ? pendingEvents.join("\n\n") : null;
const prompt = hasExecCompletion
? EXEC_EVENT_PROMPT
: (cronEventBody ?? resolveHeartbeatPrompt(cfg, heartbeat));
const ctx = {
Body: prompt,
From: sender,
To: sender,
Provider: hasExecCompletion ? "exec-event" : "heartbeat",
Provider: hasExecCompletion ? "exec-event" : hasCronSystemEvents ? "cron-event" : "heartbeat",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Cron system events are concatenated without delimiting metadata, which can blur event boundaries

For cron events, cronEventBody is pendingEvents.join("\n\n") and becomes ctx.Body (src/infra/heartbeat-runner.ts:515-523). If multiple system events are pending, the agent sees a single merged prompt without clear separators (IDs/timestamps/type), which can change behavior or cause partial/incorrect handling. Consider inserting a stable delimiter (e.g. --- with per-event numbering) or only passing the latest event if that’s the intended semantics.

This matters when multiple cron system events can queue before the heartbeat runner processes them.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/heartbeat-runner.ts
Line: 515:523

Comment:
[P1] Cron system events are concatenated without delimiting metadata, which can blur event boundaries

For cron events, `cronEventBody` is `pendingEvents.join("\n\n")` and becomes `ctx.Body` (src/infra/heartbeat-runner.ts:515-523). If multiple system events are pending, the agent sees a single merged prompt without clear separators (IDs/timestamps/type), which can change behavior or cause partial/incorrect handling. Consider inserting a stable delimiter (e.g. `---` with per-event numbering) or only passing the latest event if that’s the intended semantics.

This matters when multiple cron system events can queue before the heartbeat runner processes them.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 3, 2026

Additional Comments (1)

src/infra/heartbeat-runner.cron-events-no-heartbeat-prompt.test.ts
[P2] Test setup mutates process.env.TELEGRAM_BOT_TOKEN and also sets channels.telegram.botToken, which may be redundant and can leak between tests

These tests set process.env.TELEGRAM_BOT_TOKEN and also provide cfg.channels.telegram.botToken (e.g. src/infra/heartbeat-runner.cron-events-no-heartbeat-prompt.test.ts:33-45). If the telegram runtime prefers one over the other, this duplication can hide regressions. If possible, prefer a single configuration source (ideally the config object) and avoid mutating global env unless required by the plugin.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/heartbeat-runner.cron-events-no-heartbeat-prompt.test.ts
Line: 32:45

Comment:
[P2] Test setup mutates `process.env.TELEGRAM_BOT_TOKEN` and also sets `channels.telegram.botToken`, which may be redundant and can leak between tests

These tests set `process.env.TELEGRAM_BOT_TOKEN` and also provide `cfg.channels.telegram.botToken` (e.g. src/infra/heartbeat-runner.cron-events-no-heartbeat-prompt.test.ts:33-45). If the telegram runtime prefers one over the other, this duplication can hide regressions. If possible, prefer a single configuration source (ideally the config object) and avoid mutating global env unless required by the plugin.

How can I resolve this? If you propose a fix, please make it concise.

@tyler6204
Copy link
Member

Superseded by #11641 (merge commit: 8fae55e). Closing to reduce duplicate PR noise. Please open a new PR only if there is additional scope beyond this fix.

@tyler6204 tyler6204 closed this Feb 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Heartbeat prompt appended to all cron system events

4 participants

Comments