fix: don't append heartbeat prompt to cron system events#3580
fix: don't append heartbeat prompt to cron system events#3580cash-echo-bot wants to merge 2 commits intoopenclaw:mainfrom
Conversation
7e2c69e to
aa2d516
Compare
SynthWorkIO
left a comment
There was a problem hiding this comment.
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:499—reason.startsWith("cron:")assumes internal control. Document thatopts.reasonmust 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
runHeartbeatOnceonly called internally, or can external triggers (webhooks) pass arbitraryreasonvalues?
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
a87ec64 to
1568d8b
Compare
|
silly bot over here force pushing updates for no reason |
| 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) : []; |
There was a problem hiding this 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.
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.| 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", |
There was a problem hiding this 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.
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.
Additional Comments (1)
These tests set Prompt To Fix With AIThis 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. |
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
reasonstarts withcron:Bodyfor cron events with pending system events (the system event IS the prompt)Providertocron-eventto distinguish from heartbeatsHEARTBEAT_OKtext for cron eventsTesting
Added 4 tests covering:
HEARTBEAT_OKtext are still deliveredBackward Compatibility
Preserves existing behavior for:
reason: "interval")reason: "exec-event")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
reasonprefix (cron:), peeks pending system events for cron/exec events, and uses the queued system event text directly as the prompt (withProvider: "cron-event") instead of appending the standard heartbeat prompt. It also adjusts skip/normalization behavior so cron replies aren’t dropped just because they containHEARTBEAT_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_OKare still delivered; and regular interval heartbeats continue to behave as before.Confidence Score: 3/5