diff --git a/CHANGELOG.md b/CHANGELOG.md index 093593fcb67..35de9cada51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, ``), 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. diff --git a/src/cron/service.issue-19676-at-reschedule.test.ts b/src/cron/service.issue-19676-at-reschedule.test.ts new file mode 100644 index 00000000000..32139dd7ddf --- /dev/null +++ b/src/cron/service.issue-19676-at-reschedule.test.ts @@ -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(); + }); +}); diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 7bb80ef37ac..740ddf83361 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -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);