mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(cron): honor updated recurring schedule anchors (#128893)
This commit is contained in:
committed by
GitHub
parent
2ec0757c2c
commit
9abfc1b4a3
@@ -73,51 +73,91 @@ describe("update() must not drop a due every-job's pending run", () => {
|
||||
cron.stop();
|
||||
});
|
||||
|
||||
it("re-anchors an every-job to the edit time when the interval actually changes", async () => {
|
||||
const store = await makeStorePath();
|
||||
const base = Date.parse("2025-12-13T00:00:00.000Z");
|
||||
it.each([
|
||||
{ name: "before its first run", previousEveryMs: 10_000, nextEveryMs: 3_600_000 },
|
||||
{
|
||||
name: "after a completed run when the interval increases",
|
||||
previousEveryMs: 10_000,
|
||||
nextEveryMs: 3_600_000,
|
||||
completedRun: true,
|
||||
},
|
||||
{
|
||||
name: "after a completed run when the interval decreases",
|
||||
previousEveryMs: 60_000,
|
||||
nextEveryMs: 10_000,
|
||||
completedRun: true,
|
||||
},
|
||||
{
|
||||
name: "at an explicit future anchor after a completed run",
|
||||
previousEveryMs: 10_000,
|
||||
nextEveryMs: 3_600_000,
|
||||
completedRun: true,
|
||||
futureAnchorOffsetMs: 7_200_000,
|
||||
},
|
||||
])(
|
||||
"re-anchors an every-job $name",
|
||||
async ({ previousEveryMs, nextEveryMs, completedRun, futureAnchorOffsetMs }) => {
|
||||
const store = await makeStorePath();
|
||||
const base = Date.parse("2025-12-13T00:00:00.000Z");
|
||||
|
||||
const finished = createFinishedBarrier();
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeat: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
onEvent: finished.onEvent,
|
||||
});
|
||||
const finished = createFinishedBarrier();
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeat: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
onEvent: finished.onEvent,
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
await cron.start();
|
||||
|
||||
const job = await cron.add({
|
||||
name: "every 10s",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 10_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "agentTurn", message: "tick" },
|
||||
});
|
||||
const jobId = job.id;
|
||||
expect(job.schedule).toMatchObject({ kind: "every", anchorMs: base });
|
||||
const job = await cron.add({
|
||||
name: "every 10s",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: previousEveryMs },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "agentTurn", message: "tick" },
|
||||
});
|
||||
const jobId = job.id;
|
||||
expect(job.schedule).toMatchObject({ kind: "every", anchorMs: base });
|
||||
|
||||
// User edits the interval from 10s to 1h. The control UI omits the internal
|
||||
// anchorMs, so the new cadence must start from the edit time rather than
|
||||
// keeping the old phase; nextRunAtMs is one new interval from now.
|
||||
const editTime = base + 3_000;
|
||||
vi.setSystemTime(new Date(editTime));
|
||||
await cron.update(jobId, { schedule: { kind: "every", everyMs: 3_600_000 } });
|
||||
let editTime = base + 3_000;
|
||||
if (completedRun) {
|
||||
vi.setSystemTime(new Date(base + previousEveryMs + 5));
|
||||
const firstRun = finished.waitForOk(jobId);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await firstRun;
|
||||
const completedJob = (await cron.list({ includeDisabled: true })).find(
|
||||
(candidate) => candidate.id === jobId,
|
||||
)!;
|
||||
editTime = completedJob.state.lastRunAtMs! + 3_000;
|
||||
}
|
||||
|
||||
const current = (await cron.list({ includeDisabled: true })).find((j) => j.id === jobId)!;
|
||||
expect(current.schedule).toMatchObject({
|
||||
kind: "every",
|
||||
everyMs: 3_600_000,
|
||||
anchorMs: editTime,
|
||||
});
|
||||
expect(current.state.nextRunAtMs).toBe(editTime + 3_600_000);
|
||||
vi.setSystemTime(new Date(editTime));
|
||||
const futureAnchorMs =
|
||||
futureAnchorOffsetMs === undefined ? undefined : editTime + futureAnchorOffsetMs;
|
||||
await cron.update(jobId, {
|
||||
schedule: {
|
||||
kind: "every",
|
||||
everyMs: nextEveryMs,
|
||||
...(futureAnchorMs === undefined ? {} : { anchorMs: futureAnchorMs }),
|
||||
},
|
||||
});
|
||||
|
||||
cron.stop();
|
||||
});
|
||||
const current = (await cron.list({ includeDisabled: true })).find((j) => j.id === jobId)!;
|
||||
expect(current.schedule).toMatchObject({
|
||||
kind: "every",
|
||||
everyMs: nextEveryMs,
|
||||
anchorMs: futureAnchorMs ?? editTime,
|
||||
});
|
||||
expect(current.state.nextRunAtMs).toBe(futureAnchorMs ?? editTime + nextEveryMs);
|
||||
|
||||
cron.stop();
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a due cron-job nextRunAtMs on an idempotent schedule re-save", async () => {
|
||||
const store = await makeStorePath();
|
||||
|
||||
@@ -323,8 +323,13 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
|
||||
if (everyMs < 1) {
|
||||
return undefined;
|
||||
}
|
||||
const fallbackAnchorMs = isFiniteTimestamp(job.createdAtMs) ? job.createdAtMs : nowMs;
|
||||
const anchorMs = resolveEveryAnchorMs({
|
||||
schedule: job.schedule,
|
||||
fallbackAnchorMs,
|
||||
});
|
||||
const lastRunAtMs = job.state.lastRunAtMs;
|
||||
if (isFiniteTimestamp(lastRunAtMs)) {
|
||||
if (isFiniteTimestamp(lastRunAtMs) && lastRunAtMs >= anchorMs) {
|
||||
const nextFromLastRun = Math.floor(lastRunAtMs) + everyMs;
|
||||
if (!isFiniteTimestamp(nextFromLastRun)) {
|
||||
return undefined;
|
||||
@@ -333,11 +338,6 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
|
||||
return nextFromLastRun;
|
||||
}
|
||||
}
|
||||
const fallbackAnchorMs = isFiniteTimestamp(job.createdAtMs) ? job.createdAtMs : nowMs;
|
||||
const anchorMs = resolveEveryAnchorMs({
|
||||
schedule: job.schedule,
|
||||
fallbackAnchorMs,
|
||||
});
|
||||
const next = computeNextRunAtMs({ ...job.schedule, everyMs, anchorMs }, nowMs);
|
||||
return isFiniteTimestamp(next) ? next : undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user