Reject cron steps that would silently run hourly (#8607)

* fix(schedules): reject ignored numeric cron steps

* fix(schedules): preserve saved numeric steps during recovery

Reject unsupported numeric steps at create/update while keeping saved
schedules executable with their previous singleton cadence.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-11-03 00:56:28 +00:00
committed by GitHub
co-authored by 见安
parent 07a2e4accd
commit 736f7bf042
4 changed files with 123 additions and 2 deletions
+8 -2
View File
@@ -1,4 +1,7 @@
import { parseCronExpression } from "@getpaseo/protocol/schedule/cron-expression";
import {
parseCronExpression,
validateCronExpression,
} from "@getpaseo/protocol/schedule/cron-expression";
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
interface CronDateParts {
@@ -78,7 +81,10 @@ function createCronDatePartsReader(timeZone: string | undefined): (date: Date) =
export function validateScheduleCadence(cadence: ScheduleCadence): void {
if (cadence.type === "cron") {
parseCronExpression(cadence.expression);
const error = validateCronExpression(cadence.expression);
if (error) {
throw new Error(error);
}
if (cadence.timezone !== undefined) {
assertValidTimeZone(cadence.timezone);
}
@@ -2114,6 +2114,81 @@ describe("ScheduleService", () => {
});
});
test("rejects numeric cron steps when creating or updating a schedule", async () => {
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
});
const input = {
prompt: "Periodic check",
cadence: { type: "cron" as const, expression: "*/15 * * * *" },
target: {
type: "new-agent" as const,
config: { provider: "claude" as const, cwd: tempDir },
},
runOnCreate: false,
};
await expect(
service.create({ ...input, cadence: { type: "cron", expression: "0/15 * * * *" } }),
).rejects.toThrow("Invalid cron minute step: use * or a range before /");
expect(await service.list()).toEqual([]);
const created = await service.create(input);
await expect(
service.update({ id: created.id, cadence: { type: "cron", expression: "0/15 * * * *" } }),
).rejects.toThrow("Invalid cron minute step: use * or a range before /");
expect((await service.inspect(created.id)).cadence).toEqual(input.cadence);
});
test("recovers and runs numeric cron steps persisted by an older daemon", async () => {
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const created = await service.create({
prompt: "Periodic check",
cadence: { type: "cron", expression: "0 * * * *" },
target: {
type: "new-agent",
config: { provider: "claude", cwd: tempDir },
},
runOnCreate: false,
});
const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger());
await store.update(created.id, (schedule) => ({
...schedule,
cadence: { type: "cron", expression: "0/15 * * * *" },
}));
now = new Date("2026-01-01T03:10:00.000Z");
try {
await service.start();
await service.stop();
expect((await service.inspect(created.id)).nextRunAt).toBe("2026-01-01T04:00:00.000Z");
now = new Date("2026-01-01T04:00:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
expect(inspected.cadence).toEqual({ type: "cron", expression: "0/15 * * * *" });
expect(inspected.status).toBe("active");
expect(inspected.nextRunAt).toBe("2026-01-01T05:00:00.000Z");
expect(inspected.runs).toHaveLength(1);
expect(inspected.runs[0]).toMatchObject({ status: "succeeded", output: "ok" });
} finally {
await service.stop();
}
});
test("advances stale nextRunAt on daemon restart", async () => {
const service1 = createScheduleService({
paseoHome: tempDir,