Merge ca9df70e07c42a8bd5499f7653816c4c4a2a3f17 into 9fb78453e088cd7b553d7779faa0de5c83708e70
This commit is contained in:
commit
da4451379f
@ -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 () => {
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user