Skip to content

Heartbeat checks wrong session queue for exec completion events #14191

@LucasAIBuilder

Description

@LucasAIBuilder

Bug description

When an exec completes on a channel-specific session (e.g., a Discord channel or WhatsApp peer), the heartbeat fires but fails to detect the pending exec event. The heartbeat always returns HEARTBEAT_OK instead of relaying the exec result via EXEC_EVENT_PROMPT.

The root cause is a session key mismatch: exec events are enqueued under the peer-specific session key (e.g., agent:main:discord:channel:<channelId>), but the heartbeat runner always checks the main session key (agent:main:main).

Steps to reproduce

  1. Configure heartbeat with any target (e.g., target: "last")
  2. Send a message from Discord (or any channel) that triggers an exec
  3. Approve the exec
  4. Exec completes — requestHeartbeatNow({ reason: "exec-event" }) fires
  5. Heartbeat runs and responds with HEARTBEAT_OK instead of relaying the exec result
  6. The exec result is never delivered until the user sends another message in that channel session

Root cause

The system event queue (src/infra/system-events.ts) is a Map<string, SessionQueue> keyed by session key. Events are strictly isolated per key.

Enqueue path (src/gateway/server-node-events.ts):

// exec.finished handler
const sessionKey = typeof obj.sessionKey === "string"
  ? obj.sessionKey.trim()
  : `node-${nodeId}`;
// ...
enqueueSystemEvent(text, { sessionKey, contextKey: ... });
requestHeartbeatNow({ reason: "exec-event" });

The sessionKey comes from the node event payload — the peer-specific session key set during original message processing (e.g., agent:main:discord:channel:1469129735933132962).

Heartbeat check path (src/infra/heartbeat-runner.ts):

const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
// ...
const pendingEvents = isExecEvent || isCronEvent
  ? peekSystemEvents(sessionKey)  // <-- uses heartbeat's main session key
  : [];

resolveHeartbeatSession() always returns the main session key (agent:main:main) unless heartbeat.session is explicitly configured.

Result: peekSystemEvents("agent:main:main") finds nothing because the events live under "agent:main:discord:channel:...". hasExecCompletion is false, and the standard heartbeat prompt is used.

The wake handler (src/infra/heartbeat-wake.ts) only passes reason — no session key context:

export function requestHeartbeatNow(opts?: { reason?: string; coalesceMs?: number }) {
  pendingReason = opts?.reason ?? pendingReason ?? "requested";
  schedule(opts?.coalesceMs ?? DEFAULT_COALESCE_MS);
}

The originating session key is lost at this point.

Relationship to #7065

This is separate from #7065, which was about the reason check being too strict (reason === "exec-event" only). PR #11641 fixed #7065 by expanding which reasons trigger the event check. But even with the correct reason, peekSystemEvents() looks in the wrong queue — the session key mismatch is orthogonal to the reason filtering.

Suggested fix

The core problem is that requestHeartbeatNow() discards the originating session key. One approach:

  1. Extend requestHeartbeatNow() to accept an optional sessionKey parameter
  2. Pass the sessionKey from server-node-events.ts through the wake handler to runHeartbeatOnce()
  3. In runHeartbeatOnce(), use the originating session key (when provided) for peekSystemEvents() instead of the resolved heartbeat session key

Alternatively, peekSystemEvents() could scan all session queues (the queues Map), but that seems less targeted.

Agent prompt

When an exec completes on a channel-specific session (Discord, WhatsApp, Slack, etc.), the heartbeat fires with reason: "exec-event" but then checks peekSystemEvents() on the main session key (resolveHeartbeatSession()) instead of the session where the exec event was actually enqueued. This means exec results are never relayed by the heartbeat — it always falls through to HEARTBEAT_OK. The originating session key from the exec event needs to be propagated through requestHeartbeatNow() in src/infra/heartbeat-wake.ts so the heartbeat runner in src/infra/heartbeat-runner.ts can peek the correct session queue. Key files: server-node-events.ts (enqueue side), heartbeat-wake.ts (wake handler that loses the key), heartbeat-runner.ts (peek side), system-events.ts (the per-session queue Map).

Environment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstaleMarked as stale due to inactivity

    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