fix(cron): wait for heartbeat to complete when wakeMode is "now"
Fixes #652 When cron jobs with sessionTarget:"main" have wakeMode:"now", they were being marked as completed immediately without waiting for the agent to actually process the system event. The issue was that requestHeartbeatNow() is fire-and-forget and doesn't wait for the heartbeat to complete. The job would finish with durationMs: 0 before the agent had a chance to run. This fix: - Adds runHeartbeatOnce to CronServiceDeps - Wires it up in gateway/server.ts to load config and pass runtime - Modifies executeJob() to call runHeartbeatOnce when wakeMode:"now" - Waits for heartbeat to complete and maps status to cron result: * "ran" → "ok" * "skipped" → "skipped" * "failed" → "error" - Falls back to old behavior for wakeMode:"next-heartbeat" or if runHeartbeatOnce is not available (backward compatibility) Benefits: - Jobs now have accurate durationMs reflecting actual processing time - Jobs are correctly marked with "error" status if heartbeat fails - Prevents race condition where job completes before agent runs [AI-assisted] - Generated with z.ai GLM-4.7 [Tested: Lightly tested - Logic validated with test scenarios, code quality checks passed, integration testing requires live Clawdbot instance]
This commit is contained in:
committed by
Peter Steinberger
parent
5a57cbe571
commit
91c870a0c4
@@ -37,6 +37,11 @@ export type CronServiceDeps = {
|
|||||||
cronEnabled: boolean;
|
cronEnabled: boolean;
|
||||||
enqueueSystemEvent: (text: string) => void;
|
enqueueSystemEvent: (text: string) => void;
|
||||||
requestHeartbeatNow: (opts?: { reason?: string }) => void;
|
requestHeartbeatNow: (opts?: { reason?: string }) => void;
|
||||||
|
runHeartbeatOnce?: (opts?: { reason?: string }) => Promise<{
|
||||||
|
status: "ran" | "skipped" | "failed";
|
||||||
|
durationMs: number;
|
||||||
|
reason?: string;
|
||||||
|
}>;
|
||||||
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
|
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
|
||||||
status: "ok" | "error" | "skipped";
|
status: "ok" | "error" | "skipped";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
@@ -514,10 +519,31 @@ export class CronService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.deps.enqueueSystemEvent(text);
|
this.deps.enqueueSystemEvent(text);
|
||||||
if (job.wakeMode === "now") {
|
if (job.wakeMode === "now" && this.deps.runHeartbeatOnce) {
|
||||||
this.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
|
const heartbeatResult = await this.deps.runHeartbeatOnce({
|
||||||
}
|
reason: `cron:${job.id}`,
|
||||||
|
});
|
||||||
|
// Map heartbeat status to cron status
|
||||||
|
if (heartbeatResult.status === "ran") {
|
||||||
await finish("ok", undefined, text);
|
await finish("ok", undefined, text);
|
||||||
|
} else if (heartbeatResult.status === "skipped") {
|
||||||
|
await finish(
|
||||||
|
"skipped",
|
||||||
|
heartbeatResult.reason ?? "heartbeat skipped",
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
} else if (heartbeatResult.status === "failed") {
|
||||||
|
await finish(
|
||||||
|
"error",
|
||||||
|
heartbeatResult.reason ?? "heartbeat failed",
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// wakeMode is "next-heartbeat" or runHeartbeatOnce not available
|
||||||
|
this.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
|
||||||
|
await finish("ok", undefined, text);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ import { startNodeBridgeServer } from "../infra/bridge/server.js";
|
|||||||
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
|
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
|
||||||
import { GatewayLockError } from "../infra/gateway-lock.js";
|
import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||||
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||||
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
|
import {
|
||||||
|
runHeartbeatOnce,
|
||||||
|
startHeartbeatRunner,
|
||||||
|
} from "../infra/heartbeat-runner.js";
|
||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
||||||
@@ -715,6 +718,14 @@ export async function startGatewayServer(
|
|||||||
enqueueSystemEvent(text, { sessionKey: resolveMainSessionKey(cfg) });
|
enqueueSystemEvent(text, { sessionKey: resolveMainSessionKey(cfg) });
|
||||||
},
|
},
|
||||||
requestHeartbeatNow,
|
requestHeartbeatNow,
|
||||||
|
runHeartbeatOnce: async (opts) => {
|
||||||
|
const runtimeConfig = loadConfig();
|
||||||
|
return await runHeartbeatOnce({
|
||||||
|
cfg: runtimeConfig,
|
||||||
|
reason: opts?.reason,
|
||||||
|
deps: { runtime },
|
||||||
|
});
|
||||||
|
},
|
||||||
runIsolatedAgentJob: async ({ job, message }) => {
|
runIsolatedAgentJob: async ({ job, message }) => {
|
||||||
const runtimeConfig = loadConfig();
|
const runtimeConfig = loadConfig();
|
||||||
return await runCronIsolatedAgentTurn({
|
return await runCronIsolatedAgentTurn({
|
||||||
|
|||||||
Reference in New Issue
Block a user