





























@@ -642,4 +642,188 @@ describe("cron service ops seam coverage", () => {
642642await manualRun;
643643});
644644});
645+646+it("rejects add of a structurally valid cron expression that never matches", async () => {
647+const { storePath } = await makeStorePath();
648+const now = Date.parse("2026-06-09T00:00:00.000Z");
649+const state = createOkIsolatedCronState({ storePath, now });
650+651+await expect(
652+add(state, {
653+name: "feb 30 cleanup",
654+enabled: true,
655+schedule: { kind: "cron", expr: "0 0 30 2 *" },
656+sessionTarget: "isolated",
657+wakeMode: "next-heartbeat",
658+payload: { kind: "agentTurn", message: "do work" },
659+}),
660+).rejects.toThrow(/has no upcoming run time and would never fire/);
661+if (state.timer) {
662+clearTimeout(state.timer);
663+}
664+665+const loaded = await loadCronStore(storePath);
666+expect(loaded.jobs).toEqual([]);
667+});
668+669+it("accepts add of a satisfiable cron expression and arms a next run", async () => {
670+const { storePath } = await makeStorePath();
671+const now = Date.parse("2026-06-09T00:00:00.000Z");
672+const state = createOkIsolatedCronState({ storePath, now });
673+674+const job = await add(state, {
675+name: "daily cleanup",
676+enabled: true,
677+schedule: { kind: "cron", expr: "0 0 * * *" },
678+sessionTarget: "isolated",
679+wakeMode: "next-heartbeat",
680+payload: { kind: "agentTurn", message: "do work" },
681+});
682+if (state.timer) {
683+clearTimeout(state.timer);
684+}
685+686+expect(typeof job.state.nextRunAtMs).toBe("number");
687+expect(job.state.nextRunAtMs).toBeGreaterThan(now);
688+});
689+690+it("rejects update that changes a job to a never-matching cron expression", async () => {
691+const { storePath } = await makeStorePath();
692+const now = Date.parse("2026-06-09T00:00:00.000Z");
693+const state = createOkIsolatedCronState({ storePath, now });
694+695+const job = await add(state, {
696+name: "daily cleanup",
697+enabled: true,
698+schedule: { kind: "cron", expr: "0 0 * * *" },
699+sessionTarget: "isolated",
700+wakeMode: "next-heartbeat",
701+payload: { kind: "agentTurn", message: "do work" },
702+});
703+if (state.timer) {
704+clearTimeout(state.timer);
705+}
706+707+await expect(
708+update(state, job.id, { schedule: { kind: "cron", expr: "0 0 30 2 *" } }),
709+).rejects.toThrow(/has no upcoming run time and would never fire/);
710+if (state.timer) {
711+clearTimeout(state.timer);
712+}
713+714+const loaded = await loadCronStore(storePath);
715+const stored = loaded.jobs.find((entry) => entry.id === job.id);
716+expect(stored?.schedule).toMatchObject({ kind: "cron", expr: "0 0 * * *" });
717+});
718+719+it("allows non-schedule updates on a pre-existing never-matching job", async () => {
720+const { storePath } = await makeStorePath();
721+const now = Date.parse("2026-06-09T00:00:00.000Z");
722+await writeCronStoreSnapshot({
723+ storePath,
724+jobs: [
725+{
726+id: "legacy-unsatisfiable",
727+name: "legacy unsatisfiable",
728+enabled: true,
729+createdAtMs: now - 60_000,
730+updatedAtMs: now - 60_000,
731+schedule: { kind: "cron", expr: "0 0 30 2 *" },
732+sessionTarget: "isolated",
733+wakeMode: "next-heartbeat",
734+payload: { kind: "agentTurn", message: "do work" },
735+state: {},
736+},
737+],
738+});
739+const state = createOkIsolatedCronState({ storePath, now });
740+741+const updated = await update(state, "legacy-unsatisfiable", { enabled: false });
742+if (state.timer) {
743+clearTimeout(state.timer);
744+}
745+746+expect(updated.enabled).toBe(false);
747+});
748+749+it("rejects enabling a pre-existing never-matching job", async () => {
750+const { storePath } = await makeStorePath();
751+const now = Date.parse("2026-06-09T00:00:00.000Z");
752+await writeCronStoreSnapshot({
753+ storePath,
754+jobs: [
755+{
756+id: "legacy-unsatisfiable",
757+name: "legacy unsatisfiable",
758+enabled: false,
759+createdAtMs: now,
760+updatedAtMs: now,
761+schedule: { kind: "cron", expr: "0 0 30 2 *" },
762+sessionTarget: "isolated",
763+wakeMode: "next-heartbeat",
764+payload: { kind: "agentTurn", message: "do work" },
765+state: {},
766+},
767+],
768+});
769+const state = createOkIsolatedCronState({ storePath, now });
770+771+await expect(update(state, "legacy-unsatisfiable", { enabled: true })).rejects.toThrow(
772+/has no upcoming run time and would never fire/,
773+);
774+775+const loaded = await loadCronStore(storePath);
776+expect(loaded.jobs[0]?.enabled).toBe(false);
777+});
778+779+it("uses the service clock when validating a finite-year cron update", async () => {
780+const { storePath } = await makeStorePath();
781+const now = Date.parse("2000-06-09T00:00:00.000Z");
782+const state = createOkIsolatedCronState({ storePath, now });
783+const job = await add(state, {
784+name: "future finite-year job",
785+enabled: true,
786+schedule: { kind: "cron", expr: "0 0 * * *" },
787+sessionTarget: "isolated",
788+wakeMode: "next-heartbeat",
789+payload: { kind: "agentTurn", message: "do work" },
790+});
791+if (state.timer) {
792+clearTimeout(state.timer);
793+}
794+795+const updated = await update(state, job.id, {
796+schedule: { kind: "cron", expr: "0 0 0 1 1 * 2001", tz: "UTC" },
797+});
798+if (state.timer) {
799+clearTimeout(state.timer);
800+}
801+802+expect(updated.state.nextRunAtMs).toBe(Date.parse("2001-01-01T00:00:00.000Z"));
803+});
804+805+it("accepts a finite-year cron while its final staggered run is pending", async () => {
806+const { storePath } = await makeStorePath();
807+const finalBaseRunAtMs = Date.parse("2001-01-01T00:00:00.000Z");
808+const state = createOkIsolatedCronState({ storePath, now: finalBaseRunAtMs + 1 });
809+810+const job = await add(state, {
811+name: "final staggered run",
812+enabled: true,
813+schedule: {
814+kind: "cron",
815+expr: "0 0 0 1 1 * 2001",
816+tz: "UTC",
817+staggerMs: 3_600_000,
818+},
819+sessionTarget: "isolated",
820+wakeMode: "next-heartbeat",
821+payload: { kind: "agentTurn", message: "do work" },
822+});
823+if (state.timer) {
824+clearTimeout(state.timer);
825+}
826+827+expect(job.state.nextRunAtMs).toBeGreaterThan(finalBaseRunAtMs);
828+});
645829});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。