mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:40:42 +00:00
fix(heartbeat): make phase scheduling active-hours-aware (#75487)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user