fix(cron): re-arm one-shot at-jobs when rescheduled after completion (openclaw#28915) thanks @Glucksberg
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -121,6 +121,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
89
src/cron/service.issue-19676-at-reschedule.test.ts
Normal file
89
src/cron/service.issue-19676-at-reschedule.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeJobNextRunAtMs } from "./service/jobs.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
const ORIGINAL_AT_MS = Date.parse("2026-02-22T10:00:00.000Z");
|
||||
const LAST_RUN_AT_MS = Date.parse("2026-02-22T10:00:05.000Z"); // ran shortly after scheduled time
|
||||
const RESCHEDULED_AT_MS = Date.parse("2026-02-22T12:00:00.000Z"); // rescheduled to 2 hours later
|
||||
|
||||
function createAtJob(
|
||||
overrides: { state?: CronJob["state"]; schedule?: CronJob["schedule"] } = {},
|
||||
): CronJob {
|
||||
return {
|
||||
id: "issue-19676",
|
||||
name: "one-shot-reminder",
|
||||
enabled: true,
|
||||
createdAtMs: ORIGINAL_AT_MS - 60_000,
|
||||
updatedAtMs: ORIGINAL_AT_MS - 60_000,
|
||||
schedule: overrides.schedule ?? { kind: "at", at: new Date(ORIGINAL_AT_MS).toISOString() },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "agentTurn", message: "reminder" },
|
||||
delivery: { mode: "none" },
|
||||
state: { ...overrides.state },
|
||||
};
|
||||
}
|
||||
|
||||
describe("Cron issue #19676 at-job reschedule", () => {
|
||||
it("returns undefined for a completed one-shot job that has not been rescheduled", () => {
|
||||
const job = createAtJob({
|
||||
state: { lastStatus: "ok", lastRunAtMs: LAST_RUN_AT_MS },
|
||||
});
|
||||
const nowMs = LAST_RUN_AT_MS + 1_000;
|
||||
expect(computeJobNextRunAtMs(job, nowMs)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the new atMs when a completed one-shot job is rescheduled to a future time", () => {
|
||||
const job = createAtJob({
|
||||
schedule: { kind: "at", at: new Date(RESCHEDULED_AT_MS).toISOString() },
|
||||
state: { lastStatus: "ok", lastRunAtMs: LAST_RUN_AT_MS },
|
||||
});
|
||||
const nowMs = LAST_RUN_AT_MS + 1_000;
|
||||
expect(computeJobNextRunAtMs(job, nowMs)).toBe(RESCHEDULED_AT_MS);
|
||||
});
|
||||
|
||||
it("returns the new atMs when rescheduled via legacy numeric atMs field", () => {
|
||||
const job = createAtJob({
|
||||
state: { lastStatus: "ok", lastRunAtMs: LAST_RUN_AT_MS },
|
||||
});
|
||||
// Simulate legacy numeric atMs field on the schedule object.
|
||||
const schedule = job.schedule as { kind: "at"; atMs?: number };
|
||||
schedule.atMs = RESCHEDULED_AT_MS;
|
||||
const nowMs = LAST_RUN_AT_MS + 1_000;
|
||||
expect(computeJobNextRunAtMs(job, nowMs)).toBe(RESCHEDULED_AT_MS);
|
||||
});
|
||||
|
||||
it("returns undefined when rescheduled to a time before the last run", () => {
|
||||
const beforeLastRun = LAST_RUN_AT_MS - 60_000;
|
||||
const job = createAtJob({
|
||||
schedule: { kind: "at", at: new Date(beforeLastRun).toISOString() },
|
||||
state: { lastStatus: "ok", lastRunAtMs: LAST_RUN_AT_MS },
|
||||
});
|
||||
const nowMs = LAST_RUN_AT_MS + 1_000;
|
||||
expect(computeJobNextRunAtMs(job, nowMs)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still returns atMs for a job that has never run", () => {
|
||||
const job = createAtJob();
|
||||
const nowMs = ORIGINAL_AT_MS - 60_000;
|
||||
expect(computeJobNextRunAtMs(job, nowMs)).toBe(ORIGINAL_AT_MS);
|
||||
});
|
||||
|
||||
it("still returns atMs for a job whose last status is error", () => {
|
||||
const job = createAtJob({
|
||||
state: { lastStatus: "error", lastRunAtMs: LAST_RUN_AT_MS },
|
||||
});
|
||||
const nowMs = LAST_RUN_AT_MS + 1_000;
|
||||
expect(computeJobNextRunAtMs(job, nowMs)).toBe(ORIGINAL_AT_MS);
|
||||
});
|
||||
|
||||
it("returns undefined for a disabled job even if rescheduled", () => {
|
||||
const job = createAtJob({
|
||||
schedule: { kind: "at", at: new Date(RESCHEDULED_AT_MS).toISOString() },
|
||||
state: { lastStatus: "ok", lastRunAtMs: LAST_RUN_AT_MS },
|
||||
});
|
||||
job.enabled = false;
|
||||
const nowMs = LAST_RUN_AT_MS + 1_000;
|
||||
expect(computeJobNextRunAtMs(job, nowMs)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -181,10 +181,6 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
|
||||
return isFiniteTimestamp(next) ? next : undefined;
|
||||
}
|
||||
if (job.schedule.kind === "at") {
|
||||
// One-shot jobs stay due until they successfully finish.
|
||||
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) {
|
||||
return undefined;
|
||||
}
|
||||
// Handle both canonical `at` (string) and legacy `atMs` (number) fields.
|
||||
// The store migration should convert atMs→at, but be defensive in case
|
||||
// the migration hasn't run yet or was bypassed.
|
||||
@@ -197,6 +193,14 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
|
||||
: typeof schedule.at === "string"
|
||||
? parseAbsoluteTimeMs(schedule.at)
|
||||
: null;
|
||||
// One-shot jobs stay due until they successfully finish, but if the
|
||||
// schedule was updated to a time after the last run, re-arm the job.
|
||||
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) {
|
||||
if (atMs !== null && Number.isFinite(atMs) && atMs > job.state.lastRunAtMs) {
|
||||
return atMs;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return atMs !== null && Number.isFinite(atMs) ? atMs : undefined;
|
||||
}
|
||||
const next = computeStaggeredCronNextRunAtMs(job, nowMs);
|
||||
|
||||
Reference in New Issue
Block a user