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"; }