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 <noreply@anthropic.com>

* cron: add heartbeat agentId propagation regression test (#14140) (thanks @ishikawa-pro)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
石川 諒
2026-02-12 13:22:29 +09:00
committed by GitHub
parent 04f695e562
commit 04e3a66f90
4 changed files with 47 additions and 2 deletions

View File

@@ -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();

View File

@@ -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<HeartbeatRunResult>;
runHeartbeatOnce?: (opts?: { reason?: string; agentId?: string }) => Promise<HeartbeatRunResult>;
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
status: "ok" | "error" | "skipped";
summary?: string;

View File

@@ -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"

View File

@@ -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 },
});
},