openclaw - ✅(Solved) Fix [Bug]: main/systemEvent cron heartbeat inherits global heartbeat.to and leaks topic reminder to DM [2 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#73900Fetched 2026-04-29 06:13:33
View on GitHub
Comments
1
Participants
2
Timeline
3
Reactions
0
Timeline (top)
cross-referenced ×2commented ×1

A sessionTarget: "main" + payload.kind: "systemEvent" cron job that was bound to a Telegram forum topic session woke the correct topic heartbeat session, but its reminder reply was delivered to the user's Telegram DM instead of the originating topic.

This looks like a core delivery/heartbeat merge bug: the cron main-session path calls runHeartbeatOnce({ heartbeat: { target: "last" } }), but that shallow override still inherits the global agents.defaults.heartbeat.to value. The inherited explicit to then wins over the intended session-bound last route, causing a cross-context delivery leak.

Error Message

The cron fired and created/used the expected topic heartbeat session:

Root Cause

This is a privacy / cross-context routing issue. A reminder created from a group/forum topic can leak into a DM if the global heartbeat config has a DM to configured.

PR fix notes

PR #73968: fix(cron): keep system events on session route

Description (problem / solution / changelog)

Summary

  • Fixes #73900.
  • Keeps cron sessionTarget: "main" systemEvent heartbeat wakes on the bound session route when cron forces heartbeat.target = "last".
  • Adds a gateway cron regression test and changelog entry.

Root cause

The cron wake adapter merged the cron override { target: "last" } over the resolved heartbeat defaults, but kept inherited explicit destination fields like heartbeat.to and heartbeat.accountId. Downstream heartbeat delivery treats those fields as explicit routing inputs, so a global DM destination could override the session-bound group/topic route.

Why This Is Safe

The change is limited to cron-supplied target: "last" overrides. It preserves non-routing heartbeat settings such as cadence, prompt, and direct-message policy, while removing inherited explicit destination fields so the existing session delivery context remains authoritative for the wake.

Security / Runtime Controls

Runtime delivery resolution, allowlist checks, account validation, direct-message blocking policy, systemEvent queuing, and heartbeat execution controls are unchanged. The fix does not rely on prompt text; it removes the conflicting inherited routing inputs before runtime delivery is resolved.

Tests

  • pnpm test src/gateway/server-cron.test.ts -- --reporter=verbose
  • git diff --check
  • pnpm check:changed

Out Of Scope

  • No changes to general heartbeat delivery semantics outside cron-forced target: "last" wakes.
  • No changes to Telegram-specific routing, plugin target parsing, or cron payload handoff behavior.
  • No broad refactors of heartbeat config typing or cron service internals.

Made with Cursor

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • src/gateway/server-cron.test.ts (modified, +73/-0)
  • src/gateway/server-cron.ts (modified, +17/-1)

PR #74053: fix(heartbeat): derive ctx.To from delivery target, not sender sentinel

Description (problem / solution / changelog)

What

In src/infra/heartbeat-runner.ts, the heartbeat turn's runtime context sets both From and To to the resolved sender. When sender falls back to the literal "heartbeat" sentinel (the documented default in resolveHeartbeatSenderId, returned when no candidates resolve), ctx.To = "heartbeat" then propagates into the persisted session's lastTo via resolveLastToRaw in src/auto-reply/reply/session-delivery.ts:

return params.originatingToRaw || params.toRaw || params.persistedLastTo;

When originatingToRaw (= delivery.to) is empty (e.g. crons with delivery.mode: "none"), the fallback returns toRaw = ctx.To = "heartbeat", which is then written into the session's lastTo and deliveryContext.to. From that point, every later message dispatched from that session with delivery.channel: "last" resolves the recipient to the literal string "heartbeat", which Telegram rejects with getChat 400 Bad Request: chat not found. The failed payloads pile up in delivery-queue/failed/ indefinitely until a human edits the session store.

This was diagnosed in production after agent:main:main accumulated 3+ phantom @heartbeat deliveries across days; manually resetting sessions.json worked until the next heartbeat path ran without a real recipient and re-corrupted it.

Fix

Derive ctx.To from the resolved delivery target rather than mirroring sender:

   const ctx = {
     Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),
     From: sender,
-    To: sender,
+    To: delivery.to,
     OriginatingChannel:
       !suppressOriginatingContext && delivery.channel !== "none" ? delivery.channel : undefined,
     OriginatingTo: !suppressOriginatingContext ? delivery.to : undefined,

When delivery.to is set (the case existing tests exercise), sender already equals delivery.to, so ctx.To is unchanged. When delivery.to is undefined (the bug case), ctx.To is now undefined and resolveLastToRaw correctly falls through to persistedLastTo, preserving the real recipient already on the session.

From: sender is left alone — the "heartbeat" sentinel as a synthetic identifier is still useful in the LLM prompt to label self-events.

Tests

Existing tests still pass (heartbeat-runner.sender-prefers-delivery-target.test.ts, heartbeat-runner.returns-default-unset.test.ts) — they assert ctx.To === delivery.to in scenarios where sender resolves to the same value, which still holds.

New regression test heartbeat-runner.does-not-leak-sender-into-to.test.ts:

  • Configures heartbeat.target: "none" with a session that already has a real lastTo
  • Enqueues an actionable system event so runHeartbeatOnce actually invokes the reply spy
  • Asserts ctx.To !== "heartbeat" (the sentinel)
  • Asserts the session's persisted lastTo is preserved across the heartbeat turn

Why this matters

This is a delivery-routing data-corruption bug with privacy implications: once a session's lastTo is corrupted, subsequent operator-authored messages routed through delivery.channel: "last" from that session can mis-deliver, and (in the steady-state Telegram failure mode) leak proactive reminder content into the failed-queue file on disk where it sits visible to anyone with filesystem access. In production the failed queue accumulated reminders containing dependents' names and schedules.

Related

  • #73900 — different heartbeat routing leak (global heartbeat.to overriding session last); same area of code, distinct fix.
  • #63733, #65693, #21235 — historical poisoned-deliveryContext bugs in the heartbeat path.

Checklist

  • Fix applied to src/infra/heartbeat-runner.ts
  • Regression test added
  • Existing related tests pass
  • CHANGELOG entry added under Unreleased / Fixes

🤖 Generated with Claude Code

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • src/infra/heartbeat-runner.does-not-leak-sender-into-to.test.ts (added, +90/-0)
  • src/infra/heartbeat-runner.ts (modified, +6/-1)

Code Example

{
  "agents": {
    "defaults": {
      "heartbeat": {
        "accountId": "default",
        "every": "1h",
        "isolatedSession": true,
        "model": "openai-codex/gpt-5.5",
        "session": "main",
        "target": "none",
        "to": "telegram:<user-dm-id>"
      }
    }
  }
}

---

{
  "sessionTarget": "main",
  "wakeMode": "now",
  "payload": {
    "kind": "systemEvent",
    "text": "Reminder: ..."
  },
  "sessionKey": "agent:main:telegram:group:<group-id>:topic:<topic-id>"
}

---

agent:main:telegram:group:<group-id>:topic:<topic-id>:heartbeat

---

cron job created at 07:47:54
original topic session final messages delivered to the topic at 07:47:59 / 07:48:01
heartbeat session started at 08:00:08:
  agent:main:telegram:group:<group-id>:topic:<topic-id>:heartbeat
runtime context chat_id for the heartbeat turn:
  <user-dm-id>
Telegram sent map at 08:00:44:
  <user-dm-id> -> message <dm-message-id>

---

{
  "chatType": "group",
  "deliveryContext": {
    "channel": "telegram",
    "to": "telegram:<group-id>",
    "accountId": "default",
    "threadId": <topic-id>
  },
  "lastChannel": "telegram",
  "lastTo": "telegram:<group-id>",
  "lastThreadId": <topic-id>
}

---

agent:main:telegram:group:<group-id>:topic:<topic-id>

---

runHeartbeatOnce({
  reason,
  agentId,
  sessionKey: targetMainSessionKey,
  heartbeat: { target: "last" }
})

---

const heartbeatOverride = opts?.heartbeat
  ? { ...baseHeartbeat, ...opts.heartbeat }
  : undefined
RAW_BUFFERClick to expand / collapse

Summary

A sessionTarget: "main" + payload.kind: "systemEvent" cron job that was bound to a Telegram forum topic session woke the correct topic heartbeat session, but its reminder reply was delivered to the user's Telegram DM instead of the originating topic.

This looks like a core delivery/heartbeat merge bug: the cron main-session path calls runHeartbeatOnce({ heartbeat: { target: "last" } }), but that shallow override still inherits the global agents.defaults.heartbeat.to value. The inherited explicit to then wins over the intended session-bound last route, causing a cross-context delivery leak.

Why this matters

This is a privacy / cross-context routing issue. A reminder created from a group/forum topic can leak into a DM if the global heartbeat config has a DM to configured.

Environment

  • OpenClaw: v2026.4.26
  • Channel: Telegram
  • Scenario: Telegram forum supergroup topic -> user's Telegram DM
  • Cron payload: sessionTarget: "main", payload.kind: "systemEvent", wakeMode: "now"

Relevant config shape

Global heartbeat defaults include an explicit DM target:

{
  "agents": {
    "defaults": {
      "heartbeat": {
        "accountId": "default",
        "every": "1h",
        "isolatedSession": true,
        "model": "openai-codex/gpt-5.5",
        "session": "main",
        "target": "none",
        "to": "telegram:<user-dm-id>"
      }
    }
  }
}

A one-shot cron reminder was created from a Telegram forum topic session:

{
  "sessionTarget": "main",
  "wakeMode": "now",
  "payload": {
    "kind": "systemEvent",
    "text": "Reminder: ..."
  },
  "sessionKey": "agent:main:telegram:group:<group-id>:topic:<topic-id>"
}

Observed behavior

The cron fired and created/used the expected topic heartbeat session:

agent:main:telegram:group:<group-id>:topic:<topic-id>:heartbeat

However, the runtime context for that heartbeat turn showed the DM chat as the delivery context, and the final reminder reply was sent to the user's Telegram DM.

Evidence from local logs/session store (IDs redacted):

cron job created at 07:47:54
original topic session final messages delivered to the topic at 07:47:59 / 07:48:01
heartbeat session started at 08:00:08:
  agent:main:telegram:group:<group-id>:topic:<topic-id>:heartbeat
runtime context chat_id for the heartbeat turn:
  <user-dm-id>
Telegram sent map at 08:00:44:
  <user-dm-id> -> message <dm-message-id>

The session store for the originating topic had the correct delivery context:

{
  "chatType": "group",
  "deliveryContext": {
    "channel": "telegram",
    "to": "telegram:<group-id>",
    "accountId": "default",
    "threadId": <topic-id>
  },
  "lastChannel": "telegram",
  "lastTo": "telegram:<group-id>",
  "lastThreadId": <topic-id>
}

Expected behavior

For a cron systemEvent bound to a session key like:

agent:main:telegram:group:<group-id>:topic:<topic-id>

the heartbeat wake should deliver any user-visible reminder reply back to that bound session route, including the forum topic thread id.

At minimum, if the cron path overrides heartbeat target to last, inherited global heartbeat.to should not override the session-bound route.

Actual behavior

The explicit global agents.defaults.heartbeat.to appears to be inherited during the cron wake. That explicit DM to overrides the intended target: "last" route, so the reminder reply is delivered to DM.

Likely root cause

In the main/systemEvent cron execution path, the runtime calls something equivalent to:

runHeartbeatOnce({
  reason,
  agentId,
  sessionKey: targetMainSessionKey,
  heartbeat: { target: "last" }
})

The server wrapper then merges this shallowly with the configured heartbeat defaults:

const heartbeatOverride = opts?.heartbeat
  ? { ...baseHeartbeat, ...opts.heartbeat }
  : undefined

Because opts.heartbeat only sets target: "last", other default fields remain, including to: "telegram:<user-dm-id>".

Later heartbeat delivery resolution treats explicit heartbeat.to as an explicit destination, so it wins over the session's delivery context.

Suggested fix

When cron main/systemEvent forces heartbeat.target = "last", it should either:

  1. clear inherited explicit destination fields (to, possibly channel/accountId if appropriate), or
  2. use a dedicated non-inheriting heartbeat override for cron/systemEvent wakes, or
  3. make target: "last" semantically ignore inherited to unless to was explicitly supplied in the same override object.

The safest behavior is probably: session-bound systemEvent cron wakes should route to the bound session delivery context, not to global heartbeat.to.

Related issues

  • #47865 — systemEvent messages visible to user despite delivery none
  • #45806 — closed, heartbeat/last route resolved to @heartbeat
  • #73777 / #73189 — current open issues around sessionTarget: main + systemEvent handoff

extent analysis

TL;DR

The issue can be fixed by modifying the cron main/systemEvent execution path to clear inherited explicit destination fields when heartbeat.target is set to "last".

Guidance

  • Identify the cron main/systemEvent execution path and modify it to clear inherited explicit destination fields (to, channel, accountId) when heartbeat.target is set to "last".
  • Consider using a dedicated non-inheriting heartbeat override for cron/systemEvent wakes to avoid overwriting session-bound delivery contexts.
  • Verify that the target: "last" semantic ignores inherited to unless to was explicitly supplied in the same override object.
  • Review related issues (#47865, #45806, #73777, #73189) to ensure that the fix does not introduce new problems.

Example

const heartbeatOverride = opts?.heartbeat
 ? { 
     ...baseHeartbeat, 
     ...opts.heartbeat, 
      to: undefined, // clear inherited explicit destination field
      channel: undefined,
      accountId: undefined
    }
  : undefined

Notes

The suggested fix assumes that the target: "last" semantic should take precedence over inherited to fields. However, this may not be the case in all scenarios, and additional testing may be required to ensure that the fix works as expected.

Recommendation

Apply the suggested fix by modifying the cron main/systemEvent execution path to clear inherited explicit destination fields when heartbeat.target is set to "last", as this is the safest behavior to ensure that session-bound systemEvent cron wakes route to the bound session delivery context.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

For a cron systemEvent bound to a session key like:

agent:main:telegram:group:<group-id>:topic:<topic-id>

the heartbeat wake should deliver any user-visible reminder reply back to that bound session route, including the forum topic thread id.

At minimum, if the cron path overrides heartbeat target to last, inherited global heartbeat.to should not override the session-bound route.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING