heartbeat: fix backgrounded exec notification path

- resolveHeartbeatReasonKind: add `exec:` prefix match so `exec:<id>:exit`
  reasons emitted by maybeNotifyOnExit are classified as "exec-event"
  instead of "other", enabling shouldInspectPendingEvents = true

- isExecCompletionEvent: extend to also match "exec completed",
  "exec failed", and "exec killed" (maybeNotifyOnExit event text),
  not just "exec finished" (emitExecSystemEvent / gateway path)

Without both fixes, backgrounded allowlisted commands triggered a
heartbeat that routed to the correct session but fell through to a
regular heartbeat prompt instead of the exec-event prompt.
This commit is contained in:
eviaaaaa 2026-03-09 09:40:07 +08:00 committed by Kaspre
parent f7d8b28d21
commit c21582983d
4 changed files with 44 additions and 1 deletions

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

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