Skip to content

Commit 1d4e431

Browse files
authored
fix: preserve deferred cron heartbeat target (#69021)
* test(cron): cover deferred heartbeat target preservation * fix(cron): preserve deferred heartbeat target override * test(cron): update timer expectation for deferred heartbeat target * fix(cron): preserve agent heartbeat config for targeted wakes * test(cron): use wake request type in scheduler helper * fix(cron): forward heartbeat overrides through gateway wake adapter * fix(cron): preserve coalesced wake heartbeat overrides * fix: preserve deferred cron heartbeat target (#69021)
1 parent 64089fd commit 1d4e431

11 files changed

Lines changed: 191 additions & 12 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
1919
- Discord/slash commands: tolerate partial Discord channel metadata in slash-command and model-picker flows so partial channel objects no longer crash when channel names, topics, or thread parent metadata are unavailable. (#68953) Thanks @dutifulbob.
2020
- BlueBubbles: consolidate outbound HTTP through a typed `BlueBubblesClient` that resolves the SSRF policy once at construction so image attachments stop getting blocked on localhost and reactions stop getting blocked on private-IP BB deployments. Fixes #34749 and #59722. (#68234) Thanks @omarshahine.
2121
- Cron/gateway: reject ambiguous announce delivery config at add/update time so invalid multi-channel or target-id provider settings fail early instead of persisting broken cron jobs. (#69015) Thanks @obviyus.
22+
- Cron/main-session delivery: preserve `heartbeat.target="last"` through deferred wake queuing, gateway wake forwarding, and same-target wake coalescing so queued cron replies still return to the last active chat. (#69021) Thanks @obviyus.
2223

2324
## 2026.4.19-beta.2
2425

src/cron/service.main-job-passes-heartbeat-target-last.test.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,40 @@ describe("cron main job passes heartbeat target=last", () => {
8888
expect(callArgs?.heartbeat?.target).toBe("last");
8989
});
9090

91-
it("should not pass heartbeat target for wakeMode=next-heartbeat main jobs", async () => {
91+
it("should preserve heartbeat.target=last when wakeMode=now falls back to requestHeartbeatNow", async () => {
92+
const { storePath } = await makeStorePath();
93+
const now = Date.now();
94+
95+
const job = createMainCronJob({
96+
now,
97+
id: "test-main-delivery-busy",
98+
wakeMode: "now",
99+
});
100+
101+
await writeCronStoreSnapshot({ storePath, jobs: [job] });
102+
103+
const runHeartbeatOnce = vi.fn<RunHeartbeatOnce>(async () => ({
104+
status: "skipped" as const,
105+
reason: "requests-in-flight",
106+
}));
107+
108+
const { cron, requestHeartbeatNow } = createCronWithSpies({
109+
storePath,
110+
runHeartbeatOnce,
111+
});
112+
113+
await runSingleTick(cron);
114+
115+
expect(runHeartbeatOnce).toHaveBeenCalled();
116+
expect(requestHeartbeatNow).toHaveBeenCalledWith(
117+
expect.objectContaining({
118+
reason: "cron:test-main-delivery-busy",
119+
heartbeat: { target: "last" },
120+
}),
121+
);
122+
});
123+
124+
it("should preserve heartbeat.target=last for wakeMode=next-heartbeat main jobs", async () => {
92125
const { storePath } = await makeStorePath();
93126
const now = Date.now();
94127

@@ -112,9 +145,13 @@ describe("cron main job passes heartbeat target=last", () => {
112145

113146
await runSingleTick(cron);
114147

115-
// wakeMode=next-heartbeat uses requestHeartbeatNow, not runHeartbeatOnce
116148
expect(requestHeartbeatNow).toHaveBeenCalled();
117-
// runHeartbeatOnce should NOT have been called for next-heartbeat mode
149+
expect(requestHeartbeatNow).toHaveBeenCalledWith(
150+
expect.objectContaining({
151+
reason: "cron:test-next-heartbeat",
152+
heartbeat: { target: "last" },
153+
}),
154+
);
118155
expect(runHeartbeatOnce).not.toHaveBeenCalled();
119156
});
120157
});

src/cron/service/state.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CronConfig } from "../../config/types.cron.js";
2-
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
2+
import type { HeartbeatRunResult, HeartbeatWakeRequest } from "../../infra/heartbeat-wake.js";
33
import type {
44
CronDeliveryStatus,
55
CronJob,
@@ -64,7 +64,7 @@ export type CronServiceDeps = {
6464
text: string,
6565
opts?: { agentId?: string; sessionKey?: string; contextKey?: string; trusted?: boolean },
6666
) => void;
67-
requestHeartbeatNow: (opts?: { reason?: string; agentId?: string; sessionKey?: string }) => void;
67+
requestHeartbeatNow: (opts?: HeartbeatWakeRequest) => void;
6868
runHeartbeatOnce?: (opts?: {
6969
reason?: string;
7070
agentId?: string;

src/cron/service/timer.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ describe("cron service timer seam coverage", () => {
6565
reason: "cron:main-heartbeat-job",
6666
agentId: undefined,
6767
sessionKey: "agent:main:main",
68+
heartbeat: { target: "last" },
6869
});
6970

7071
const persisted = JSON.parse(await fs.readFile(storePath, "utf8")) as {

src/cron/service/timer.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,6 +1220,7 @@ async function executeMainSessionCronJob(
12201220
reason,
12211221
agentId: job.agentId,
12221222
sessionKey: targetMainSessionKey,
1223+
heartbeat: { target: "last" },
12231224
});
12241225
return { status: "ok", summary: text };
12251226
}
@@ -1234,6 +1235,7 @@ async function executeMainSessionCronJob(
12341235
reason,
12351236
agentId: job.agentId,
12361237
sessionKey: targetMainSessionKey,
1238+
heartbeat: { target: "last" },
12371239
});
12381240
return { status: "ok", summary: text };
12391241
}
@@ -1256,6 +1258,7 @@ async function executeMainSessionCronJob(
12561258
reason: `cron:${job.id}`,
12571259
agentId: job.agentId,
12581260
sessionKey: targetMainSessionKey,
1261+
heartbeat: { target: "last" },
12591262
});
12601263
return { status: "ok", summary: text };
12611264
}

src/gateway/server-cron.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,48 @@ describe("buildGatewayCronService", () => {
140140
}
141141
});
142142

143+
it("forwards heartbeat overrides through the cron wake adapter", () => {
144+
const cfg = createCronConfig("server-cron-heartbeat-override");
145+
loadConfigMock.mockReturnValue(cfg);
146+
147+
const state = buildGatewayCronService({
148+
cfg,
149+
deps: {} as CliDeps,
150+
broadcast: () => {},
151+
});
152+
try {
153+
const cronDeps = (
154+
state.cron as unknown as {
155+
state?: {
156+
deps?: {
157+
requestHeartbeatNow?: (opts?: {
158+
agentId?: string;
159+
sessionKey?: string | null;
160+
reason?: string;
161+
heartbeat?: { target?: string };
162+
}) => void;
163+
};
164+
};
165+
}
166+
).state?.deps;
167+
168+
cronDeps?.requestHeartbeatNow?.({
169+
reason: "cron:test",
170+
sessionKey: "discord:channel:ops",
171+
heartbeat: { target: "last" },
172+
});
173+
174+
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({
175+
reason: "cron:test",
176+
agentId: "main",
177+
sessionKey: "agent:main:discord:channel:ops",
178+
heartbeat: { target: "last" },
179+
});
180+
} finally {
181+
state.cron.stop();
182+
}
183+
});
184+
143185
it("preserves trust downgrades when cron enqueues system events", () => {
144186
const cfg = createCronConfig("server-cron-untrusted");
145187
loadConfigMock.mockReturnValue(cfg);

src/gateway/server-cron.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ export function buildGatewayCronService(params: {
297297
reason: opts?.reason,
298298
agentId,
299299
sessionKey,
300+
heartbeat: opts?.heartbeat,
300301
});
301302
},
302303
runHeartbeatOnce: async (opts) => {

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe("startHeartbeatRunner", () => {
5858
async function expectWakeDispatch(params: {
5959
cfg: OpenClawConfig;
6060
runSpy: RunOnce;
61-
wake: { reason: string; agentId?: string; sessionKey?: string; coalesceMs: number };
61+
wake: Parameters<typeof requestHeartbeatNow>[0];
6262
expectedCall: Record<string, unknown>;
6363
}) {
6464
const runner = startHeartbeatRunner({
@@ -305,6 +305,47 @@ describe("startHeartbeatRunner", () => {
305305
runner.stop();
306306
});
307307

308+
it("merges targeted wake heartbeat overrides onto the agent heartbeat config", async () => {
309+
useFakeHeartbeatTime();
310+
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
311+
const runner = await expectWakeDispatch({
312+
cfg: {
313+
...heartbeatConfig([
314+
{
315+
id: "ops",
316+
heartbeat: {
317+
every: "15m",
318+
prompt: "Ops prompt",
319+
directPolicy: "block",
320+
target: "discord:channel:ops",
321+
},
322+
},
323+
]),
324+
} as OpenClawConfig,
325+
runSpy,
326+
wake: {
327+
reason: "cron:job-123",
328+
agentId: "ops",
329+
sessionKey: "agent:ops:discord:channel:alerts",
330+
heartbeat: { target: "last" },
331+
coalesceMs: 0,
332+
},
333+
expectedCall: {
334+
agentId: "ops",
335+
reason: "cron:job-123",
336+
sessionKey: "agent:ops:discord:channel:alerts",
337+
heartbeat: {
338+
every: "15m",
339+
prompt: "Ops prompt",
340+
directPolicy: "block",
341+
target: "last",
342+
},
343+
},
344+
});
345+
346+
runner.stop();
347+
});
348+
308349
it("does not fan out to unrelated agents for session-scoped exec wakes", async () => {
309350
useFakeHeartbeatTime();
310351
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });

src/infra/heartbeat-runner.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ import {
8686
areHeartbeatsEnabled,
8787
type HeartbeatRunResult,
8888
type HeartbeatWakeHandler,
89+
type HeartbeatWakeRequest,
8990
requestHeartbeatNow,
9091
setHeartbeatsEnabled,
9192
setHeartbeatWakeHandler,
@@ -1409,6 +1410,9 @@ export function startHeartbeatRunner(opts: {
14091410
const reason = params?.reason;
14101411
const requestedAgentId = params?.agentId ? normalizeAgentId(params.agentId) : undefined;
14111412
const requestedSessionKey = normalizeOptionalString(params?.sessionKey);
1413+
const requestedHeartbeat = params?.heartbeat;
1414+
const resolveRequestedHeartbeat = (heartbeat?: HeartbeatConfig) =>
1415+
requestedHeartbeat ? { ...heartbeat, ...requestedHeartbeat } : heartbeat;
14121416
const isInterval = reason === "interval";
14131417
const startedAt = Date.now();
14141418
const now = startedAt;
@@ -1428,7 +1432,7 @@ export function startHeartbeatRunner(opts: {
14281432
const res = await runOnce({
14291433
cfg: state.cfg,
14301434
agentId: targetAgent.agentId,
1431-
heartbeat: targetAgent.heartbeat,
1435+
heartbeat: resolveRequestedHeartbeat(targetAgent.heartbeat),
14321436
reason,
14331437
sessionKey: requestedSessionKey,
14341438
deps: { runtime: state.runtime },
@@ -1496,11 +1500,12 @@ export function startHeartbeatRunner(opts: {
14961500
}
14971501
};
14981502

1499-
const wakeHandler: HeartbeatWakeHandler = async (params) =>
1503+
const wakeHandler: HeartbeatWakeHandler = async (params: HeartbeatWakeRequest) =>
15001504
run({
15011505
reason: params.reason,
15021506
agentId: params.agentId,
15031507
sessionKey: params.sessionKey,
1508+
heartbeat: params.heartbeat,
15041509
});
15051510
const disposeWakeHandler = setHeartbeatWakeHandler(wakeHandler);
15061511
updateConfig(state.cfg);

src/infra/heartbeat-wake.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ describe("heartbeat-wake", () => {
263263
reason: "cron:job-1",
264264
agentId: "ops",
265265
sessionKey: "agent:ops:discord:channel:alerts",
266+
heartbeat: { target: "last" },
266267
coalesceMs: 0,
267268
});
268269

@@ -272,6 +273,7 @@ describe("heartbeat-wake", () => {
272273
reason: "cron:job-1",
273274
agentId: "ops",
274275
sessionKey: "agent:ops:discord:channel:alerts",
276+
heartbeat: { target: "last" },
275277
});
276278

277279
await vi.advanceTimersByTimeAsync(1000);
@@ -280,6 +282,37 @@ describe("heartbeat-wake", () => {
280282
reason: "cron:job-1",
281283
agentId: "ops",
282284
sessionKey: "agent:ops:discord:channel:alerts",
285+
heartbeat: { target: "last" },
286+
});
287+
});
288+
289+
it("preserves heartbeat override when same-target wakes coalesce", async () => {
290+
vi.useFakeTimers();
291+
const handler = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
292+
setHeartbeatWakeHandler(handler);
293+
294+
requestHeartbeatNow({
295+
reason: "manual",
296+
agentId: "ops",
297+
sessionKey: "agent:ops:discord:channel:alerts",
298+
heartbeat: { target: "last" },
299+
coalesceMs: 100,
300+
});
301+
requestHeartbeatNow({
302+
reason: "manual",
303+
agentId: "ops",
304+
sessionKey: "agent:ops:discord:channel:alerts",
305+
coalesceMs: 100,
306+
});
307+
308+
await vi.advanceTimersByTimeAsync(100);
309+
310+
expect(handler).toHaveBeenCalledTimes(1);
311+
expect(handler).toHaveBeenCalledWith({
312+
reason: "manual",
313+
agentId: "ops",
314+
sessionKey: "agent:ops:discord:channel:alerts",
315+
heartbeat: { target: "last" },
283316
});
284317
});
285318

0 commit comments

Comments
 (0)