From 04e3a66f907ebe404aba1473d2534bce9e980415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=B3=E5=B7=9D=20=E8=AB=92?= Date: Thu, 12 Feb 2026 13:22:29 +0900 Subject: [PATCH] fix(cron): pass agentId to runHeartbeatOnce for main-session jobs (#14140) * fix(cron): pass agentId to runHeartbeatOnce for main-session jobs Main-session cron jobs with agentId always ran the heartbeat under the default agent, ignoring the job's agent binding. enqueueSystemEvent correctly routed the system event to the bound agent's session, but runHeartbeatOnce was called without agentId, so the heartbeat ran under the default agent and never picked up the event. Thread agentId from job.agentId through the CronServiceDeps type, timer execution, and the gateway wrapper so heartbeat-runner uses the correct agent. Co-Authored-By: Claude Opus 4.6 * cron: add heartbeat agentId propagation regression test (#14140) (thanks @ishikawa-pro) --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- ...runs-one-shot-main-job-disables-it.test.ts | 43 +++++++++++++++++++ src/cron/service/state.ts | 2 +- src/cron/service/timer.ts | 2 +- src/gateway/server-cron.ts | 2 + 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index 1cc3eca03c..bbee9cf7e8 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -200,6 +200,49 @@ describe("CronService", () => { await store.cleanup(); }); + it("passes agentId to runHeartbeatOnce for main-session wakeMode now jobs", async () => { + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + const runHeartbeatOnce = vi.fn(async () => ({ status: "ran" as const, durationMs: 1 })); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow, + runHeartbeatOnce, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + + await cron.start(); + const job = await cron.add({ + name: "wakeMode now with agent", + agentId: "ops", + enabled: true, + schedule: { kind: "at", at: new Date(1).toISOString() }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "hello" }, + }); + + await cron.run(job.id, "force"); + + expect(runHeartbeatOnce).toHaveBeenCalledTimes(1); + expect(runHeartbeatOnce).toHaveBeenCalledWith( + expect.objectContaining({ + reason: `cron:${job.id}`, + agentId: "ops", + }), + ); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); + expect(enqueueSystemEvent).toHaveBeenCalledWith("hello", { agentId: "ops" }); + + cron.stop(); + await store.cleanup(); + }); + it("wakeMode now falls back to queued heartbeat when main lane stays busy", async () => { const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index c51103f339..025da7b3fa 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -37,7 +37,7 @@ export type CronServiceDeps = { sessionStorePath?: string; enqueueSystemEvent: (text: string, opts?: { agentId?: string }) => void; requestHeartbeatNow: (opts?: { reason?: string }) => void; - runHeartbeatOnce?: (opts?: { reason?: string }) => Promise; + runHeartbeatOnce?: (opts?: { reason?: string; agentId?: string }) => Promise; runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{ status: "ok" | "error" | "skipped"; summary?: string; diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 3b446848a3..802ff63b70 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -440,7 +440,7 @@ async function executeJobCore( let heartbeatResult: HeartbeatRunResult; for (;;) { - heartbeatResult = await state.deps.runHeartbeatOnce({ reason }); + heartbeatResult = await state.deps.runHeartbeatOnce({ reason, agentId: job.agentId }); if ( heartbeatResult.status !== "skipped" || heartbeatResult.reason !== "requests-in-flight" diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 10ce4200a6..07fd2831cb 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -69,9 +69,11 @@ export function buildGatewayCronService(params: { requestHeartbeatNow, runHeartbeatOnce: async (opts) => { const runtimeConfig = loadConfig(); + const agentId = opts?.agentId ? resolveCronAgent(opts.agentId).agentId : undefined; return await runHeartbeatOnce({ cfg: runtimeConfig, reason: opts?.reason, + agentId, deps: { ...params.deps, runtime: defaultRuntime }, }); },