diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index dbf1bde579f..c48baeeeb6b 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..7bfbdbd6646 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 }); } }; @@ -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 }); } } })(); 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..7e5114d1c41 100644 --- a/src/infra/heartbeat-events-filter.ts +++ b/src/infra/heartbeat-events-filter.ts @@ -84,7 +84,14 @@ 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) + // Anchored to the parenthesised format to avoid false positives in free-form cron text + return ( + lower.includes("exec finished") || + /exec (?:completed|failed|killed) \(/.test(lower) + ); } // 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"; } 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..d7fed91a023 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") { @@ -269,12 +271,17 @@ export function resolveHeartbeatDeliveryTarget(params: { } if (target === "none") { - const base = resolveSessionDeliveryTarget({ entry }); - return buildNoHeartbeatDeliveryTarget({ - reason: "target-none", - lastChannel: base.lastChannel, - lastAccountId: base.lastAccountId, - }); + 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"; } const resolvedTarget = resolveSessionDeliveryTarget({