Skip to content

Commit 627813a

Browse files
authored
fix(heartbeat): scope exec wake dispatch to session key (#32724)
Merged via squash. Prepared head SHA: 563fee0 Co-authored-by: altaywtf <[email protected]> Co-authored-by: altaywtf <[email protected]> Reviewed-by: @altaywtf
1 parent 1ded5cc commit 627813a

File tree

8 files changed

+180
-7
lines changed

8 files changed

+180
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111

1212
### Fixes
1313

14+
- Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf
1415
- macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://<peer>.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman.
1516
- Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin.
1617
- Telegram/plugin outbound hook parity: run `message_sending` + `message_sent` in Telegram reply delivery, include reply-path hook metadata (`mediaUrls`, `threadId`), and report `message_sent.success=false` when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("../infra/heartbeat-wake.js", () => ({
4+
requestHeartbeatNow: vi.fn(),
5+
}));
6+
7+
vi.mock("../infra/system-events.js", () => ({
8+
enqueueSystemEvent: vi.fn(),
9+
}));
10+
11+
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
12+
import { enqueueSystemEvent } from "../infra/system-events.js";
13+
import { emitExecSystemEvent } from "./bash-tools.exec-runtime.js";
14+
15+
const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow);
16+
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
17+
18+
describe("emitExecSystemEvent", () => {
19+
beforeEach(() => {
20+
requestHeartbeatNowMock.mockClear();
21+
enqueueSystemEventMock.mockClear();
22+
});
23+
24+
it("scopes heartbeat wake to the event session key", () => {
25+
emitExecSystemEvent("Exec finished", {
26+
sessionKey: "agent:ops:main",
27+
contextKey: "exec:run-1",
28+
});
29+
30+
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
31+
sessionKey: "agent:ops:main",
32+
contextKey: "exec:run-1",
33+
});
34+
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({
35+
reason: "exec-event",
36+
sessionKey: "agent:ops:main",
37+
});
38+
});
39+
40+
it("keeps wake unscoped for non-agent session keys", () => {
41+
emitExecSystemEvent("Exec finished", {
42+
sessionKey: "global",
43+
contextKey: "exec:run-global",
44+
});
45+
46+
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
47+
sessionKey: "global",
48+
contextKey: "exec:run-global",
49+
});
50+
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({
51+
reason: "exec-event",
52+
});
53+
});
54+
55+
it("ignores events without a session key", () => {
56+
emitExecSystemEvent("Exec finished", {
57+
sessionKey: " ",
58+
contextKey: "exec:run-2",
59+
});
60+
61+
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
62+
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
63+
});
64+
});

src/agents/bash-tools.exec-runtime.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
66
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
77
import { findPathKey, mergePathPrepend } from "../infra/path-prepend.js";
88
import { enqueueSystemEvent } from "../infra/system-events.js";
9+
import { scopedHeartbeatWakeOptions } from "../routing/session-key.js";
910
import type { ProcessSession } from "./bash-process-registry.js";
1011
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
1112
import type { BashSandboxConfig } from "./bash-tools.shared.js";
@@ -239,7 +240,9 @@ function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "faile
239240
? `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel}) :: ${output}`
240241
: `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel})`;
241242
enqueueSystemEvent(summary, { sessionKey });
242-
requestHeartbeatNow({ reason: `exec:${session.id}:exit` });
243+
requestHeartbeatNow(
244+
scopedHeartbeatWakeOptions(sessionKey, { reason: `exec:${session.id}:exit` }),
245+
);
243246
}
244247

245248
export function createApprovalSlug(id: string) {
@@ -265,7 +268,7 @@ export function emitExecSystemEvent(
265268
return;
266269
}
267270
enqueueSystemEvent(text, { sessionKey, contextKey: opts.contextKey });
268-
requestHeartbeatNow({ reason: "exec-event" });
271+
requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }));
269272
}
270273

271274
export async function runExecProcess(opts: {

src/agents/bash-tools.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import path from "node:path";
2-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import {
4+
resetHeartbeatWakeStateForTests,
5+
setHeartbeatWakeHandler,
6+
} from "../infra/heartbeat-wake.js";
37
import { applyPathPrepend, findPathKey } from "../infra/path-prepend.js";
48
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
59
import { captureEnv } from "../test-utils/env.js";
@@ -510,6 +514,14 @@ describe("exec exit codes", () => {
510514
});
511515

512516
describe("exec notifyOnExit", () => {
517+
beforeEach(() => {
518+
resetHeartbeatWakeStateForTests();
519+
});
520+
521+
afterEach(() => {
522+
resetHeartbeatWakeStateForTests();
523+
});
524+
513525
it("enqueues a system event when a backgrounded exec exits", async () => {
514526
const tool = createNotifyOnExitExecTool();
515527

@@ -521,6 +533,45 @@ describe("exec notifyOnExit", () => {
521533
expect(hasEvent).toBe(true);
522534
});
523535

536+
it("scopes notifyOnExit heartbeat wake to the exec session key", async () => {
537+
const tool = createNotifyOnExitExecTool();
538+
const wakeHandler = vi.fn().mockResolvedValue({ status: "skipped", reason: "disabled" });
539+
const dispose = setHeartbeatWakeHandler(
540+
wakeHandler as unknown as Parameters<typeof setHeartbeatWakeHandler>[0],
541+
);
542+
try {
543+
const sessionId = await startBackgroundCommand(tool, echoAfterDelay("notify"));
544+
545+
await expect
546+
.poll(() => wakeHandler.mock.calls[0]?.[0], NOTIFY_POLL_OPTIONS)
547+
.toMatchObject({
548+
reason: `exec:${sessionId}:exit`,
549+
sessionKey: DEFAULT_NOTIFY_SESSION_KEY,
550+
});
551+
} finally {
552+
dispose();
553+
}
554+
});
555+
556+
it("keeps notifyOnExit heartbeat wake unscoped for non-agent session keys", async () => {
557+
const tool = createNotifyOnExitExecTool({ sessionKey: "global" });
558+
const wakeHandler = vi.fn().mockResolvedValue({ status: "skipped", reason: "disabled" });
559+
const dispose = setHeartbeatWakeHandler(
560+
wakeHandler as unknown as Parameters<typeof setHeartbeatWakeHandler>[0],
561+
);
562+
try {
563+
const sessionId = await startBackgroundCommand(tool, echoAfterDelay("notify"));
564+
565+
await expect
566+
.poll(() => wakeHandler.mock.calls[0]?.[0], NOTIFY_POLL_OPTIONS)
567+
.toEqual({
568+
reason: `exec:${sessionId}:exit`,
569+
});
570+
} finally {
571+
dispose();
572+
}
573+
});
574+
524575
it.each<NotifyNoopCase>(NOOP_NOTIFY_CASES)("$label", runNotifyNoopCase);
525576
});
526577

src/gateway/server-node-events.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ describe("node exec events", () => {
111111
"Exec started (node=node-1 id=run-1): ls -la",
112112
{ sessionKey: "agent:main:main", contextKey: "exec:run-1" },
113113
);
114-
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" });
114+
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({
115+
reason: "exec-event",
116+
sessionKey: "agent:main:main",
117+
});
115118
});
116119

117120
it("enqueues exec.finished events with output", async () => {
@@ -185,7 +188,10 @@ describe("node exec events", () => {
185188
"Exec denied (node=node-3 id=run-3, allowlist-miss): rm -rf /",
186189
{ sessionKey: "agent:demo:main", contextKey: "exec:run-3" },
187190
);
188-
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" });
191+
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({
192+
reason: "exec-event",
193+
sessionKey: "agent:demo:main",
194+
});
189195
});
190196

191197
it("suppresses exec.started when notifyOnExit is false", async () => {

src/gateway/server-node-events.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { buildOutboundSessionContext } from "../infra/outbound/session-context.j
1010
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
1111
import { registerApnsToken } from "../infra/push-apns.js";
1212
import { enqueueSystemEvent } from "../infra/system-events.js";
13-
import { normalizeMainKey } from "../routing/session-key.js";
13+
import { normalizeMainKey, scopedHeartbeatWakeOptions } from "../routing/session-key.js";
1414
import { defaultRuntime } from "../runtime.js";
1515
import { parseMessageWithAttachments } from "./chat-attachments.js";
1616
import { normalizeRpcAttachmentsToChatAttachments } from "./server-methods/attachment-normalize.js";
@@ -574,7 +574,10 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
574574
}
575575

576576
enqueueSystemEvent(text, { sessionKey, contextKey: runId ? `exec:${runId}` : "exec" });
577-
requestHeartbeatNow({ reason: "exec-event" });
577+
// Scope wakes only for canonical agent sessions. Synthetic node-* fallback
578+
// keys should keep legacy unscoped behavior so enabled non-main heartbeat
579+
// agents still run when no explicit agent session is provided.
580+
requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }));
578581
return;
579582
}
580583
case "push.apns.register": {

src/infra/heartbeat-runner.scheduler.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,4 +202,42 @@ describe("startHeartbeatRunner", () => {
202202

203203
runner.stop();
204204
});
205+
206+
it("does not fan out to unrelated agents for session-scoped exec wakes", async () => {
207+
vi.useFakeTimers();
208+
vi.setSystemTime(new Date(0));
209+
210+
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
211+
const runner = startHeartbeatRunner({
212+
cfg: {
213+
agents: {
214+
defaults: { heartbeat: { every: "30m" } },
215+
list: [
216+
{ id: "main", heartbeat: { every: "30m" } },
217+
{ id: "finance", heartbeat: { every: "30m" } },
218+
],
219+
},
220+
} as OpenClawConfig,
221+
runOnce: runSpy,
222+
});
223+
224+
requestHeartbeatNow({
225+
reason: "exec-event",
226+
sessionKey: "agent:main:main",
227+
coalesceMs: 0,
228+
});
229+
await vi.advanceTimersByTimeAsync(1);
230+
231+
expect(runSpy).toHaveBeenCalledTimes(1);
232+
expect(runSpy).toHaveBeenCalledWith(
233+
expect.objectContaining({
234+
agentId: "main",
235+
reason: "exec-event",
236+
sessionKey: "agent:main:main",
237+
}),
238+
);
239+
expect(runSpy.mock.calls.some((call) => call[0]?.agentId === "finance")).toBe(false);
240+
241+
runner.stop();
242+
});
205243
});

src/routing/session-key.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ function normalizeToken(value: string | undefined | null): string {
3030
return (value ?? "").trim().toLowerCase();
3131
}
3232

33+
export function scopedHeartbeatWakeOptions<T extends object>(
34+
sessionKey: string,
35+
wakeOptions: T,
36+
): T | (T & { sessionKey: string }) {
37+
return parseAgentSessionKey(sessionKey) ? { ...wakeOptions, sessionKey } : wakeOptions;
38+
}
39+
3340
export function normalizeMainKey(value: string | undefined | null): string {
3441
const trimmed = (value ?? "").trim();
3542
return trimmed ? trimmed.toLowerCase() : DEFAULT_MAIN_KEY;

0 commit comments

Comments
 (0)