Skip to content

Commit 10448a0

Browse files
fix(heartbeat): make phase scheduling active-hours-aware (#75487) (#75597)
Summary: - The PR adds active-hours-aware heartbeat phase seeking, wires runner scheduling and config reloads through it, adds scheduler/e2e coverage, and records the user-facing fix in the changelog. - Reproducibility: yes. Current main can be reproduced with a `4h` heartbeat and an `Asia/Shanghai` active-hours window during a quiet-hours restart: main arms raw UTC-phase slots and only skips when the timer fires. ClawSweeper fixups: - Included follow-up commit: fix(heartbeat): recompute schedule when activeHours config changes vi… - Included follow-up commit: fix(heartbeat): add iteration cap to active-hours seek + edge-case tests - Included follow-up commit: chore: clean up redundant code comments - Included follow-up commit: fix(heartbeat): make phase scheduling active-hours-aware (#75487) - Included follow-up commit: fix(clawsweeper): address review for automerge-openclaw-openclaw-7559… Validation: - ClawSweeper review passed for head 02a1283. - Required merge gates passed before the squash merge. Prepared head SHA: 02a1283 Review: #75597 (comment) Co-authored-by: Alex Knight <aknight@atlassian.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent 0fad53a commit 10448a0

6 files changed

Lines changed: 556 additions & 4 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
1414

1515
- Agents/sessions: preserve terminal lifecycle state when final run metadata persists from a stale in-memory snapshot, preventing `main` sessions from staying stuck as running after completed or timed-out turns.
1616
- Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting.
17+
- Heartbeat/scheduler: make heartbeat phase scheduling active-hours-aware so the scheduler seeks forward to the first in-window phase slot instead of arming timers for quiet-hours slots and relying solely on the runtime guard. Non-UTC `activeHours.timezone` values (e.g. `Asia/Shanghai`) now correctly influence when the next heartbeat timer fires, avoiding wasted quiet-hours ticks and long dormant gaps after gateway restarts. Fixes #75487. Thanks @amknight.
1718
- Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky.
1819
- Plugins/externalization: keep diagnostics ClawHub packages and persisted bundled-plugin relocation on npm-first install metadata for launch, and omit Discord from the core package now that its external package is published. Thanks @vincentkoc.
1920
- Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof.

src/infra/heartbeat-active-hours.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ type HeartbeatConfig = AgentDefaultsConfig["heartbeat"];
66

77
const ACTIVE_HOURS_TIME_PATTERN = /^(?:([01]\d|2[0-3]):([0-5]\d)|24:00)$/;
88

9-
function resolveActiveHoursTimezone(cfg: OpenClawConfig, raw?: string): string {
9+
export function resolveActiveHoursTimezone(cfg: OpenClawConfig, raw?: string): string {
1010
const trimmed = raw?.trim();
1111
if (!trimmed || trimmed === "user") {
1212
return resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../config/config.js";
3+
import { startHeartbeatRunner } from "./heartbeat-runner.js";
4+
import { computeNextHeartbeatPhaseDueMs, resolveHeartbeatPhaseMs } from "./heartbeat-schedule.js";
5+
import { resetHeartbeatWakeStateForTests } from "./heartbeat-wake.js";
6+
7+
/** Verifies that the scheduler seeks to in-window phase slots (#75487). */
8+
describe("heartbeat scheduler: activeHours-aware scheduling (#75487)", () => {
9+
type RunOnce = Parameters<typeof startHeartbeatRunner>[0]["runOnce"];
10+
const TEST_SCHEDULER_SEED = "heartbeat-ah-schedule-test-seed";
11+
12+
function useFakeHeartbeatTime(startMs: number) {
13+
vi.useFakeTimers();
14+
vi.setSystemTime(new Date(startMs));
15+
}
16+
17+
function heartbeatConfig(overrides?: {
18+
every?: string;
19+
activeHours?: { start: string; end: string; timezone?: string };
20+
userTimezone?: string;
21+
}): OpenClawConfig {
22+
return {
23+
agents: {
24+
defaults: {
25+
heartbeat: {
26+
every: overrides?.every ?? "4h",
27+
...(overrides?.activeHours ? { activeHours: overrides.activeHours } : {}),
28+
},
29+
...(overrides?.userTimezone ? { userTimezone: overrides.userTimezone } : {}),
30+
},
31+
},
32+
};
33+
}
34+
35+
function resolveDueFromNow(nowMs: number, intervalMs: number, agentId: string) {
36+
return computeNextHeartbeatPhaseDueMs({
37+
nowMs,
38+
intervalMs,
39+
phaseMs: resolveHeartbeatPhaseMs({
40+
schedulerSeed: TEST_SCHEDULER_SEED,
41+
agentId,
42+
intervalMs,
43+
}),
44+
});
45+
}
46+
47+
afterEach(() => {
48+
resetHeartbeatWakeStateForTests();
49+
vi.useRealTimers();
50+
vi.restoreAllMocks();
51+
});
52+
53+
it("skips quiet-hours slots and fires at the first in-window phase slot", async () => {
54+
// 09:00–17:00 UTC, 4h interval. Start at 16:30 — raw due is after 17:00.
55+
const startMs = Date.parse("2026-06-15T16:30:00.000Z");
56+
useFakeHeartbeatTime(startMs);
57+
58+
const intervalMs = 4 * 60 * 60_000;
59+
const callTimes: number[] = [];
60+
const runSpy: RunOnce = vi.fn().mockImplementation(async () => {
61+
callTimes.push(Date.now());
62+
return { status: "ran", durationMs: 1 };
63+
});
64+
65+
const runner = startHeartbeatRunner({
66+
cfg: heartbeatConfig({
67+
every: "4h",
68+
activeHours: { start: "09:00", end: "17:00", timezone: "UTC" },
69+
}),
70+
runOnce: runSpy,
71+
stableSchedulerSeed: TEST_SCHEDULER_SEED,
72+
});
73+
74+
const rawDueMs = resolveDueFromNow(startMs, intervalMs, "main");
75+
76+
// Advance past the raw due — should NOT fire (quiet hours).
77+
await vi.advanceTimersByTimeAsync(rawDueMs - startMs + 1);
78+
79+
// Advance to end of next day's window — should fire within 09:00–17:00.
80+
const safeEndOfWindow = Date.parse("2026-06-16T17:00:00.000Z");
81+
await vi.advanceTimersByTimeAsync(safeEndOfWindow - Date.now());
82+
83+
expect(runSpy).toHaveBeenCalled();
84+
const firstCallHourUTC = new Date(callTimes[0]).getUTCHours();
85+
expect(firstCallHourUTC).toBeGreaterThanOrEqual(9);
86+
expect(firstCallHourUTC).toBeLessThan(17);
87+
88+
runner.stop();
89+
});
90+
91+
it("fires immediately when the first phase slot is already within active hours", async () => {
92+
const startMs = Date.parse("2026-06-15T10:00:00.000Z");
93+
useFakeHeartbeatTime(startMs);
94+
95+
const intervalMs = 4 * 60 * 60_000;
96+
const runSpy: RunOnce = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
97+
98+
const runner = startHeartbeatRunner({
99+
cfg: heartbeatConfig({
100+
every: "4h",
101+
activeHours: { start: "08:00", end: "20:00", timezone: "UTC" },
102+
}),
103+
runOnce: runSpy,
104+
stableSchedulerSeed: TEST_SCHEDULER_SEED,
105+
});
106+
107+
const rawDueMs = resolveDueFromNow(startMs, intervalMs, "main");
108+
await vi.advanceTimersByTimeAsync(rawDueMs - startMs + 1);
109+
110+
expect(runSpy).toHaveBeenCalledTimes(1);
111+
runner.stop();
112+
});
113+
114+
it("seeks forward correctly with a non-UTC timezone (e.g. America/New_York)", async () => {
115+
// 09:00–17:00 ET (EDT = UTC-4 in June) → 13:00–21:00 UTC.
116+
// Start at 21:30 UTC (17:30 ET = outside window).
117+
const startMs = Date.parse("2026-06-15T21:30:00.000Z");
118+
useFakeHeartbeatTime(startMs);
119+
120+
const callTimes: number[] = [];
121+
const runSpy: RunOnce = vi.fn().mockImplementation(async () => {
122+
callTimes.push(Date.now());
123+
return { status: "ran", durationMs: 1 };
124+
});
125+
126+
const runner = startHeartbeatRunner({
127+
cfg: heartbeatConfig({
128+
every: "4h",
129+
activeHours: { start: "09:00", end: "17:00", timezone: "America/New_York" },
130+
}),
131+
runOnce: runSpy,
132+
stableSchedulerSeed: TEST_SCHEDULER_SEED,
133+
});
134+
135+
await vi.advanceTimersByTimeAsync(48 * 60 * 60_000);
136+
137+
expect(runSpy).toHaveBeenCalled();
138+
const firstCallHourUTC = new Date(callTimes[0]).getUTCHours();
139+
expect(firstCallHourUTC).toBeGreaterThanOrEqual(13);
140+
expect(firstCallHourUTC).toBeLessThan(21);
141+
142+
runner.stop();
143+
});
144+
145+
it("advances to in-window slot after a quiet-hours skip during interval runs", async () => {
146+
// 09:00–17:00 UTC, 4h interval. Verify ALL fires over 48h stay in-window.
147+
const startMs = Date.parse("2026-06-15T09:00:00.000Z");
148+
useFakeHeartbeatTime(startMs);
149+
150+
const callTimes: number[] = [];
151+
const runSpy: RunOnce = vi.fn().mockImplementation(async () => {
152+
callTimes.push(Date.now());
153+
return { status: "ran", durationMs: 1 };
154+
});
155+
156+
const runner = startHeartbeatRunner({
157+
cfg: heartbeatConfig({
158+
every: "4h",
159+
activeHours: { start: "09:00", end: "17:00", timezone: "UTC" },
160+
}),
161+
runOnce: runSpy,
162+
stableSchedulerSeed: TEST_SCHEDULER_SEED,
163+
});
164+
165+
await vi.advanceTimersByTimeAsync(48 * 60 * 60_000);
166+
167+
expect(callTimes.length).toBeGreaterThan(0);
168+
for (const t of callTimes) {
169+
const hour = new Date(t).getUTCHours();
170+
expect(
171+
hour,
172+
`fire at ${new Date(t).toISOString()} is outside active window`,
173+
).toBeGreaterThanOrEqual(9);
174+
expect(hour, `fire at ${new Date(t).toISOString()} is outside active window`).toBeLessThan(
175+
17,
176+
);
177+
}
178+
179+
runner.stop();
180+
});
181+
182+
it("does not loop indefinitely when activeHours window is zero-width", async () => {
183+
// start === end → never-active; seek falls back, runtime guard skips.
184+
const startMs = Date.parse("2026-06-15T10:00:00.000Z");
185+
useFakeHeartbeatTime(startMs);
186+
187+
const runSpy: RunOnce = vi.fn().mockResolvedValue({ status: "skipped", reason: "quiet-hours" });
188+
189+
const runner = startHeartbeatRunner({
190+
cfg: heartbeatConfig({
191+
every: "30m",
192+
activeHours: { start: "12:00", end: "12:00", timezone: "UTC" },
193+
}),
194+
runOnce: runSpy,
195+
stableSchedulerSeed: TEST_SCHEDULER_SEED,
196+
});
197+
198+
await vi.advanceTimersByTimeAsync(2 * 60 * 60_000);
199+
200+
expect(runSpy).toHaveBeenCalled();
201+
runner.stop();
202+
});
203+
204+
it("recomputes schedule when activeHours config changes via hot reload", async () => {
205+
// Narrow window pushes nextDueMs to tomorrow; widening via updateConfig
206+
// must recompute from `now` so the timer fires today.
207+
const startMs = Date.parse("2026-06-15T14:00:00.000Z");
208+
useFakeHeartbeatTime(startMs);
209+
210+
const callTimes: number[] = [];
211+
const runSpy: RunOnce = vi.fn().mockImplementation(async () => {
212+
callTimes.push(Date.now());
213+
return { status: "ran", durationMs: 1 };
214+
});
215+
216+
const runner = startHeartbeatRunner({
217+
cfg: heartbeatConfig({
218+
every: "4h",
219+
activeHours: { start: "09:00", end: "10:00", timezone: "UTC" },
220+
}),
221+
runOnce: runSpy,
222+
stableSchedulerSeed: TEST_SCHEDULER_SEED,
223+
});
224+
225+
await vi.advanceTimersByTimeAsync(60 * 60_000);
226+
expect(runSpy).not.toHaveBeenCalled();
227+
228+
// Widen window — scheduler must recompute, not keep stale tomorrow slot.
229+
runner.updateConfig(
230+
heartbeatConfig({
231+
every: "4h",
232+
activeHours: { start: "08:00", end: "20:00", timezone: "UTC" },
233+
}),
234+
);
235+
236+
await vi.advanceTimersByTimeAsync(8 * 60 * 60_000);
237+
expect(runSpy).toHaveBeenCalled();
238+
const firstCallHour = new Date(callTimes[0]).getUTCHours();
239+
expect(firstCallHour).toBeGreaterThanOrEqual(8);
240+
expect(firstCallHour).toBeLessThan(20);
241+
expect(new Date(callTimes[0]).getUTCDate()).toBe(15); // today, not tomorrow
242+
243+
runner.stop();
244+
});
245+
246+
it("recomputes schedule when activeHours effective timezone changes via hot reload", async () => {
247+
const startMs = Date.parse("2026-06-15T14:00:00.000Z");
248+
useFakeHeartbeatTime(startMs);
249+
250+
const callTimes: number[] = [];
251+
const runSpy: RunOnce = vi.fn().mockImplementation(async () => {
252+
callTimes.push(Date.now());
253+
return { status: "ran", durationMs: 1 };
254+
});
255+
256+
const activeHours = { start: "16:00", end: "17:00" };
257+
const runner = startHeartbeatRunner({
258+
cfg: heartbeatConfig({
259+
every: "4h",
260+
activeHours,
261+
userTimezone: "America/New_York",
262+
}),
263+
runOnce: runSpy,
264+
stableSchedulerSeed: TEST_SCHEDULER_SEED,
265+
});
266+
267+
await vi.advanceTimersByTimeAsync(60 * 60_000);
268+
expect(runSpy).not.toHaveBeenCalled();
269+
270+
runner.updateConfig(
271+
heartbeatConfig({
272+
every: "4h",
273+
activeHours,
274+
userTimezone: "UTC",
275+
}),
276+
);
277+
278+
const endOfUtcWindow = Date.parse("2026-06-15T17:00:00.000Z");
279+
await vi.advanceTimersByTimeAsync(endOfUtcWindow - Date.now());
280+
281+
expect(runSpy).toHaveBeenCalled();
282+
const firstCall = new Date(callTimes[0]);
283+
expect(firstCall.getUTCHours()).toBe(16);
284+
expect(firstCall.getUTCDate()).toBe(15);
285+
286+
runner.stop();
287+
});
288+
});

0 commit comments

Comments
 (0)