From ae4907ce6e3d7163d5611107d6dbbef17d9923f3 Mon Sep 17 00:00:00 2001 From: adhitShet <131381638+adhitShet@users.noreply.github.com> Date: Fri, 20 Feb 2026 05:03:57 +0400 Subject: [PATCH] fix(heartbeat): return false for zero-width active-hours window (#21408) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 993860bd0393fe9f48022f36c950c069863b4a61 Co-authored-by: adhitShet <131381638+adhitShet@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/gateway/heartbeat.md | 13 ++++++++++++- src/infra/heartbeat-active-hours.test.ts | 4 ++-- src/infra/heartbeat-active-hours.ts | 2 +- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00cdbfab5fb..2337b4434d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Heartbeat/Cron: restore interval heartbeat behavior so missing `HEARTBEAT.md` no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths. - Heartbeat/Active hours: constrain active-hours `24` sentinel parsing to `24:00` in time validation so invalid values like `24:30` are rejected early. (#21410) thanks @adhitShet. +- Heartbeat: treat `activeHours` windows with identical `start`/`end` times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet. - Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant. - Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek. - Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 36550e35aec..b682da0f814 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -163,6 +163,16 @@ Restrict heartbeats to business hours in a specific timezone: Outside this window (before 9am or after 10pm Eastern), heartbeats are skipped. The next scheduled tick inside the window will run normally. +### 24/7 setup + +If you want heartbeats to run all day, use one of these patterns: + +- Omit `activeHours` entirely (no time-window restriction; this is the default behavior). +- Set a full-day window: `activeHours: { start: "00:00", end: "24:00" }`. + +Do not set the same `start` and `end` time (for example `08:00` to `08:00`). +That is treated as a zero-width window, so heartbeats are always skipped. + ### Multi account example Use `accountId` to target a specific account on multi-account channels like Telegram: @@ -210,10 +220,11 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `prompt`: overrides the default prompt body (not merged). - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery. - `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. -- `activeHours`: restricts heartbeat runs to a time window. Object with `start` (HH:MM, inclusive), `end` (HH:MM exclusive; `24:00` allowed for end-of-day), and optional `timezone`. +- `activeHours`: restricts heartbeat runs to a time window. Object with `start` (HH:MM, inclusive; use `00:00` for start-of-day), `end` (HH:MM exclusive; `24:00` allowed for end-of-day), and optional `timezone`. - Omitted or `"user"`: uses your `agents.defaults.userTimezone` if set, otherwise falls back to the host system timezone. - `"local"`: always uses the host system timezone. - Any IANA identifier (e.g. `America/New_York`): used directly; if invalid, falls back to the `"user"` behavior above. + - `start` and `end` must not be equal for an active window; equal values are treated as zero-width (always outside the window). - Outside the active window, heartbeats are skipped until the next tick inside the window. ## Delivery behavior diff --git a/src/infra/heartbeat-active-hours.test.ts b/src/infra/heartbeat-active-hours.test.ts index e3bce7f5bd9..aac1cde605e 100644 --- a/src/infra/heartbeat-active-hours.test.ts +++ b/src/infra/heartbeat-active-hours.test.ts @@ -39,7 +39,7 @@ describe("isWithinActiveHours", () => { ).toBe(true); }); - it("returns true when activeHours start equals end", () => { + it("returns false when activeHours start equals end", () => { const cfg = cfgWithUserTimezone("UTC"); expect( isWithinActiveHours( @@ -47,7 +47,7 @@ describe("isWithinActiveHours", () => { heartbeatWindow("08:00", "08:00", "UTC"), Date.UTC(2025, 0, 1, 12, 0, 0), ), - ).toBe(true); + ).toBe(false); }); it("respects user timezone windows for normal ranges", () => { diff --git a/src/infra/heartbeat-active-hours.ts b/src/infra/heartbeat-active-hours.ts index 91341b69a8b..385b44646de 100644 --- a/src/infra/heartbeat-active-hours.ts +++ b/src/infra/heartbeat-active-hours.ts @@ -83,7 +83,7 @@ export function isWithinActiveHours( return true; } if (startMin === endMin) { - return true; + return false; } const timeZone = resolveActiveHoursTimezone(cfg, active.timezone);