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] 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,