From d57cbc5244607e8ea2e2671fec40e7528cc4263d Mon Sep 17 00:00:00 2001 From: eviaaaaa <2278596667@qq.com> Date: Fri, 20 Feb 2026 15:05:49 +0800 Subject: [PATCH 1/5] fix(heartbeat): propagate sessionKey in exec/hooks to fix async context loss This commit fixes a critical issue where asynchronous execution results and webhook wakes were failing to reach users in non-main sessions (such as Discord threads, DMs, or Slack channels). The root cause was that `requestHeartbeatNow` was being called without a `sessionKey`. This caused the heartbeat system to: 1. Default to the Main Session. 2. Coalesce unrelated wake requests into a single generic 'default' wake, losing the specific session context needed to flush the event queue. Changes: - agents: `emitExecSystemEvent` now strictly propagates the `sessionKey` to `requestHeartbeatNow`. - gateway: `dispatchWakeHook` now includes the target `sessionKey` from the wake request. - gateway: Node events (`exec.started`, `exec.finished`, `exec.denied`) now carry the originating `sessionKey` to the heartbeat system. - test: Updated `server-node-events.test.ts` to assert that `sessionKey` is correctly passed when requesting heartbeats. # Conflicts: # src/agents/bash-tools.exec-runtime.ts # src/gateway/server-node-events.ts --- src/gateway/server-node-events.test.ts | 14 +++++++++++--- src/gateway/server/hooks.ts | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index a5a7578ddbc..d58be257ec6 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -154,14 +154,18 @@ describe("node exec events", () => { exitCode: 0, timedOut: false, output: "done", + sessionKey: "agent:test:main", }), }); expect(enqueueSystemEventMock).toHaveBeenCalledWith( "Exec finished (node=node-2 id=run-2, code 0)\ndone", - { sessionKey: "node-node-2", contextKey: "exec:run-2" }, + { sessionKey: "agent:test:main", contextKey: "exec:run-2" }, ); - expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" }); + expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ + reason: "exec-event", + sessionKey: "agent:test:main", + }); }); it("suppresses noisy exec.finished success events with empty output", async () => { @@ -189,6 +193,7 @@ describe("node exec events", () => { exitCode: 0, timedOut: false, output: "x".repeat(600), + sessionKey: "agent:test:main", }), }); @@ -197,7 +202,10 @@ describe("node exec events", () => { expect(text.startsWith("Exec finished (node=node-2 id=run-long, code 0)\n")).toBe(true); expect(text.endsWith("…")).toBe(true); expect(text.length).toBeLessThan(280); - expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" }); + expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ + reason: "exec-event", + sessionKey: "agent:test:main", + }); }); it("enqueues exec.denied events with reason", async () => { diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 0ba718adcc3..844b5f3a7d3 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -37,7 +37,7 @@ export function createGatewayHooksRequestHandler(params: { const sessionKey = resolveMainSessionKeyFromConfig(); enqueueSystemEvent(value.text, { sessionKey }); if (value.mode === "now") { - requestHeartbeatNow({ reason: "hook:wake" }); + requestHeartbeatNow({ reason: "hook:wake", sessionKey }); } }; From fc6fde280f5f2a244d2fa47e047c2884d37665aa Mon Sep 17 00:00:00 2001 From: eviaaaaa <2278596667@qq.com> Date: Fri, 20 Feb 2026 15:29:28 +0800 Subject: [PATCH 2/5] fix(gateway): pass sessionKey to heartbeat in hooks handlers Addresses code review feedback by ensuring the sessionKey is consistently propagated to requestHeartbeatNow in both the success and error paths. --- src/gateway/server/hooks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 844b5f3a7d3..7bfbdbd6646 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -94,7 +94,7 @@ export function createGatewayHooksRequestHandler(params: { sessionKey: mainSessionKey, }); if (value.wakeMode === "now") { - requestHeartbeatNow({ reason: `hook:${jobId}` }); + requestHeartbeatNow({ reason: `hook:${jobId}`, sessionKey: mainSessionKey }); } } } catch (err) { @@ -103,7 +103,7 @@ export function createGatewayHooksRequestHandler(params: { sessionKey: mainSessionKey, }); if (value.wakeMode === "now") { - requestHeartbeatNow({ reason: `hook:${jobId}:error` }); + requestHeartbeatNow({ reason: `hook:${jobId}:error`, sessionKey: mainSessionKey }); } } })(); From f7d8b28d21465543a6f20b415aea780365a744e4 Mon Sep 17 00:00:00 2001 From: eviaaaaa <2278596667@qq.com> Date: Sat, 7 Mar 2026 00:39:25 +0800 Subject: [PATCH 3/5] fix(heartbeat): relay exec-event responses to last session channel when target is none Ensure async exec completion messages are delivered back to the originating session channel even when heartbeat target is configured as "none". - add `forceLastTargetWhenNone` to heartbeat delivery target resolution - enable the fallback for exec-event runs in heartbeat runner - update target-none exec test to assert delivery to last channel This keeps cron reminders silent under target=none, while restoring expected exec result routing to the original channel (e.g. Discord). --- src/infra/heartbeat-runner.ghost-reminder.test.ts | 9 +++++---- ...heartbeat-runner.returns-default-unset.test.ts | 15 ++++++++++----- src/infra/heartbeat-runner.ts | 7 ++++++- src/infra/outbound/targets.ts | 10 +++++++++- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index f215b8313d1..04b739f2066 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -216,10 +216,10 @@ describe("Ghost reminder bug (issue #13317)", () => { expect(sendTelegram).not.toHaveBeenCalled(); }); - it("uses an internal-only exec prompt when delivery target is none", async () => { + it("relays exec prompt to last channel when delivery target is none", async () => { const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ tmpPrefix: "openclaw-exec-internal-", - replyText: "Handled internally", + replyText: "Exec completed successfully", reason: "exec-event", target: "none", enqueue: (sessionKey) => { @@ -229,7 +229,8 @@ describe("Ghost reminder bug (issue #13317)", () => { expect(result.status).toBe("ran"); expect(calledCtx?.Provider).toBe("exec-event"); - expect(calledCtx?.Body).toContain("Handle the result internally"); - expect(sendTelegram).not.toHaveBeenCalled(); + expect(calledCtx?.Body).toContain("Please relay the command output to the user"); + expect(calledCtx?.Body).not.toContain("Handle the result internally"); + expect(sendTelegram).toHaveBeenCalled(); }); }); diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 8bca1ca1de7..71b12f20feb 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -1332,7 +1332,7 @@ describe("runHeartbeatOnce", () => { } }); - it("uses an internal-only exec prompt when heartbeat delivery target is none", async () => { + it("relays exec completion to last channel when heartbeat delivery target is none", async () => { const tmpDir = await createCaseDir("hb-exec-target-none"); const storePath = path.join(tmpDir, "sessions.json"); const cfg: OpenClawConfig = { @@ -1363,7 +1363,7 @@ describe("runHeartbeatOnce", () => { }); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); - replySpy.mockResolvedValue({ text: "Handled internally" }); + replySpy.mockResolvedValue({ text: "Exec completed successfully" }); const sendWhatsApp = vi .fn< (to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }> @@ -1377,11 +1377,16 @@ describe("runHeartbeatOnce", () => { deps: createHeartbeatDeps(sendWhatsApp), }); expect(res.status).toBe("ran"); - expect(sendWhatsApp).toHaveBeenCalledTimes(0); + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + expect(sendWhatsApp).toHaveBeenCalledWith( + "120363401234567890@g.us", + "Exec completed successfully", + expect.objectContaining({ accountId: undefined }), + ); const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string }; expect(calledCtx.Provider).toBe("exec-event"); - expect(calledCtx.Body).toContain("Handle the result internally"); - expect(calledCtx.Body).not.toContain("Please relay the command output to the user"); + expect(calledCtx.Body).toContain("Please relay the command output to the user"); + expect(calledCtx.Body).not.toContain("Handle the result internally"); } finally { replySpy.mockRestore(); } diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 5e6ddcf07cf..8364c0dbf81 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -598,7 +598,12 @@ export async function runHeartbeatOnce(opts: { runStorePath = cronSession.storePath; } - const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); + const delivery = resolveHeartbeatDeliveryTarget({ + cfg, + entry, + heartbeat, + forceLastTargetWhenNone: preflight.isExecEventReason, + }); const heartbeatAccountId = heartbeat?.accountId?.trim(); if (delivery.reason === "unknown-account") { log.warn("heartbeat: unknown accountId", { diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 2d294efbef9..6863430153b 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -254,9 +254,11 @@ export function resolveHeartbeatDeliveryTarget(params: { cfg: OpenClawConfig; entry?: SessionEntry; heartbeat?: AgentDefaultsConfig["heartbeat"]; + forceLastTargetWhenNone?: boolean; }): OutboundTarget { const { cfg, entry } = params; const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat; + const forceLastTargetWhenNone = params.forceLastTargetWhenNone === true; const rawTarget = heartbeat?.target; let target: HeartbeatTarget = "none"; if (rawTarget === "none" || rawTarget === "last") { @@ -268,7 +270,7 @@ export function resolveHeartbeatDeliveryTarget(params: { } } - if (target === "none") { + if (target === "none" && !forceLastTargetWhenNone) { const base = resolveSessionDeliveryTarget({ entry }); return buildNoHeartbeatDeliveryTarget({ reason: "target-none", @@ -277,6 +279,12 @@ export function resolveHeartbeatDeliveryTarget(params: { }); } + // For async exec completion events, always fall back to the session's last + // delivery target even when heartbeat target is explicitly set to "none". + if (target === "none" && forceLastTargetWhenNone) { + target = "last"; + } + const resolvedTarget = resolveSessionDeliveryTarget({ entry, requestedChannel: target === "last" ? "last" : target, From c21582983d1e584df7f80fef95074f55bc89cc0c Mon Sep 17 00:00:00 2001 From: eviaaaaa <2278596667@qq.com> Date: Mon, 9 Mar 2026 09:40:07 +0800 Subject: [PATCH 4/5] heartbeat: fix backgrounded exec notification path - resolveHeartbeatReasonKind: add `exec:` prefix match so `exec::exit` reasons emitted by maybeNotifyOnExit are classified as "exec-event" instead of "other", enabling shouldInspectPendingEvents = true - isExecCompletionEvent: extend to also match "exec completed", "exec failed", and "exec killed" (maybeNotifyOnExit event text), not just "exec finished" (emitExecSystemEvent / gateway path) Without both fixes, backgrounded allowlisted commands triggered a heartbeat that routed to the correct session but fell through to a regular heartbeat prompt instead of the exec-event prompt. --- src/infra/heartbeat-events-filter.test.ts | 27 +++++++++++++++++++++++ src/infra/heartbeat-events-filter.ts | 10 ++++++++- src/infra/heartbeat-reason.test.ts | 4 ++++ src/infra/heartbeat-reason.ts | 4 ++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/infra/heartbeat-events-filter.test.ts b/src/infra/heartbeat-events-filter.test.ts index 9cff6652537..ed1c0bca711 100644 --- a/src/infra/heartbeat-events-filter.test.ts +++ b/src/infra/heartbeat-events-filter.test.ts @@ -91,3 +91,30 @@ describe("heartbeat event classification", () => { expect(isCronSystemEvent(value)).toBe(expected); }); }); + +describe("isExecCompletionEvent", () => { + it("matches emitExecSystemEvent (gateway/node approval path) events", () => { + expect(isExecCompletionEvent("Exec finished (gateway id=g1, session=s1, code 0)")).toBe(true); + expect(isExecCompletionEvent("exec finished (node=n1, code 1)\nsome output")).toBe(true); + }); + + it("matches maybeNotifyOnExit (backgrounded allowlisted commands) events", () => { + expect(isExecCompletionEvent("Exec completed (abc12345, code 0) :: some output")).toBe(true); + expect(isExecCompletionEvent("Exec completed (abc12345, code 0)")).toBe(true); + expect(isExecCompletionEvent("Exec failed (abc12345, code 1) :: error text")).toBe(true); + expect(isExecCompletionEvent("Exec failed (abc12345, signal SIGTERM)")).toBe(true); + expect(isExecCompletionEvent("Exec killed (abc12345, signal SIGKILL)")).toBe(true); + }); + + it("is case-insensitive", () => { + expect(isExecCompletionEvent("EXEC COMPLETED (abc12345, code 0)")).toBe(true); + expect(isExecCompletionEvent("exec failed (abc12345, code 2)")).toBe(true); + }); + + it("does not match non-exec events", () => { + expect(isExecCompletionEvent("Exec running (gateway id=g1, session=s1, >5s): ls")).toBe(false); + expect(isExecCompletionEvent("Exec denied (gateway id=g1, reason): rm -rf /")).toBe(false); + expect(isExecCompletionEvent("Heartbeat wake")).toBe(false); + expect(isExecCompletionEvent("")).toBe(false); + }); +}); diff --git a/src/infra/heartbeat-events-filter.ts b/src/infra/heartbeat-events-filter.ts index 1682c3b308b..611884f9dca 100644 --- a/src/infra/heartbeat-events-filter.ts +++ b/src/infra/heartbeat-events-filter.ts @@ -84,7 +84,15 @@ function isHeartbeatNoiseEvent(evt: string): boolean { } export function isExecCompletionEvent(evt: string): boolean { - return evt.toLowerCase().includes("exec finished"); + const lower = evt.toLowerCase(); + // "exec finished" — emitExecSystemEvent (gateway/node approval path) + // "exec completed/failed/killed" — maybeNotifyOnExit (backgrounded allowlisted commands) + return ( + lower.includes("exec finished") || + lower.includes("exec completed") || + lower.includes("exec failed") || + lower.includes("exec killed") + ); } // Returns true when a system event should be treated as real cron reminder content. diff --git a/src/infra/heartbeat-reason.test.ts b/src/infra/heartbeat-reason.test.ts index ab0fe94ec06..b02e6b546c7 100644 --- a/src/infra/heartbeat-reason.test.ts +++ b/src/infra/heartbeat-reason.test.ts @@ -20,6 +20,8 @@ describe("heartbeat-reason", () => { { value: "interval", expected: "interval" }, { value: "manual", expected: "manual" }, { value: "exec-event", expected: "exec-event" }, + // maybeNotifyOnExit backgrounded exec path: exec::exit + { value: "exec:abc12345abc12345:exit", expected: "exec-event" }, { value: "wake", expected: "wake" }, { value: "acp:spawn:stream", expected: "wake" }, { value: "acp:spawn:", expected: "wake" }, @@ -36,6 +38,7 @@ describe("heartbeat-reason", () => { it.each([ { value: "exec-event", expected: true }, + { value: "exec:abc123:exit", expected: true }, { value: "cron:job-1", expected: true }, { value: "wake", expected: true }, { value: "acp:spawn:stream", expected: true }, @@ -50,6 +53,7 @@ describe("heartbeat-reason", () => { it.each([ { value: "manual", expected: true }, { value: "exec-event", expected: true }, + { value: "exec:abc123:exit", expected: true }, { value: "hook:wake", expected: true }, { value: "interval", expected: false }, { value: "cron:job-1", expected: false }, diff --git a/src/infra/heartbeat-reason.ts b/src/infra/heartbeat-reason.ts index 447ca733e53..4a2096149b0 100644 --- a/src/infra/heartbeat-reason.ts +++ b/src/infra/heartbeat-reason.ts @@ -31,6 +31,10 @@ export function resolveHeartbeatReasonKind(reason?: string): HeartbeatReasonKind if (trimmed === "exec-event") { return "exec-event"; } + // exec::exit — emitted by maybeNotifyOnExit for backgrounded commands + if (trimmed.startsWith("exec:")) { + return "exec-event"; + } if (trimmed === "wake") { return "wake"; } From ca9df70e07c42a8bd5499f7653816c4c4a2a3f17 Mon Sep 17 00:00:00 2001 From: Kaspre Date: Fri, 20 Mar 2026 00:15:46 -0400 Subject: [PATCH 5/5] refactor: address review feedback on exec event matching and target guard - Anchor isExecCompletionEvent patterns to maybeNotifyOnExit format ("exec completed/failed/killed (") to avoid false positives in free-form cron reminder text - Collapse redundant double-check on target === "none" into single if/else block Co-authored-by: eviaaaaa <2278596667@qq.com> --- src/infra/heartbeat-events-filter.ts | 7 +++---- src/infra/outbound/targets.ts | 23 +++++++++++------------ 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/infra/heartbeat-events-filter.ts b/src/infra/heartbeat-events-filter.ts index 611884f9dca..7e5114d1c41 100644 --- a/src/infra/heartbeat-events-filter.ts +++ b/src/infra/heartbeat-events-filter.ts @@ -86,12 +86,11 @@ function isHeartbeatNoiseEvent(evt: string): boolean { export function isExecCompletionEvent(evt: string): boolean { const lower = evt.toLowerCase(); // "exec finished" — emitExecSystemEvent (gateway/node approval path) - // "exec completed/failed/killed" — maybeNotifyOnExit (backgrounded allowlisted commands) + // "Exec completed/failed/killed (" — maybeNotifyOnExit (backgrounded allowlisted commands) + // Anchored to the parenthesised format to avoid false positives in free-form cron text return ( lower.includes("exec finished") || - lower.includes("exec completed") || - lower.includes("exec failed") || - lower.includes("exec killed") + /exec (?:completed|failed|killed) \(/.test(lower) ); } diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 6863430153b..d7fed91a023 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -270,18 +270,17 @@ export function resolveHeartbeatDeliveryTarget(params: { } } - if (target === "none" && !forceLastTargetWhenNone) { - const base = resolveSessionDeliveryTarget({ entry }); - return buildNoHeartbeatDeliveryTarget({ - reason: "target-none", - lastChannel: base.lastChannel, - lastAccountId: base.lastAccountId, - }); - } - - // For async exec completion events, always fall back to the session's last - // delivery target even when heartbeat target is explicitly set to "none". - if (target === "none" && forceLastTargetWhenNone) { + if (target === "none") { + if (!forceLastTargetWhenNone) { + const base = resolveSessionDeliveryTarget({ entry }); + return buildNoHeartbeatDeliveryTarget({ + reason: "target-none", + lastChannel: base.lastChannel, + lastAccountId: base.lastAccountId, + }); + } + // For async exec completion events, always fall back to the session's last + // delivery target even when heartbeat target is explicitly set to "none". target = "last"; }