Skip to content

[Bug]: main/systemEvent cron heartbeat inherits global heartbeat.to and leaks topic reminder to DM #73900

@richardmqq

Description

@richardmqq

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions