92 lines
2.8 KiB
TypeScript
92 lines
2.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { CronJob } from "./types.js";
|
|
import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js";
|
|
import { createCronServiceState } from "./service/state.js";
|
|
import { onTimer } from "./service/timer.js";
|
|
|
|
const noopLogger = createNoopLogger();
|
|
const { makeStorePath } = createCronStoreHarness();
|
|
|
|
function createDueRecurringJob(params: {
|
|
id: string;
|
|
nowMs: number;
|
|
nextRunAtMs: number;
|
|
}): CronJob {
|
|
return {
|
|
id: params.id,
|
|
name: params.id,
|
|
enabled: true,
|
|
deleteAfterRun: false,
|
|
createdAtMs: params.nowMs,
|
|
updatedAtMs: params.nowMs,
|
|
schedule: { kind: "every", everyMs: 5 * 60_000 },
|
|
sessionTarget: "isolated",
|
|
wakeMode: "next-heartbeat",
|
|
payload: { kind: "agentTurn", message: "test" },
|
|
delivery: { mode: "none" },
|
|
state: { nextRunAtMs: params.nextRunAtMs },
|
|
};
|
|
}
|
|
|
|
describe("CronService - timer re-arm when running (#12025)", () => {
|
|
beforeEach(() => {
|
|
noopLogger.debug.mockClear();
|
|
noopLogger.info.mockClear();
|
|
noopLogger.warn.mockClear();
|
|
noopLogger.error.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("re-arms the timer when onTimer is called while state.running is true", async () => {
|
|
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
const store = await makeStorePath();
|
|
const now = Date.parse("2026-02-06T10:05:00.000Z");
|
|
|
|
const state = createCronServiceState({
|
|
cronEnabled: true,
|
|
storePath: store.storePath,
|
|
log: noopLogger,
|
|
nowMs: () => now,
|
|
enqueueSystemEvent: vi.fn(),
|
|
requestHeartbeatNow: vi.fn(),
|
|
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }),
|
|
});
|
|
|
|
// Simulate a job that is currently running.
|
|
state.running = true;
|
|
state.store = {
|
|
version: 1,
|
|
jobs: [
|
|
createDueRecurringJob({
|
|
id: "recurring-job",
|
|
nowMs: now,
|
|
nextRunAtMs: now + 5 * 60_000,
|
|
}),
|
|
],
|
|
};
|
|
|
|
// Before the fix in #12025, this would return without re-arming,
|
|
// silently killing the scheduler.
|
|
await onTimer(state);
|
|
|
|
// The timer must be re-armed so the scheduler continues ticking,
|
|
// with a fixed 60s delay to avoid hot-looping.
|
|
expect(state.timer).not.toBeNull();
|
|
expect(timeoutSpy).toHaveBeenCalled();
|
|
const delays = timeoutSpy.mock.calls
|
|
.map(([, delay]) => delay)
|
|
.filter((d): d is number => typeof d === "number");
|
|
expect(delays).toContain(60_000);
|
|
|
|
// state.running should still be true (onTimer bailed out, didn't
|
|
// touch it — the original caller's finally block handles that).
|
|
expect(state.running).toBe(true);
|
|
|
|
timeoutSpy.mockRestore();
|
|
await store.cleanup();
|
|
});
|
|
});
|