Skip to content

Commit 8ae1987

Browse files
fix(cron): pass heartbeat target=last for main-session cron jobs (#28508) (#28583)
* fix(cron): pass heartbeat target=last for main-session cron jobs When a cron job with sessionTarget=main and wakeMode=now fires, it triggers a heartbeat via runHeartbeatOnce. Since e2362d3 changed the default heartbeat target from "last" to "none", these cron-triggered heartbeats silently discard their responses instead of delivering them to the last active channel (e.g. Telegram). Fix: pass heartbeat: { target: "last" } from the cron timer to runHeartbeatOnce for main-session jobs, and wire the override through the gateway cron service builder. This restores delivery for sessionTarget=main cron jobs without reverting the intentional default change for regular heartbeats. Regression introduced in: e2362d3 (2026-02-25) Fixes #28508 * Cron: align server-cron wake routing expectations for main-target jobs --------- Co-authored-by: Tak Hoffman <[email protected]>
1 parent d7d3416 commit 8ae1987

File tree

5 files changed

+150
-3
lines changed

5 files changed

+150
-3
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js";
3+
import { CronService } from "./service.js";
4+
import { setupCronServiceSuite, writeCronStoreSnapshot } from "./service.test-harness.js";
5+
import type { CronJob } from "./types.js";
6+
7+
const { logger, makeStorePath } = setupCronServiceSuite({
8+
prefix: "cron-main-heartbeat-target",
9+
});
10+
11+
describe("cron main job passes heartbeat target=last", () => {
12+
it("should pass heartbeat.target=last to runHeartbeatOnce for wakeMode=now main jobs", async () => {
13+
const { storePath } = await makeStorePath();
14+
const now = Date.now();
15+
16+
const job: CronJob = {
17+
id: "test-main-delivery",
18+
name: "test-main-delivery",
19+
enabled: true,
20+
createdAtMs: now - 10_000,
21+
updatedAtMs: now - 10_000,
22+
schedule: { kind: "every", everyMs: 60_000 },
23+
sessionTarget: "main",
24+
wakeMode: "now",
25+
payload: { kind: "systemEvent", text: "Check in" },
26+
state: { nextRunAtMs: now - 1 },
27+
};
28+
29+
await writeCronStoreSnapshot({ storePath, jobs: [job] });
30+
31+
const enqueueSystemEvent = vi.fn();
32+
const requestHeartbeatNow = vi.fn();
33+
const runHeartbeatOnce = vi.fn<
34+
(opts?: {
35+
reason?: string;
36+
agentId?: string;
37+
sessionKey?: string;
38+
heartbeat?: { target?: string };
39+
}) => Promise<HeartbeatRunResult>
40+
>(async () => ({
41+
status: "ran" as const,
42+
durationMs: 50,
43+
}));
44+
45+
const cron = new CronService({
46+
storePath,
47+
cronEnabled: true,
48+
log: logger,
49+
enqueueSystemEvent,
50+
requestHeartbeatNow,
51+
runHeartbeatOnce,
52+
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
53+
});
54+
55+
await cron.start();
56+
57+
// Wait for the timer to fire
58+
await vi.advanceTimersByTimeAsync(2_000);
59+
60+
// Give the async run a chance to complete
61+
await vi.advanceTimersByTimeAsync(1_000);
62+
63+
cron.stop();
64+
65+
// runHeartbeatOnce should have been called
66+
expect(runHeartbeatOnce).toHaveBeenCalled();
67+
68+
// The heartbeat config passed should include target: "last" so the
69+
// heartbeat runner delivers the response to the last active channel.
70+
const callArgs = runHeartbeatOnce.mock.calls[0]?.[0];
71+
expect(callArgs).toBeDefined();
72+
expect(callArgs?.heartbeat).toBeDefined();
73+
expect(callArgs?.heartbeat?.target).toBe("last");
74+
});
75+
76+
it("should not pass heartbeat target for wakeMode=next-heartbeat main jobs", async () => {
77+
const { storePath } = await makeStorePath();
78+
const now = Date.now();
79+
80+
const job: CronJob = {
81+
id: "test-next-heartbeat",
82+
name: "test-next-heartbeat",
83+
enabled: true,
84+
createdAtMs: now - 10_000,
85+
updatedAtMs: now - 10_000,
86+
schedule: { kind: "every", everyMs: 60_000 },
87+
sessionTarget: "main",
88+
wakeMode: "next-heartbeat",
89+
payload: { kind: "systemEvent", text: "Check in" },
90+
state: { nextRunAtMs: now - 1 },
91+
};
92+
93+
await writeCronStoreSnapshot({ storePath, jobs: [job] });
94+
95+
const enqueueSystemEvent = vi.fn();
96+
const requestHeartbeatNow = vi.fn();
97+
const runHeartbeatOnce = vi.fn(async () => ({
98+
status: "ran" as const,
99+
durationMs: 50,
100+
}));
101+
102+
const cron = new CronService({
103+
storePath,
104+
cronEnabled: true,
105+
log: logger,
106+
enqueueSystemEvent,
107+
requestHeartbeatNow,
108+
runHeartbeatOnce,
109+
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
110+
});
111+
112+
await cron.start();
113+
await vi.advanceTimersByTimeAsync(2_000);
114+
await vi.advanceTimersByTimeAsync(1_000);
115+
cron.stop();
116+
117+
// wakeMode=next-heartbeat uses requestHeartbeatNow, not runHeartbeatOnce
118+
expect(requestHeartbeatNow).toHaveBeenCalled();
119+
// runHeartbeatOnce should NOT have been called for next-heartbeat mode
120+
expect(runHeartbeatOnce).not.toHaveBeenCalled();
121+
});
122+
});

src/cron/service/state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export type CronServiceDeps = {
5656
reason?: string;
5757
agentId?: string;
5858
sessionKey?: string;
59+
/** Optional heartbeat config override (e.g. target: "last" for cron-triggered heartbeats). */
60+
heartbeat?: { target?: string };
5961
}) => Promise<HeartbeatRunResult>;
6062
/**
6163
* WakeMode=now: max time to wait for runHeartbeatOnce to stop returning

src/cron/service/timer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,11 @@ export async function executeJobCore(
663663
reason,
664664
agentId: job.agentId,
665665
sessionKey: targetMainSessionKey,
666+
// Cron-triggered heartbeats should deliver to the last active channel.
667+
// Without this override, heartbeat target defaults to "none" (since
668+
// e2362d35) and cron main-session responses are silently swallowed.
669+
// See: https://github.com/openclaw/openclaw/issues/28508
670+
heartbeat: { target: "last" },
666671
});
667672
if (
668673
heartbeatResult.status !== "skipped" ||

src/gateway/server-cron.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ describe("buildGatewayCronService", () => {
4040
fetchWithSsrFGuardMock.mockClear();
4141
});
4242

43-
it("canonicalizes non-agent sessionKey to agent store key for enqueue + wake", async () => {
43+
it("routes main-target jobs to the main session for enqueue + wake", async () => {
4444
const tmpDir = path.join(os.tmpdir(), `server-cron-${Date.now()}`);
4545
const cfg = {
4646
session: {
@@ -73,12 +73,12 @@ describe("buildGatewayCronService", () => {
7373
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
7474
"hello",
7575
expect.objectContaining({
76-
sessionKey: "agent:main:discord:channel:ops",
76+
sessionKey: "agent:main:main",
7777
}),
7878
);
7979
expect(requestHeartbeatNowMock).toHaveBeenCalledWith(
8080
expect.objectContaining({
81-
sessionKey: "agent:main:discord:channel:ops",
81+
sessionKey: undefined,
8282
}),
8383
);
8484
} finally {

src/gateway/server-cron.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,29 @@ export function buildGatewayCronService(params: {
182182
},
183183
runHeartbeatOnce: async (opts) => {
184184
const { runtimeConfig, agentId, sessionKey } = resolveCronWakeTarget(opts);
185+
// Merge cron-supplied heartbeat overrides (e.g. target: "last") with the
186+
// fully resolved agent heartbeat config so cron-triggered heartbeats
187+
// respect agent-specific overrides (agents.list[].heartbeat) before
188+
// falling back to agents.defaults.heartbeat.
189+
const agentEntry =
190+
Array.isArray(runtimeConfig.agents?.list) &&
191+
runtimeConfig.agents.list.find(
192+
(entry) =>
193+
entry && typeof entry.id === "string" && normalizeAgentId(entry.id) === agentId,
194+
);
195+
const baseHeartbeat = {
196+
...runtimeConfig.agents?.defaults?.heartbeat,
197+
...agentEntry?.heartbeat,
198+
};
199+
const heartbeatOverride = opts?.heartbeat
200+
? { ...baseHeartbeat, ...opts.heartbeat }
201+
: undefined;
185202
return await runHeartbeatOnce({
186203
cfg: runtimeConfig,
187204
reason: opts?.reason,
188205
agentId,
189206
sessionKey,
207+
heartbeat: heartbeatOverride,
190208
deps: { ...params.deps, runtime: defaultRuntime },
191209
});
192210
},

0 commit comments

Comments
 (0)