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:
Roshan Singh
2026-01-10 16:24:46 +00:00
committed by Peter Steinberger
parent 5a57cbe571
commit 91c870a0c4
2 changed files with 40 additions and 3 deletions

View File

@@ -37,6 +37,11 @@ export type CronServiceDeps = {
cronEnabled: boolean;
enqueueSystemEvent: (text: 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<{
status: "ok" | "error" | "skipped";
summary?: string;
@@ -514,10 +519,31 @@ export class CronService {
return;
}
this.deps.enqueueSystemEvent(text);
if (job.wakeMode === "now") {
if (job.wakeMode === "now" && this.deps.runHeartbeatOnce) {
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);
} 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);
}
await finish("ok", undefined, text);
return;
}

View File

@@ -61,7 +61,10 @@ import { startNodeBridgeServer } from "../infra/bridge/server.js";
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
import { GatewayLockError } from "../infra/gateway-lock.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 { getMachineDisplayName } from "../infra/machine-name.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
@@ -715,6 +718,14 @@ export async function startGatewayServer(
enqueueSystemEvent(text, { sessionKey: resolveMainSessionKey(cfg) });
},
requestHeartbeatNow,
runHeartbeatOnce: async (opts) => {
const runtimeConfig = loadConfig();
return await runHeartbeatOnce({
cfg: runtimeConfig,
reason: opts?.reason,
deps: { runtime },
});
},
runIsolatedAgentJob: async ({ job, message }) => {
const runtimeConfig = loadConfig();
return await runCronIsolatedAgentTurn({