




















@@ -0,0 +1,288 @@
1+import { afterEach, describe, expect, it, vi } from "vitest";
2+import type { OpenClawConfig } from "../config/config.js";
3+import { startHeartbeatRunner } from "./heartbeat-runner.js";
4+import { computeNextHeartbeatPhaseDueMs, resolveHeartbeatPhaseMs } from "./heartbeat-schedule.js";
5+import { resetHeartbeatWakeStateForTests } from "./heartbeat-wake.js";
6+7+/** Verifies that the scheduler seeks to in-window phase slots (#75487). */
8+describe("heartbeat scheduler: activeHours-aware scheduling (#75487)", () => {
9+type RunOnce = Parameters<typeof startHeartbeatRunner>[0]["runOnce"];
10+const TEST_SCHEDULER_SEED = "heartbeat-ah-schedule-test-seed";
11+12+function useFakeHeartbeatTime(startMs: number) {
13+vi.useFakeTimers();
14+vi.setSystemTime(new Date(startMs));
15+}
16+17+function heartbeatConfig(overrides?: {
18+every?: string;
19+activeHours?: { start: string; end: string; timezone?: string };
20+userTimezone?: string;
21+}): OpenClawConfig {
22+return {
23+agents: {
24+defaults: {
25+heartbeat: {
26+every: overrides?.every ?? "4h",
27+ ...(overrides?.activeHours ? { activeHours: overrides.activeHours } : {}),
28+},
29+ ...(overrides?.userTimezone ? { userTimezone: overrides.userTimezone } : {}),
30+},
31+},
32+};
33+}
34+35+function resolveDueFromNow(nowMs: number, intervalMs: number, agentId: string) {
36+return computeNextHeartbeatPhaseDueMs({
37+ nowMs,
38+ intervalMs,
39+phaseMs: resolveHeartbeatPhaseMs({
40+schedulerSeed: TEST_SCHEDULER_SEED,
41+ agentId,
42+ intervalMs,
43+}),
44+});
45+}
46+47+afterEach(() => {
48+resetHeartbeatWakeStateForTests();
49+vi.useRealTimers();
50+vi.restoreAllMocks();
51+});
52+53+it("skips quiet-hours slots and fires at the first in-window phase slot", async () => {
54+// 09:00–17:00 UTC, 4h interval. Start at 16:30 — raw due is after 17:00.
55+const startMs = Date.parse("2026-06-15T16:30:00.000Z");
56+useFakeHeartbeatTime(startMs);
57+58+const intervalMs = 4 * 60 * 60_000;
59+const callTimes: number[] = [];
60+const runSpy: RunOnce = vi.fn().mockImplementation(async () => {
61+callTimes.push(Date.now());
62+return { status: "ran", durationMs: 1 };
63+});
64+65+const runner = startHeartbeatRunner({
66+cfg: heartbeatConfig({
67+every: "4h",
68+activeHours: { start: "09:00", end: "17:00", timezone: "UTC" },
69+}),
70+runOnce: runSpy,
71+stableSchedulerSeed: TEST_SCHEDULER_SEED,
72+});
73+74+const rawDueMs = resolveDueFromNow(startMs, intervalMs, "main");
75+76+// Advance past the raw due — should NOT fire (quiet hours).
77+await vi.advanceTimersByTimeAsync(rawDueMs - startMs + 1);
78+79+// Advance to end of next day's window — should fire within 09:00–17:00.
80+const safeEndOfWindow = Date.parse("2026-06-16T17:00:00.000Z");
81+await vi.advanceTimersByTimeAsync(safeEndOfWindow - Date.now());
82+83+expect(runSpy).toHaveBeenCalled();
84+const firstCallHourUTC = new Date(callTimes[0]).getUTCHours();
85+expect(firstCallHourUTC).toBeGreaterThanOrEqual(9);
86+expect(firstCallHourUTC).toBeLessThan(17);
87+88+runner.stop();
89+});
90+91+it("fires immediately when the first phase slot is already within active hours", async () => {
92+const startMs = Date.parse("2026-06-15T10:00:00.000Z");
93+useFakeHeartbeatTime(startMs);
94+95+const intervalMs = 4 * 60 * 60_000;
96+const runSpy: RunOnce = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
97+98+const runner = startHeartbeatRunner({
99+cfg: heartbeatConfig({
100+every: "4h",
101+activeHours: { start: "08:00", end: "20:00", timezone: "UTC" },
102+}),
103+runOnce: runSpy,
104+stableSchedulerSeed: TEST_SCHEDULER_SEED,
105+});
106+107+const rawDueMs = resolveDueFromNow(startMs, intervalMs, "main");
108+await vi.advanceTimersByTimeAsync(rawDueMs - startMs + 1);
109+110+expect(runSpy).toHaveBeenCalledTimes(1);
111+runner.stop();
112+});
113+114+it("seeks forward correctly with a non-UTC timezone (e.g. America/New_York)", async () => {
115+// 09:00–17:00 ET (EDT = UTC-4 in June) → 13:00–21:00 UTC.
116+// Start at 21:30 UTC (17:30 ET = outside window).
117+const startMs = Date.parse("2026-06-15T21:30:00.000Z");
118+useFakeHeartbeatTime(startMs);
119+120+const callTimes: number[] = [];
121+const runSpy: RunOnce = vi.fn().mockImplementation(async () => {
122+callTimes.push(Date.now());
123+return { status: "ran", durationMs: 1 };
124+});
125+126+const runner = startHeartbeatRunner({
127+cfg: heartbeatConfig({
128+every: "4h",
129+activeHours: { start: "09:00", end: "17:00", timezone: "America/New_York" },
130+}),
131+runOnce: runSpy,
132+stableSchedulerSeed: TEST_SCHEDULER_SEED,
133+});
134+135+await vi.advanceTimersByTimeAsync(48 * 60 * 60_000);
136+137+expect(runSpy).toHaveBeenCalled();
138+const firstCallHourUTC = new Date(callTimes[0]).getUTCHours();
139+expect(firstCallHourUTC).toBeGreaterThanOrEqual(13);
140+expect(firstCallHourUTC).toBeLessThan(21);
141+142+runner.stop();
143+});
144+145+it("advances to in-window slot after a quiet-hours skip during interval runs", async () => {
146+// 09:00–17:00 UTC, 4h interval. Verify ALL fires over 48h stay in-window.
147+const startMs = Date.parse("2026-06-15T09:00:00.000Z");
148+useFakeHeartbeatTime(startMs);
149+150+const callTimes: number[] = [];
151+const runSpy: RunOnce = vi.fn().mockImplementation(async () => {
152+callTimes.push(Date.now());
153+return { status: "ran", durationMs: 1 };
154+});
155+156+const runner = startHeartbeatRunner({
157+cfg: heartbeatConfig({
158+every: "4h",
159+activeHours: { start: "09:00", end: "17:00", timezone: "UTC" },
160+}),
161+runOnce: runSpy,
162+stableSchedulerSeed: TEST_SCHEDULER_SEED,
163+});
164+165+await vi.advanceTimersByTimeAsync(48 * 60 * 60_000);
166+167+expect(callTimes.length).toBeGreaterThan(0);
168+for (const t of callTimes) {
169+const hour = new Date(t).getUTCHours();
170+expect(
171+hour,
172+`fire at ${new Date(t).toISOString()} is outside active window`,
173+).toBeGreaterThanOrEqual(9);
174+expect(hour, `fire at ${new Date(t).toISOString()} is outside active window`).toBeLessThan(
175+17,
176+);
177+}
178+179+runner.stop();
180+});
181+182+it("does not loop indefinitely when activeHours window is zero-width", async () => {
183+// start === end → never-active; seek falls back, runtime guard skips.
184+const startMs = Date.parse("2026-06-15T10:00:00.000Z");
185+useFakeHeartbeatTime(startMs);
186+187+const runSpy: RunOnce = vi.fn().mockResolvedValue({ status: "skipped", reason: "quiet-hours" });
188+189+const runner = startHeartbeatRunner({
190+cfg: heartbeatConfig({
191+every: "30m",
192+activeHours: { start: "12:00", end: "12:00", timezone: "UTC" },
193+}),
194+runOnce: runSpy,
195+stableSchedulerSeed: TEST_SCHEDULER_SEED,
196+});
197+198+await vi.advanceTimersByTimeAsync(2 * 60 * 60_000);
199+200+expect(runSpy).toHaveBeenCalled();
201+runner.stop();
202+});
203+204+it("recomputes schedule when activeHours config changes via hot reload", async () => {
205+// Narrow window pushes nextDueMs to tomorrow; widening via updateConfig
206+// must recompute from `now` so the timer fires today.
207+const startMs = Date.parse("2026-06-15T14:00:00.000Z");
208+useFakeHeartbeatTime(startMs);
209+210+const callTimes: number[] = [];
211+const runSpy: RunOnce = vi.fn().mockImplementation(async () => {
212+callTimes.push(Date.now());
213+return { status: "ran", durationMs: 1 };
214+});
215+216+const runner = startHeartbeatRunner({
217+cfg: heartbeatConfig({
218+every: "4h",
219+activeHours: { start: "09:00", end: "10:00", timezone: "UTC" },
220+}),
221+runOnce: runSpy,
222+stableSchedulerSeed: TEST_SCHEDULER_SEED,
223+});
224+225+await vi.advanceTimersByTimeAsync(60 * 60_000);
226+expect(runSpy).not.toHaveBeenCalled();
227+228+// Widen window — scheduler must recompute, not keep stale tomorrow slot.
229+runner.updateConfig(
230+heartbeatConfig({
231+every: "4h",
232+activeHours: { start: "08:00", end: "20:00", timezone: "UTC" },
233+}),
234+);
235+236+await vi.advanceTimersByTimeAsync(8 * 60 * 60_000);
237+expect(runSpy).toHaveBeenCalled();
238+const firstCallHour = new Date(callTimes[0]).getUTCHours();
239+expect(firstCallHour).toBeGreaterThanOrEqual(8);
240+expect(firstCallHour).toBeLessThan(20);
241+expect(new Date(callTimes[0]).getUTCDate()).toBe(15); // today, not tomorrow
242+243+runner.stop();
244+});
245+246+it("recomputes schedule when activeHours effective timezone changes via hot reload", async () => {
247+const startMs = Date.parse("2026-06-15T14:00:00.000Z");
248+useFakeHeartbeatTime(startMs);
249+250+const callTimes: number[] = [];
251+const runSpy: RunOnce = vi.fn().mockImplementation(async () => {
252+callTimes.push(Date.now());
253+return { status: "ran", durationMs: 1 };
254+});
255+256+const activeHours = { start: "16:00", end: "17:00" };
257+const runner = startHeartbeatRunner({
258+cfg: heartbeatConfig({
259+every: "4h",
260+ activeHours,
261+userTimezone: "America/New_York",
262+}),
263+runOnce: runSpy,
264+stableSchedulerSeed: TEST_SCHEDULER_SEED,
265+});
266+267+await vi.advanceTimersByTimeAsync(60 * 60_000);
268+expect(runSpy).not.toHaveBeenCalled();
269+270+runner.updateConfig(
271+heartbeatConfig({
272+every: "4h",
273+ activeHours,
274+userTimezone: "UTC",
275+}),
276+);
277+278+const endOfUtcWindow = Date.parse("2026-06-15T17:00:00.000Z");
279+await vi.advanceTimersByTimeAsync(endOfUtcWindow - Date.now());
280+281+expect(runSpy).toHaveBeenCalled();
282+const firstCall = new Date(callTimes[0]);
283+expect(firstCall.getUTCHours()).toBe(16);
284+expect(firstCall.getUTCDate()).toBe(15);
285+286+runner.stop();
287+});
288+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。