From a794fe7975fe17319fd370bb6e96aed916ed19d1 Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Fri, 1 May 2026 20:53:38 +1000 Subject: [PATCH] fix(heartbeat): recompute schedule when activeHours config changes via hot reload --- ...t-runner.active-hours-schedule.e2e.test.ts | 51 +++++++++++++++++++ src/infra/heartbeat-runner.ts | 24 ++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts b/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts index 73591e09cdb..e6997e17525 100644 --- a/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts +++ b/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts @@ -243,4 +243,55 @@ describe("heartbeat scheduler: activeHours-aware scheduling (#75487)", () => { expect(runSpy).toHaveBeenCalled(); runner.stop(); }); + + it("recomputes schedule when activeHours config changes via hot reload", async () => { + // Start with a narrow window that pushes nextDueMs far ahead. + // Then widen the window via updateConfig — the scheduler should + // recompute from `now` instead of keeping the stale far-future slot. + const startMs = Date.parse("2026-06-15T14:00:00.000Z"); + useFakeHeartbeatTime(startMs); + + const intervalMs = 4 * 60 * 60_000; + const callTimes: number[] = []; + const runSpy: RunOnce = vi.fn().mockImplementation(async () => { + callTimes.push(Date.now()); + return { status: "ran", durationMs: 1 }; + }); + + // Narrow window: 09:00–10:00 UTC. At 14:00 UTC the next in-window + // slot is tomorrow ~09:xx (19+ hours away). + const runner = startHeartbeatRunner({ + cfg: heartbeatConfig({ + every: "4h", + activeHours: { start: "09:00", end: "10:00", timezone: "UTC" }, + }), + runOnce: runSpy, + stableSchedulerSeed: TEST_SCHEDULER_SEED, + }); + + // Advance 1 hour — should NOT fire (next slot is tomorrow). + await vi.advanceTimersByTimeAsync(60 * 60_000); + expect(runSpy).not.toHaveBeenCalled(); + + // Hot-reload: widen window to 08:00–20:00 UTC. + // At 15:00 UTC the next phase slot should now be reachable within hours. + runner.updateConfig( + heartbeatConfig({ + every: "4h", + activeHours: { start: "08:00", end: "20:00", timezone: "UTC" }, + }), + ); + + // Advance another 8 hours — should fire within the widened window. + await vi.advanceTimersByTimeAsync(8 * 60 * 60_000); + expect(runSpy).toHaveBeenCalled(); + const firstCallTime = callTimes[0]!; + const firstCallHour = new Date(firstCallTime).getUTCHours(); + expect(firstCallHour).toBeGreaterThanOrEqual(8); + expect(firstCallHour).toBeLessThan(20); + // Crucially, the first fire should be today (June 15), not tomorrow. + expect(new Date(firstCallTime).getUTCDate()).toBe(15); + + runner.stop(); + }); }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index d635c783755..ec7df6afe06 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -224,6 +224,15 @@ type HeartbeatAgentState = { floodLoggedSinceLastRun: boolean; }; +type ActiveHoursWindow = NonNullable["activeHours"]; + +/** Shallow equality for the three scheduling-relevant activeHours fields. */ +function activeHoursConfigMatch(a?: ActiveHoursWindow, b?: ActiveHoursWindow): boolean { + if (a === b) return true; + if (!a || !b) return false; + return a.start === b.start && a.end === b.end && a.timezone === b.timezone; +} + export type HeartbeatRunner = { stop: () => void; updateConfig: (cfg: OpenClawConfig) => void; @@ -1904,7 +1913,20 @@ export function startHeartbeatRunner(opts: { }); intervals.push(intervalMs); const prevState = prevAgents.get(agent.agentId); - const rawNextDueMs = resolveNextDue(now, intervalMs, phaseMs, prevState); + // When activeHours config changes, discard the preserved nextDueMs so + // the scheduler recomputes from `now` instead of keeping a stale slot + // that was pushed far ahead by the old window. resolveNextDue only + // compares intervalMs/phaseMs, so we null-out prevState when the + // scheduling-relevant active-hours fields differ. + const ahChanged = + prevState && + !activeHoursConfigMatch(prevState.heartbeat?.activeHours, agent.heartbeat?.activeHours); + const rawNextDueMs = resolveNextDue( + now, + intervalMs, + phaseMs, + ahChanged ? undefined : prevState, + ); const nextDueMs = seekNextActivePhaseDueMs({ startMs: rawNextDueMs, intervalMs,