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 e2362d35 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: e2362d35 (2026-02-25)

Fixes #28508

* Cron: align server-cron wake routing expectations for main-target jobs

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Marcus Widing
2026-02-28 18:14:24 +01:00
committed by GitHub
parent d7d3416b1d
commit 8ae1987f2a
5 changed files with 150 additions and 3 deletions

View File

@@ -40,7 +40,7 @@ describe("buildGatewayCronService", () => {
fetchWithSsrFGuardMock.mockClear();
});
it("canonicalizes non-agent sessionKey to agent store key for enqueue + wake", async () => {
it("routes main-target jobs to the main session for enqueue + wake", async () => {
const tmpDir = path.join(os.tmpdir(), `server-cron-${Date.now()}`);
const cfg = {
session: {
@@ -73,12 +73,12 @@ describe("buildGatewayCronService", () => {
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
"hello",
expect.objectContaining({
sessionKey: "agent:main:discord:channel:ops",
sessionKey: "agent:main:main",
}),
);
expect(requestHeartbeatNowMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:discord:channel:ops",
sessionKey: undefined,
}),
);
} finally {

View File

@@ -182,11 +182,29 @@ export function buildGatewayCronService(params: {
},
runHeartbeatOnce: async (opts) => {
const { runtimeConfig, agentId, sessionKey } = resolveCronWakeTarget(opts);
// Merge cron-supplied heartbeat overrides (e.g. target: "last") with the
// fully resolved agent heartbeat config so cron-triggered heartbeats
// respect agent-specific overrides (agents.list[].heartbeat) before
// falling back to agents.defaults.heartbeat.
const agentEntry =
Array.isArray(runtimeConfig.agents?.list) &&
runtimeConfig.agents.list.find(
(entry) =>
entry && typeof entry.id === "string" && normalizeAgentId(entry.id) === agentId,
);
const baseHeartbeat = {
...runtimeConfig.agents?.defaults?.heartbeat,
...agentEntry?.heartbeat,
};
const heartbeatOverride = opts?.heartbeat
? { ...baseHeartbeat, ...opts.heartbeat }
: undefined;
return await runHeartbeatOnce({
cfg: runtimeConfig,
reason: opts?.reason,
agentId,
sessionKey,
heartbeat: heartbeatOverride,
deps: { ...params.deps, runtime: defaultRuntime },
});
},