fix(heartbeat): recompute schedule when activeHours config changes via hot reload

This commit is contained in:
Alex Knight
2026-05-01 20:53:38 +10:00
committed by clawsweeper
parent e66eb7042f
commit a794fe7975
2 changed files with 74 additions and 1 deletions

View File

@@ -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:0010: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:0020: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();
});
});

View File

@@ -224,6 +224,15 @@ type HeartbeatAgentState = {
floodLoggedSinceLastRun: boolean;
};
type ActiveHoursWindow = NonNullable<HeartbeatConfig>["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,