Skip to content

Commit 73e5bb7

Browse files
authored
Cron: apply timeout to startup catch-up runs (#23966)
* Cron: apply timeout to startup catch-up runs * Changelog: add cron startup timeout catch-up note
1 parent 26644c4 commit 73e5bb7

3 files changed

Lines changed: 42 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai
6767
- Cron/Delivery: route text-only announce jobs with explicit thread/topic targets through direct outbound delivery so forum/thread destinations do not get dropped by intermediary announce turns. (#23841) Thanks @AndrewArto.
6868
- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.
6969
- Cron/Run: enforce the same per-job timeout guard for manual `cron.run` executions as timer-driven runs, including abort propagation for isolated agent jobs, so forced runs cannot wedge indefinitely. (#23704) Thanks @tkuehnl.
70+
- Cron/Startup: enforce per-job timeout guards for startup catch-up replay runs so missed isolated jobs cannot hang indefinitely during gateway boot recovery.
7071
- Cron/Schedule: for `every` jobs, prefer `lastRunAtMs + everyMs` when still in the future after restarts, then fall back to anchor scheduling for catch-up windows, so NEXT timing matches the last successful cadence. (#22895) Thanks @SidQin-cyber.
7172
- Cron/Service: execute manual `cron.run` jobs outside the cron lock (while still persisting started/finished state atomically) so `cron.list` and `cron.status` remain responsive during long forced runs. (#23628) Thanks @dsgraves.
7273
- Cron/Timer: keep a watchdog recheck timer armed while `onTimer` is actively executing so the scheduler continues polling even if a due-run tick stalls for an extended period. (#23628) Thanks @dsgraves.

src/cron/service.issue-regressions.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { CronService } from "./service.js";
88
import { createDeferred, createRunningCronServiceState } from "./service.test-harness.js";
99
import { computeJobNextRunAtMs } from "./service/jobs.js";
1010
import { createCronServiceState, type CronEvent } from "./service/state.js";
11-
import { onTimer } from "./service/timer.js";
11+
import { onTimer, runMissedJobs } from "./service/timer.js";
1212
import type { CronJob, CronJobState } from "./types.js";
1313

1414
const noopLogger = {
@@ -820,6 +820,45 @@ describe("Cron issue regressions", () => {
820820
cron.stop();
821821
});
822822

823+
it("applies timeoutSeconds to startup catch-up isolated executions", async () => {
824+
vi.useRealTimers();
825+
const store = await makeStorePath();
826+
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
827+
const cronJob = createIsolatedRegressionJob({
828+
id: "startup-timeout",
829+
name: "startup timeout",
830+
scheduledAt,
831+
schedule: { kind: "at", at: new Date(scheduledAt).toISOString() },
832+
payload: { kind: "agentTurn", message: "work", timeoutSeconds: 0.01 },
833+
state: { nextRunAtMs: scheduledAt },
834+
});
835+
await writeCronJobs(store.storePath, [cronJob]);
836+
837+
let now = scheduledAt;
838+
const abortAwareRunner = createAbortAwareIsolatedRunner();
839+
const state = createCronServiceState({
840+
cronEnabled: true,
841+
storePath: store.storePath,
842+
log: noopLogger,
843+
nowMs: () => now,
844+
enqueueSystemEvent: vi.fn(),
845+
requestHeartbeatNow: vi.fn(),
846+
runIsolatedAgentJob: vi.fn(async (params) => {
847+
const result = await abortAwareRunner.runIsolatedAgentJob(params);
848+
now += 5;
849+
return result;
850+
}),
851+
});
852+
853+
await runMissedJobs(state);
854+
855+
expect(abortAwareRunner.getObservedAbortSignal()).toBeDefined();
856+
expect(abortAwareRunner.getObservedAbortSignal()?.aborted).toBe(true);
857+
const job = state.store?.jobs.find((entry) => entry.id === "startup-timeout");
858+
expect(job?.state.lastStatus).toBe("error");
859+
expect(job?.state.lastError).toContain("timed out");
860+
});
861+
823862
it("retries cron schedule computation from the next second when the first attempt returns undefined (#17821)", () => {
824863
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
825864
const cronJob = createIsolatedRegressionJob({

src/cron/service/timer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ export async function runMissedJobs(
547547
const startedAt = state.deps.nowMs();
548548
emit(state, { jobId: candidate.job.id, action: "started", runAtMs: startedAt });
549549
try {
550-
const result = await executeJobCore(state, candidate.job);
550+
const result = await executeJobCoreWithTimeout(state, candidate.job);
551551
outcomes.push({
552552
jobId: candidate.jobId,
553553
status: result.status,

0 commit comments

Comments
 (0)