Skip to content

Commit 08c35eb

Browse files
GlucksbergTakhoffman
andauthored
fix(cron): re-arm one-shot at-jobs when rescheduled after completion (#28915) thanks @Glucksberg
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Glucksberg <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 904016b commit 08c35eb

File tree

3 files changed

+98
-4
lines changed

3 files changed

+98
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ Docs: https://docs.openclaw.ai
121121
- Matrix/Directory room IDs: preserve original room-ID casing for direct `!roomId` group lookups (without `:server`) so allowlist checks do not fail on case-sensitive IDs. Landed from contributor PR #31201 by @williamos-dev. Thanks @williamos-dev.
122122
- Discord/Inbound media fallback: preserve attachment and sticker metadata when Discord CDN fetch/save fails by keeping URL-based media entries in context, with regression coverage for save failures and mixed success/failure ordering. Landed from contributor PR #28906 by @Sid-Qin. Thanks @Sid-Qin.
123123
- Auto-reply/Block reply timeout path: normalize `onBlockReply(...)` execution through `Promise.resolve(...)` before timeout wrapping so mixed sync/async callbacks keep deterministic timeout behavior across strict TypeScript build paths. (#19779) Thanks @dalefrieswthat and @vincentkoc.
124+
- Cron/One-shot reschedule re-arm: allow completed `at` jobs to run again when rescheduled to a later time than `lastRunAtMs`, while keeping completed non-rescheduled one-shot jobs inactive. (#28915) Thanks @Glucksberg.
124125
- Docs/Docker images: clarify the official GHCR image source and tag guidance (`main`, `latest`, `<version>`), and document that `OPENCLAW_IMAGE` skips local image builds but still uses the repo-local compose/setup flow. (#27214, #31180) Fixes #15655. Thanks @ipl31.
125126
- Docker/Image base annotations: add OCI labels for base image plus source/documentation/license metadata, include revision/version/created labels in Docker release builds, and document annotation keys/release context in install docs. Fixes #27945. Thanks @vincentkoc.
126127
- Agents/Model fallback: classify additional network transport errors (`ECONNREFUSED`, `ENETUNREACH`, `EHOSTUNREACH`, `ENETRESET`, `EAI_AGAIN`) as failover-worthy so fallback chains advance when primary providers are unreachable. Landed from contributor PR #19077 by @ayanesakura. Thanks @ayanesakura.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, expect, it } from "vitest";
2+
import { computeJobNextRunAtMs } from "./service/jobs.js";
3+
import type { CronJob } from "./types.js";
4+
5+
const ORIGINAL_AT_MS = Date.parse("2026-02-22T10:00:00.000Z");
6+
const LAST_RUN_AT_MS = Date.parse("2026-02-22T10:00:05.000Z"); // ran shortly after scheduled time
7+
const RESCHEDULED_AT_MS = Date.parse("2026-02-22T12:00:00.000Z"); // rescheduled to 2 hours later
8+
9+
function createAtJob(
10+
overrides: { state?: CronJob["state"]; schedule?: CronJob["schedule"] } = {},
11+
): CronJob {
12+
return {
13+
id: "issue-19676",
14+
name: "one-shot-reminder",
15+
enabled: true,
16+
createdAtMs: ORIGINAL_AT_MS - 60_000,
17+
updatedAtMs: ORIGINAL_AT_MS - 60_000,
18+
schedule: overrides.schedule ?? { kind: "at", at: new Date(ORIGINAL_AT_MS).toISOString() },
19+
sessionTarget: "isolated",
20+
wakeMode: "next-heartbeat",
21+
payload: { kind: "agentTurn", message: "reminder" },
22+
delivery: { mode: "none" },
23+
state: { ...overrides.state },
24+
};
25+
}
26+
27+
describe("Cron issue #19676 at-job reschedule", () => {
28+
it("returns undefined for a completed one-shot job that has not been rescheduled", () => {
29+
const job = createAtJob({
30+
state: { lastStatus: "ok", lastRunAtMs: LAST_RUN_AT_MS },
31+
});
32+
const nowMs = LAST_RUN_AT_MS + 1_000;
33+
expect(computeJobNextRunAtMs(job, nowMs)).toBeUndefined();
34+
});
35+
36+
it("returns the new atMs when a completed one-shot job is rescheduled to a future time", () => {
37+
const job = createAtJob({
38+
schedule: { kind: "at", at: new Date(RESCHEDULED_AT_MS).toISOString() },
39+
state: { lastStatus: "ok", lastRunAtMs: LAST_RUN_AT_MS },
40+
});
41+
const nowMs = LAST_RUN_AT_MS + 1_000;
42+
expect(computeJobNextRunAtMs(job, nowMs)).toBe(RESCHEDULED_AT_MS);
43+
});
44+
45+
it("returns the new atMs when rescheduled via legacy numeric atMs field", () => {
46+
const job = createAtJob({
47+
state: { lastStatus: "ok", lastRunAtMs: LAST_RUN_AT_MS },
48+
});
49+
// Simulate legacy numeric atMs field on the schedule object.
50+
const schedule = job.schedule as { kind: "at"; atMs?: number };
51+
schedule.atMs = RESCHEDULED_AT_MS;
52+
const nowMs = LAST_RUN_AT_MS + 1_000;
53+
expect(computeJobNextRunAtMs(job, nowMs)).toBe(RESCHEDULED_AT_MS);
54+
});
55+
56+
it("returns undefined when rescheduled to a time before the last run", () => {
57+
const beforeLastRun = LAST_RUN_AT_MS - 60_000;
58+
const job = createAtJob({
59+
schedule: { kind: "at", at: new Date(beforeLastRun).toISOString() },
60+
state: { lastStatus: "ok", lastRunAtMs: LAST_RUN_AT_MS },
61+
});
62+
const nowMs = LAST_RUN_AT_MS + 1_000;
63+
expect(computeJobNextRunAtMs(job, nowMs)).toBeUndefined();
64+
});
65+
66+
it("still returns atMs for a job that has never run", () => {
67+
const job = createAtJob();
68+
const nowMs = ORIGINAL_AT_MS - 60_000;
69+
expect(computeJobNextRunAtMs(job, nowMs)).toBe(ORIGINAL_AT_MS);
70+
});
71+
72+
it("still returns atMs for a job whose last status is error", () => {
73+
const job = createAtJob({
74+
state: { lastStatus: "error", lastRunAtMs: LAST_RUN_AT_MS },
75+
});
76+
const nowMs = LAST_RUN_AT_MS + 1_000;
77+
expect(computeJobNextRunAtMs(job, nowMs)).toBe(ORIGINAL_AT_MS);
78+
});
79+
80+
it("returns undefined for a disabled job even if rescheduled", () => {
81+
const job = createAtJob({
82+
schedule: { kind: "at", at: new Date(RESCHEDULED_AT_MS).toISOString() },
83+
state: { lastStatus: "ok", lastRunAtMs: LAST_RUN_AT_MS },
84+
});
85+
job.enabled = false;
86+
const nowMs = LAST_RUN_AT_MS + 1_000;
87+
expect(computeJobNextRunAtMs(job, nowMs)).toBeUndefined();
88+
});
89+
});

src/cron/service/jobs.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,6 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
181181
return isFiniteTimestamp(next) ? next : undefined;
182182
}
183183
if (job.schedule.kind === "at") {
184-
// One-shot jobs stay due until they successfully finish.
185-
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) {
186-
return undefined;
187-
}
188184
// Handle both canonical `at` (string) and legacy `atMs` (number) fields.
189185
// The store migration should convert atMs→at, but be defensive in case
190186
// the migration hasn't run yet or was bypassed.
@@ -197,6 +193,14 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
197193
: typeof schedule.at === "string"
198194
? parseAbsoluteTimeMs(schedule.at)
199195
: null;
196+
// One-shot jobs stay due until they successfully finish, but if the
197+
// schedule was updated to a time after the last run, re-arm the job.
198+
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) {
199+
if (atMs !== null && Number.isFinite(atMs) && atMs > job.state.lastRunAtMs) {
200+
return atMs;
201+
}
202+
return undefined;
203+
}
200204
return atMs !== null && Number.isFinite(atMs) ? atMs : undefined;
201205
}
202206
const next = computeStaggeredCronNextRunAtMs(job, nowMs);

0 commit comments

Comments
 (0)