From ee873e1b315f4766e482a3cbf38e99dae2b569e0 Mon Sep 17 00:00:00 2001 From: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 19:40:05 +0000 Subject: [PATCH] fix(heartbeat): make phase scheduling active-hours-aware (#75487) --- src/infra/heartbeat-active-hours.ts | 2 +- ...t-runner.active-hours-schedule.e2e.test.ts | 43 +++++++++++++++++++ src/infra/heartbeat-runner.ts | 41 ++++++++++++++---- 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/infra/heartbeat-active-hours.ts b/src/infra/heartbeat-active-hours.ts index aedf04dd61c..9215ce30675 100644 --- a/src/infra/heartbeat-active-hours.ts +++ b/src/infra/heartbeat-active-hours.ts @@ -6,7 +6,7 @@ type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; const ACTIVE_HOURS_TIME_PATTERN = /^(?:([01]\d|2[0-3]):([0-5]\d)|24:00)$/; -function resolveActiveHoursTimezone(cfg: OpenClawConfig, raw?: string): string { +export function resolveActiveHoursTimezone(cfg: OpenClawConfig, raw?: string): string { const trimmed = raw?.trim(); if (!trimmed || trimmed === "user") { return resolveUserTimezone(cfg.agents?.defaults?.userTimezone); 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 4f90a07d68f..9ca2d01786e 100644 --- a/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts +++ b/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts @@ -245,4 +245,47 @@ describe("heartbeat scheduler: activeHours-aware scheduling (#75487)", () => { runner.stop(); }); + + it("recomputes schedule when activeHours effective timezone changes via hot reload", async () => { + const startMs = Date.parse("2026-06-15T14:00:00.000Z"); + useFakeHeartbeatTime(startMs); + + const callTimes: number[] = []; + const runSpy: RunOnce = vi.fn().mockImplementation(async () => { + callTimes.push(Date.now()); + return { status: "ran", durationMs: 1 }; + }); + + const activeHours = { start: "16:00", end: "17:00" }; + const runner = startHeartbeatRunner({ + cfg: heartbeatConfig({ + every: "4h", + activeHours, + userTimezone: "America/New_York", + }), + runOnce: runSpy, + stableSchedulerSeed: TEST_SCHEDULER_SEED, + }); + + await vi.advanceTimersByTimeAsync(60 * 60_000); + expect(runSpy).not.toHaveBeenCalled(); + + runner.updateConfig( + heartbeatConfig({ + every: "4h", + activeHours, + userTimezone: "UTC", + }), + ); + + const endOfUtcWindow = Date.parse("2026-06-15T17:00:00.000Z"); + await vi.advanceTimersByTimeAsync(endOfUtcWindow - Date.now()); + + expect(runSpy).toHaveBeenCalled(); + const firstCall = new Date(callTimes[0]!); + expect(firstCall.getUTCHours()).toBe(16); + expect(firstCall.getUTCDate()).toBe(15); + + runner.stop(); + }); }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 5b3708b83a4..9699cc9de9e 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -83,7 +83,7 @@ import { escapeRegExp } from "../utils.js"; import { MAX_SAFE_TIMEOUT_DELAY_MS, resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js"; import { loadOrCreateDeviceIdentity } from "./device-identity.js"; import { formatErrorMessage, hasErrnoCode } from "./errors.js"; -import { isWithinActiveHours } from "./heartbeat-active-hours.js"; +import { isWithinActiveHours, resolveActiveHoursTimezone } from "./heartbeat-active-hours.js"; import { recordRunStart, shouldDeferWake, type DeferDecision } from "./heartbeat-cooldown.js"; import { buildCronEventPrompt, @@ -213,6 +213,7 @@ function canHeartbeatDeliverCommitments(heartbeat?: HeartbeatConfig): boolean { type HeartbeatAgentState = { agentId: string; heartbeat?: HeartbeatConfig; + activeHoursSchedule?: ActiveHoursSchedule; intervalMs: number; phaseMs: number; nextDueMs: number; @@ -224,11 +225,34 @@ type HeartbeatAgentState = { floodLoggedSinceLastRun: boolean; }; -type ActiveHoursWindow = NonNullable["activeHours"]; +type ActiveHoursSchedule = { + start?: string; + end?: string; + timezone: string; +}; -function activeHoursConfigMatch(a?: ActiveHoursWindow, b?: ActiveHoursWindow): boolean { - if (a === b) return true; - if (!a || !b) return false; +function resolveActiveHoursSchedule( + cfg: OpenClawConfig, + heartbeat?: HeartbeatConfig, +): ActiveHoursSchedule | undefined { + const activeHours = heartbeat?.activeHours; + if (!activeHours) { + return undefined; + } + return { + start: activeHours.start, + end: activeHours.end, + timezone: resolveActiveHoursTimezone(cfg, activeHours.timezone), + }; +} + +function activeHoursConfigMatch(a?: ActiveHoursSchedule, b?: ActiveHoursSchedule): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } return a.start === b.start && a.end === b.end && a.timezone === b.timezone; } @@ -1912,11 +1936,11 @@ export function startHeartbeatRunner(opts: { }); intervals.push(intervalMs); const prevState = prevAgents.get(agent.agentId); + const activeHoursSchedule = resolveActiveHoursSchedule(cfg, agent.heartbeat); // resolveNextDue only compares intervalMs/phaseMs, so discard - // prevState when activeHours changed to avoid a stale far-future slot. + // prevState when the effective activeHours window changed to avoid a stale far-future slot. const ahChanged = - prevState && - !activeHoursConfigMatch(prevState.heartbeat?.activeHours, agent.heartbeat?.activeHours); + prevState && !activeHoursConfigMatch(prevState.activeHoursSchedule, activeHoursSchedule); const rawNextDueMs = resolveNextDue( now, intervalMs, @@ -1932,6 +1956,7 @@ export function startHeartbeatRunner(opts: { nextAgents.set(agent.agentId, { agentId: agent.agentId, heartbeat: agent.heartbeat, + activeHoursSchedule, intervalMs, phaseMs, nextDueMs,