mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:00:43 +00:00
test: share unresolved cron next-run fixtures
This commit is contained in:
@@ -12,52 +12,89 @@ import { onTimer } from "./service/timer.js";
|
||||
|
||||
const issue66019Fixtures = setupCronRegressionFixtures({ prefix: "cron-66019-" });
|
||||
|
||||
function createIssue66019Job(params: { id: string; scheduledAt: number }) {
|
||||
return createIsolatedRegressionJob({
|
||||
id: params.id,
|
||||
name: params.id,
|
||||
scheduledAt: params.scheduledAt,
|
||||
schedule: { kind: "cron", expr: "0 7 * * *", tz: "Asia/Shanghai" },
|
||||
payload: { kind: "agentTurn", message: "ping" },
|
||||
state: { nextRunAtMs: params.scheduledAt - 1_000 },
|
||||
});
|
||||
}
|
||||
|
||||
function createIssue66019State(params: {
|
||||
storePath: string;
|
||||
nowMs: () => number;
|
||||
runIsolatedAgentJob: Parameters<typeof createCronServiceState>[0]["runIsolatedAgentJob"];
|
||||
}) {
|
||||
return createCronServiceState({
|
||||
cronEnabled: true,
|
||||
storePath: params.storePath,
|
||||
log: noopLogger,
|
||||
nowMs: params.nowMs,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob: params.runIsolatedAgentJob,
|
||||
});
|
||||
}
|
||||
|
||||
function clearCronTimer(state: ReturnType<typeof createCronServiceState>) {
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectJobDoesNotRefireWhenNextRunIsUnresolved(params: {
|
||||
state: ReturnType<typeof createCronServiceState>;
|
||||
runIsolatedAgentJob: unknown;
|
||||
advanceNow: () => void;
|
||||
}) {
|
||||
await onTimer(params.state);
|
||||
expect(params.runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(params.state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined();
|
||||
|
||||
params.advanceNow();
|
||||
await onTimer(params.state);
|
||||
|
||||
expect(params.runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(params.state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined();
|
||||
}
|
||||
|
||||
describe("#66019 unresolved next-run repro", () => {
|
||||
it("does not refire a recurring cron job 2s later when next-run resolution returns undefined", async () => {
|
||||
const store = issue66019Fixtures.makeStorePath();
|
||||
const scheduledAt = Date.parse("2026-04-13T15:40:00.000Z");
|
||||
let now = scheduledAt;
|
||||
|
||||
const cronJob = createIsolatedRegressionJob({
|
||||
const cronJob = createIssue66019Job({
|
||||
id: "cron-66019-minimal-success",
|
||||
name: "cron-66019-minimal-success",
|
||||
scheduledAt,
|
||||
schedule: { kind: "cron", expr: "0 7 * * *", tz: "Asia/Shanghai" },
|
||||
payload: { kind: "agentTurn", message: "ping" },
|
||||
state: { nextRunAtMs: scheduledAt - 1_000 },
|
||||
});
|
||||
await writeCronJobs(store.storePath, [cronJob]);
|
||||
|
||||
const runIsolatedAgentJob = createDefaultIsolatedRunner();
|
||||
const nextRunSpy = vi.spyOn(schedule, "computeNextRunAtMs").mockReturnValue(undefined);
|
||||
const state = createCronServiceState({
|
||||
cronEnabled: true,
|
||||
const state = createIssue66019State({
|
||||
storePath: store.storePath,
|
||||
log: noopLogger,
|
||||
nowMs: () => now,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob,
|
||||
});
|
||||
|
||||
try {
|
||||
await onTimer(state);
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined();
|
||||
|
||||
// Before the fix, applyJobResult would synthesize endedAt + 2_000 here,
|
||||
// so a second tick a couple seconds later would refire the same job.
|
||||
now = scheduledAt + 2_001;
|
||||
await onTimer(state);
|
||||
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined();
|
||||
await expectJobDoesNotRefireWhenNextRunIsUnresolved({
|
||||
state,
|
||||
runIsolatedAgentJob,
|
||||
advanceNow: () => {
|
||||
now = scheduledAt + 2_001;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
nextRunSpy.mockRestore();
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
clearCronTimer(state);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,13 +103,9 @@ describe("#66019 unresolved next-run repro", () => {
|
||||
const scheduledAt = Date.parse("2026-04-13T15:45:00.000Z");
|
||||
let now = scheduledAt;
|
||||
|
||||
const cronJob = createIsolatedRegressionJob({
|
||||
const cronJob = createIssue66019Job({
|
||||
id: "cron-66019-minimal-error",
|
||||
name: "cron-66019-minimal-error",
|
||||
scheduledAt,
|
||||
schedule: { kind: "cron", expr: "0 7 * * *", tz: "Asia/Shanghai" },
|
||||
payload: { kind: "agentTurn", message: "ping" },
|
||||
state: { nextRunAtMs: scheduledAt - 1_000 },
|
||||
});
|
||||
await writeCronJobs(store.storePath, [cronJob]);
|
||||
|
||||
@@ -81,34 +114,25 @@ describe("#66019 unresolved next-run repro", () => {
|
||||
error: "synthetic failure",
|
||||
});
|
||||
const nextRunSpy = vi.spyOn(schedule, "computeNextRunAtMs").mockReturnValue(undefined);
|
||||
const state = createCronServiceState({
|
||||
cronEnabled: true,
|
||||
const state = createIssue66019State({
|
||||
storePath: store.storePath,
|
||||
log: noopLogger,
|
||||
nowMs: () => now,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob,
|
||||
});
|
||||
|
||||
try {
|
||||
await onTimer(state);
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined();
|
||||
|
||||
// Before the fix, the error branch would synthesize the first backoff
|
||||
// retry (30s), so the next tick after that window would rerun the job.
|
||||
now = scheduledAt + 30_001;
|
||||
await onTimer(state);
|
||||
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined();
|
||||
await expectJobDoesNotRefireWhenNextRunIsUnresolved({
|
||||
state,
|
||||
runIsolatedAgentJob,
|
||||
advanceNow: () => {
|
||||
now = scheduledAt + 30_001;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
nextRunSpy.mockRestore();
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
clearCronTimer(state);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -117,13 +141,9 @@ describe("#66019 unresolved next-run repro", () => {
|
||||
const scheduledAt = Date.parse("2026-04-13T15:50:00.000Z");
|
||||
let now = scheduledAt;
|
||||
|
||||
const cronJob = createIsolatedRegressionJob({
|
||||
const cronJob = createIssue66019Job({
|
||||
id: "cron-66019-error-backoff-floor",
|
||||
name: "cron-66019-error-backoff-floor",
|
||||
scheduledAt,
|
||||
schedule: { kind: "cron", expr: "0 7 * * *", tz: "Asia/Shanghai" },
|
||||
payload: { kind: "agentTurn", message: "ping" },
|
||||
state: { nextRunAtMs: scheduledAt - 1_000 },
|
||||
});
|
||||
await writeCronJobs(store.storePath, [cronJob]);
|
||||
|
||||
@@ -138,13 +158,9 @@ describe("#66019 unresolved next-run repro", () => {
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValue(naturalNext);
|
||||
const state = createCronServiceState({
|
||||
cronEnabled: true,
|
||||
const state = createIssue66019State({
|
||||
storePath: store.storePath,
|
||||
log: noopLogger,
|
||||
nowMs: () => now,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob,
|
||||
});
|
||||
|
||||
@@ -162,10 +178,7 @@ describe("#66019 unresolved next-run repro", () => {
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
nextRunSpy.mockRestore();
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
clearCronTimer(state);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user