





























@@ -3,6 +3,7 @@ import {
33applyJobPatch,
44createJob,
55recomputeNextRuns,
6+recomputeNextRunsForMaintenance,
67resolveJobPayloadTextForMain,
78} from "./service/jobs.js";
89import type { CronServiceState } from "./service/state.js";
@@ -732,4 +733,216 @@ describe("recomputeNextRuns", () => {
732733}
733734expect(job.state.nextRunAtMs).toBe(now);
734735});
736+737+it("repairs future cron nextRunAtMs values that are not schedule slots", () => {
738+const now = Date.parse("2026-05-05T12:00:00.000Z");
739+const badFuture = Date.parse("2026-05-12T16:00:00.000Z");
740+const expected = Date.parse("2026-05-05T13:00:00.000Z");
741+const job: CronJob = {
742+id: "daily-21-shanghai",
743+name: "daily 21 shanghai",
744+enabled: true,
745+createdAtMs: Date.parse("2026-05-05T00:00:00.000Z"),
746+updatedAtMs: Date.parse("2026-05-05T00:00:00.000Z"),
747+schedule: { kind: "cron", expr: "0 0 21 * * *", tz: "Asia/Shanghai", staggerMs: 0 },
748+sessionTarget: "main",
749+wakeMode: "now",
750+payload: { kind: "systemEvent", text: "tick" },
751+state: { nextRunAtMs: badFuture },
752+};
753+const state = {
754+ ...createMockState(now),
755+store: { version: 1 as const, jobs: [job] },
756+} as CronServiceState;
757+758+expect(recomputeNextRunsForMaintenance(state)).toBe(true);
759+expect(job.state.nextRunAtMs).toBe(expected);
760+});
761+762+it("preserves valid future cron nextRunAtMs values during maintenance", () => {
763+const now = Date.parse("2026-05-05T12:00:00.000Z");
764+const validFuture = Date.parse("2026-05-05T13:00:00.000Z");
765+const job: CronJob = {
766+id: "daily-valid-future",
767+name: "daily valid future",
768+enabled: true,
769+createdAtMs: Date.parse("2026-05-05T00:00:00.000Z"),
770+updatedAtMs: Date.parse("2026-05-05T00:00:00.000Z"),
771+schedule: { kind: "cron", expr: "0 0 21 * * *", tz: "Asia/Shanghai", staggerMs: 0 },
772+sessionTarget: "main",
773+wakeMode: "now",
774+payload: { kind: "systemEvent", text: "tick" },
775+state: { nextRunAtMs: validFuture },
776+};
777+const state = {
778+ ...createMockState(now),
779+store: { version: 1 as const, jobs: [job] },
780+} as CronServiceState;
781+782+expect(recomputeNextRunsForMaintenance(state)).toBe(false);
783+expect(job.state.nextRunAtMs).toBe(validFuture);
784+});
785+786+it("repairs future cron nextRunAtMs values that would fire before the next schedule slot", () => {
787+const now = Date.parse("2026-05-05T12:00:00.000Z");
788+const tooEarly = Date.parse("2026-05-05T12:30:00.000Z");
789+const expected = Date.parse("2026-05-05T13:00:00.000Z");
790+const job: CronJob = {
791+id: "daily-too-early",
792+name: "daily too early",
793+enabled: true,
794+createdAtMs: Date.parse("2026-05-05T00:00:00.000Z"),
795+updatedAtMs: Date.parse("2026-05-05T00:00:00.000Z"),
796+schedule: { kind: "cron", expr: "0 0 21 * * *", tz: "Asia/Shanghai", staggerMs: 0 },
797+sessionTarget: "main",
798+wakeMode: "now",
799+payload: { kind: "systemEvent", text: "tick" },
800+state: { nextRunAtMs: tooEarly },
801+};
802+const state = {
803+ ...createMockState(now),
804+store: { version: 1 as const, jobs: [job] },
805+} as CronServiceState;
806+807+expect(recomputeNextRunsForMaintenance(state)).toBe(true);
808+expect(job.state.nextRunAtMs).toBe(expected);
809+});
810+811+it("preserves deferred agent-turn cron nextRunAtMs values before the next natural slot", () => {
812+const now = Date.parse("2026-05-05T12:00:00.000Z");
813+const deferred = Date.parse("2026-05-05T12:02:00.000Z");
814+const job: CronJob = {
815+id: "daily-deferred-agent-turn",
816+name: "daily deferred agent turn",
817+enabled: true,
818+createdAtMs: Date.parse("2026-05-05T00:00:00.000Z"),
819+updatedAtMs: Date.parse("2026-05-05T00:00:00.000Z"),
820+schedule: { kind: "cron", expr: "0 0 21 * * *", tz: "Asia/Shanghai", staggerMs: 0 },
821+sessionTarget: "isolated",
822+wakeMode: "now",
823+payload: { kind: "agentTurn", message: "tick" },
824+state: { nextRunAtMs: deferred },
825+};
826+const state = {
827+ ...createMockState(now),
828+store: { version: 1 as const, jobs: [job] },
829+} as CronServiceState;
830+831+expect(recomputeNextRunsForMaintenance(state)).toBe(false);
832+expect(job.state.nextRunAtMs).toBe(deferred);
833+});
834+835+it("preserves cron retry backoff nextRunAtMs values during maintenance", () => {
836+const now = Date.parse("2025-12-13T04:02:00.000Z");
837+const retryAt = Date.parse("2025-12-13T04:10:00.000Z");
838+const job: CronJob = {
839+id: "backoff-pending",
840+name: "backoff pending",
841+enabled: true,
842+createdAtMs: Date.parse("2025-12-10T12:00:00.000Z"),
843+updatedAtMs: Date.parse("2025-12-13T04:01:10.000Z"),
844+schedule: { kind: "cron", expr: "* * * * *", tz: "UTC" },
845+sessionTarget: "main",
846+wakeMode: "next-heartbeat",
847+payload: { kind: "systemEvent", text: "do not run during backoff" },
848+state: {
849+nextRunAtMs: retryAt,
850+lastRunAtMs: Date.parse("2025-12-13T04:01:00.000Z"),
851+lastStatus: "error",
852+consecutiveErrors: 4,
853+},
854+};
855+const state = {
856+ ...createMockState(now),
857+store: { version: 1 as const, jobs: [job] },
858+} as CronServiceState;
859+860+expect(recomputeNextRunsForMaintenance(state)).toBe(false);
861+expect(job.state.nextRunAtMs).toBe(retryAt);
862+});
863+864+it("preserves cron retry backoff nextRunAtMs values from the run end time", () => {
865+const now = Date.parse("2025-12-13T04:10:00.000Z");
866+const retryAt = Date.parse("2025-12-13T04:20:30.000Z");
867+const job: CronJob = {
868+id: "backoff-from-ended-at",
869+name: "backoff from ended at",
870+enabled: true,
871+createdAtMs: Date.parse("2025-12-10T12:00:00.000Z"),
872+updatedAtMs: Date.parse("2025-12-13T04:05:30.000Z"),
873+schedule: { kind: "cron", expr: "* * * * *", tz: "UTC" },
874+sessionTarget: "main",
875+wakeMode: "next-heartbeat",
876+payload: { kind: "systemEvent", text: "preserve run-end retry backoff" },
877+state: {
878+nextRunAtMs: retryAt,
879+lastRunAtMs: Date.parse("2025-12-13T04:01:30.000Z"),
880+lastDurationMs: 4 * 60_000,
881+lastStatus: "error",
882+consecutiveErrors: 4,
883+},
884+};
885+const state = {
886+ ...createMockState(now),
887+store: { version: 1 as const, jobs: [job] },
888+} as CronServiceState;
889+890+expect(recomputeNextRunsForMaintenance(state)).toBe(false);
891+expect(job.state.nextRunAtMs).toBe(retryAt);
892+});
893+894+it("repairs stale future cron nextRunAtMs values after error backoff has elapsed", () => {
895+const now = Date.parse("2026-05-05T12:00:00.000Z");
896+const badFuture = Date.parse("2026-05-12T16:00:00.000Z");
897+const expected = Date.parse("2026-05-05T13:00:00.000Z");
898+const job: CronJob = {
899+id: "daily-expired-error",
900+name: "daily expired error",
901+enabled: true,
902+createdAtMs: Date.parse("2026-05-05T00:00:00.000Z"),
903+updatedAtMs: Date.parse("2026-05-05T00:00:00.000Z"),
904+schedule: { kind: "cron", expr: "0 0 21 * * *", tz: "Asia/Shanghai", staggerMs: 0 },
905+sessionTarget: "main",
906+wakeMode: "now",
907+payload: { kind: "systemEvent", text: "tick" },
908+state: {
909+nextRunAtMs: badFuture,
910+lastRunAtMs: Date.parse("2026-05-04T00:00:00.000Z"),
911+lastStatus: "error",
912+consecutiveErrors: 1,
913+},
914+};
915+const state = {
916+ ...createMockState(now),
917+store: { version: 1 as const, jobs: [job] },
918+} as CronServiceState;
919+920+expect(recomputeNextRunsForMaintenance(state)).toBe(true);
921+expect(job.state.nextRunAtMs).toBe(expected);
922+});
923+924+it("does not throw while probing malformed cron schedules with future nextRunAtMs", () => {
925+const now = Date.parse("2026-05-05T12:00:00.000Z");
926+const future = Date.parse("2026-05-12T16:00:00.000Z");
927+const job: CronJob = {
928+id: "malformed-future",
929+name: "malformed future",
930+enabled: true,
931+createdAtMs: Date.parse("2026-05-05T00:00:00.000Z"),
932+updatedAtMs: Date.parse("2026-05-05T00:00:00.000Z"),
933+schedule: { kind: "cron", expr: "not a valid cron", tz: "UTC" },
934+sessionTarget: "main",
935+wakeMode: "now",
936+payload: { kind: "systemEvent", text: "tick" },
937+state: { nextRunAtMs: future },
938+};
939+const state = {
940+ ...createMockState(now),
941+store: { version: 1 as const, jobs: [job] },
942+} as CronServiceState;
943+944+expect(() => recomputeNextRunsForMaintenance(state)).not.toThrow();
945+expect(job.state.nextRunAtMs).toBe(future);
946+expect(job.state.scheduleErrorCount).toBeUndefined();
947+});
735948});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。