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:
Glucksberg
2026-03-01 23:31:24 -04:00
committed by GitHub
parent 904016b7de
commit 08c35eb13f
3 changed files with 98 additions and 4 deletions

View File

@@ -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.

View 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();
});
});

View File

@@ -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);