feat(cron): add default stagger controls for scheduled jobs

This commit is contained in:
Peter Steinberger
2026-02-17 23:46:05 +01:00
parent b98b113b88
commit c26cf6aa83
20 changed files with 907 additions and 56 deletions

View File

@@ -1,14 +1,18 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const callGatewayFromCli = vi.fn(
async (method: string, _opts: unknown, params?: unknown, _timeoutMs?: number) => {
if (method === "cron.status") {
return { enabled: true };
}
return { ok: true, params };
},
);
const defaultGatewayMock = async (
method: string,
_opts: unknown,
params?: unknown,
_timeoutMs?: number,
) => {
if (method === "cron.status") {
return { enabled: true };
}
return { ok: true, params };
};
const callGatewayFromCli = vi.fn(defaultGatewayMock);
vi.mock("./gateway-rpc.js", async () => {
const actual = await vi.importActual<typeof import("./gateway-rpc.js")>("./gateway-rpc.js");
@@ -45,8 +49,13 @@ function buildProgram() {
return program;
}
function resetGatewayMock() {
callGatewayFromCli.mockReset();
callGatewayFromCli.mockImplementation(defaultGatewayMock);
}
async function runCronEditAndGetPatch(editArgs: string[]): Promise<CronUpdatePatch> {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
await program.parseAsync(["cron", "edit", "job-1", ...editArgs], { from: "user" });
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
@@ -55,7 +64,7 @@ async function runCronEditAndGetPatch(editArgs: string[]): Promise<CronUpdatePat
describe("cron cli", () => {
it("trims model and thinking on cron add", { timeout: 60_000 }, async () => {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
@@ -89,7 +98,7 @@ describe("cron cli", () => {
});
it("defaults isolated cron add to announce delivery", async () => {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
@@ -116,7 +125,7 @@ describe("cron cli", () => {
});
it("infers sessionTarget from payload when --session is omitted", async () => {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
@@ -130,7 +139,7 @@ describe("cron cli", () => {
expect(params?.sessionTarget).toBe("main");
expect(params?.payload?.kind).toBe("systemEvent");
callGatewayFromCli.mockClear();
resetGatewayMock();
await program.parseAsync(
["cron", "add", "--name", "Isolated task", "--cron", "* * * * *", "--message", "hello"],
@@ -144,7 +153,7 @@ describe("cron cli", () => {
});
it("supports --keep-after-run on cron add", async () => {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
@@ -171,7 +180,7 @@ describe("cron cli", () => {
});
it("sends agent id on cron add", async () => {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
@@ -199,7 +208,7 @@ describe("cron cli", () => {
});
it("omits empty model and thinking on cron edit", async () => {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
@@ -218,7 +227,7 @@ describe("cron cli", () => {
});
it("trims model and thinking on cron edit", async () => {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
@@ -247,7 +256,7 @@ describe("cron cli", () => {
});
it("sets and clears agent id on cron edit", async () => {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
@@ -259,7 +268,7 @@ describe("cron cli", () => {
const patch = updateCall?.[2] as { patch?: { agentId?: unknown } };
expect(patch?.patch?.agentId).toBe("ops");
callGatewayFromCli.mockClear();
resetGatewayMock();
await program.parseAsync(["cron", "edit", "job-2", "--clear-agent"], {
from: "user",
});
@@ -269,7 +278,7 @@ describe("cron cli", () => {
});
it("allows model/thinking updates without --message", async () => {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
@@ -288,7 +297,7 @@ describe("cron cli", () => {
});
it("updates delivery settings without requiring --message", async () => {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
@@ -313,7 +322,7 @@ describe("cron cli", () => {
});
it("supports --no-deliver on cron edit", async () => {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
@@ -329,7 +338,7 @@ describe("cron cli", () => {
});
it("does not include undefined delivery fields when updating message", async () => {
callGatewayFromCli.mockClear();
resetGatewayMock();
const program = buildProgram();
@@ -404,4 +413,184 @@ describe("cron cli", () => {
expect(patch?.patch?.delivery?.mode).toBe("announce");
expect(patch?.patch?.delivery?.bestEffort).toBe(false);
});
it("sets explicit stagger for cron add", async () => {
resetGatewayMock();
const program = buildProgram();
await program.parseAsync(
[
"cron",
"add",
"--name",
"staggered",
"--cron",
"0 * * * *",
"--stagger",
"45s",
"--session",
"main",
"--system-event",
"tick",
],
{ from: "user" },
);
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
const params = addCall?.[2] as { schedule?: { kind?: string; staggerMs?: number } };
expect(params?.schedule?.kind).toBe("cron");
expect(params?.schedule?.staggerMs).toBe(45_000);
});
it("sets exact cron mode on add", async () => {
resetGatewayMock();
const program = buildProgram();
await program.parseAsync(
[
"cron",
"add",
"--name",
"exact",
"--cron",
"0 * * * *",
"--exact",
"--session",
"main",
"--system-event",
"tick",
],
{ from: "user" },
);
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
const params = addCall?.[2] as { schedule?: { kind?: string; staggerMs?: number } };
expect(params?.schedule?.kind).toBe("cron");
expect(params?.schedule?.staggerMs).toBe(0);
});
it("rejects --stagger with --exact on add", async () => {
resetGatewayMock();
const program = buildProgram();
await expect(
program.parseAsync(
[
"cron",
"add",
"--name",
"invalid",
"--cron",
"0 * * * *",
"--stagger",
"1m",
"--exact",
"--session",
"main",
"--system-event",
"tick",
],
{ from: "user" },
),
).rejects.toThrow("__exit__:1");
});
it("rejects --stagger when schedule is not cron", async () => {
resetGatewayMock();
const program = buildProgram();
await expect(
program.parseAsync(
[
"cron",
"add",
"--name",
"invalid",
"--every",
"10m",
"--stagger",
"30s",
"--session",
"main",
"--system-event",
"tick",
],
{ from: "user" },
),
).rejects.toThrow("__exit__:1");
});
it("sets explicit stagger for cron edit", async () => {
resetGatewayMock();
const program = buildProgram();
await program.parseAsync(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"], {
from: "user",
});
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { schedule?: { kind?: string; staggerMs?: number } };
};
expect(patch?.patch?.schedule?.kind).toBe("cron");
expect(patch?.patch?.schedule?.staggerMs).toBe(30_000);
});
it("applies --exact to existing cron job without requiring --cron on edit", async () => {
resetGatewayMock();
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "cron.status") {
return { enabled: true };
}
if (method === "cron.list") {
return {
jobs: [
{
id: "job-1",
schedule: { kind: "cron", expr: "0 */2 * * *", tz: "UTC", staggerMs: 300_000 },
},
],
};
}
return { ok: true, params };
},
);
const program = buildProgram();
await program.parseAsync(["cron", "edit", "job-1", "--exact"], { from: "user" });
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { schedule?: { kind?: string; expr?: string; tz?: string; staggerMs?: number } };
};
expect(patch?.patch?.schedule).toEqual({
kind: "cron",
expr: "0 */2 * * *",
tz: "UTC",
staggerMs: 0,
});
});
it("rejects --exact on edit when existing job is not cron", async () => {
resetGatewayMock();
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "cron.status") {
return { enabled: true };
}
if (method === "cron.list") {
return {
jobs: [{ id: "job-1", schedule: { kind: "every", everyMs: 60_000 } }],
};
}
return { ok: true, params };
},
);
const program = buildProgram();
await expect(
program.parseAsync(["cron", "edit", "job-1", "--exact"], { from: "user" }),
).rejects.toThrow("__exit__:1");
});
});