Merge ca9df70e07c42a8bd5499f7653816c4c4a2a3f17 into 9fb78453e088cd7b553d7779faa0de5c83708e70

This commit is contained in:
Kaspre 2026-03-21 01:15:12 -04:00 committed by GitHub
commit da4451379f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 91 additions and 23 deletions

View File

@ -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 () => {

View File

@ -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 });
}
}
})();

View File

@ -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);
});
});

View File

@ -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.

View File

@ -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:<sessionId>: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 },

View File

@ -31,6 +31,10 @@ export function resolveHeartbeatReasonKind(reason?: string): HeartbeatReasonKind
if (trimmed === "exec-event") {
return "exec-event";
}
// exec:<sessionId>:exit — emitted by maybeNotifyOnExit for backgrounded commands
if (trimmed.startsWith("exec:")) {
return "exec-event";
}
if (trimmed === "wake") {
return "wake";
}

View File

@ -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();
});
});

View File

@ -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();
}

View File

@ -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", {

View File

@ -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({