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
- Configure heartbeat with any target (e.g.,
target: "last")
- Send a message from Discord (or any channel) that triggers an exec
- Approve the exec
- Exec completes —
requestHeartbeatNow({ reason: "exec-event" }) fires
- Heartbeat runs and responds with
HEARTBEAT_OK instead of relaying the exec result
- 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:
- Extend
requestHeartbeatNow() to accept an optional sessionKey parameter
- Pass the
sessionKey from server-node-events.ts through the wake handler to runHeartbeatOnce()
- 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
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_OKinstead of relaying the exec result viaEXEC_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
target: "last")requestHeartbeatNow({ reason: "exec-event" })firesHEARTBEAT_OKinstead of relaying the exec resultRoot cause
The system event queue (
src/infra/system-events.ts) is aMap<string, SessionQueue>keyed by session key. Events are strictly isolated per key.Enqueue path (
src/gateway/server-node-events.ts):The
sessionKeycomes 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):resolveHeartbeatSession()always returns the main session key (agent:main:main) unlessheartbeat.sessionis explicitly configured.Result:
peekSystemEvents("agent:main:main")finds nothing because the events live under"agent:main:discord:channel:...".hasExecCompletionis false, and the standard heartbeat prompt is used.The wake handler (
src/infra/heartbeat-wake.ts) only passesreason— no session key context: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:requestHeartbeatNow()to accept an optionalsessionKeyparametersessionKeyfromserver-node-events.tsthrough the wake handler torunHeartbeatOnce()runHeartbeatOnce(), use the originating session key (when provided) forpeekSystemEvents()instead of the resolved heartbeat session keyAlternatively,
peekSystemEvents()could scan all session queues (thequeuesMap), 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 checkspeekSystemEvents()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 toHEARTBEAT_OK. The originating session key from the exec event needs to be propagated throughrequestHeartbeatNow()insrc/infra/heartbeat-wake.tsso the heartbeat runner insrc/infra/heartbeat-runner.tscan 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
target: "last", model:google/gemini-2.5-flashper-sender(default)