fix(heartbeat): make phase scheduling active-hours-aware (#75487)

This commit is contained in:
clawsweeper
2026-05-02 19:40:05 +00:00
parent f11859e759
commit ee873e1b31
3 changed files with 77 additions and 9 deletions

View File

@@ -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);

View File

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

View File

@@ -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<HeartbeatConfig>["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,