From cf4ffff3e1cc210f65e94b4dfa68fcd0d7055fb6 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Feb 2026 19:32:18 -0500 Subject: [PATCH] fix(heartbeat): run when HEARTBEAT.md is missing --- docs/automation/troubleshooting.md | 1 - ...tbeat-runner.returns-default-unset.test.ts | 79 ++++++++++++++++--- src/infra/heartbeat-runner.ts | 63 +++++++-------- 3 files changed, 100 insertions(+), 43 deletions(-) diff --git a/docs/automation/troubleshooting.md b/docs/automation/troubleshooting.md index a189d805221..9190855dd59 100644 --- a/docs/automation/troubleshooting.md +++ b/docs/automation/troubleshooting.md @@ -90,7 +90,6 @@ Common signatures: - `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`. - `requests-in-flight` → main lane busy; heartbeat deferred. - `empty-heartbeat-file` → interval heartbeat skipped because `HEARTBEAT.md` has no actionable content and no tagged cron event is queued. -- `no-heartbeat-file` → interval heartbeat skipped because `HEARTBEAT.md` is missing and no tagged cron event is queued. - `alerts-disabled` → visibility settings suppress outbound heartbeat messages. ## Timezone and activeHours gotchas diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index c9c302141ae..7542678b904 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -1372,7 +1372,7 @@ describe("runHeartbeatOnce", () => { } }); - it("skips heartbeat when HEARTBEAT.md does not exist (saves API calls)", async () => { + it("runs heartbeat when HEARTBEAT.md does not exist", async () => { const tmpDir = await createCaseDir("openclaw-hb"); const storePath = path.join(tmpDir, "sessions.json"); const workspaceDir = path.join(tmpDir, "workspace"); @@ -1409,7 +1409,7 @@ describe("runHeartbeatOnce", () => { ), ); - replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); + replySpy.mockResolvedValue({ text: "Checked logs and PRs" }); const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid", @@ -1426,13 +1426,74 @@ describe("runHeartbeatOnce", () => { }, }); - // Should skip - no HEARTBEAT.md means nothing actionable - expect(res.status).toBe("skipped"); - if (res.status === "skipped") { - expect(res.reason).toBe("no-heartbeat-file"); - } - expect(replySpy).not.toHaveBeenCalled(); - expect(sendWhatsApp).not.toHaveBeenCalled(); + // Missing HEARTBEAT.md should still run so prompt/system instructions can drive work. + expect(res.status).toBe("ran"); + expect(replySpy).toHaveBeenCalled(); + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + } finally { + replySpy.mockRestore(); + } + }); + + it("runs heartbeat when HEARTBEAT.md read fails with a non-ENOENT error", async () => { + const tmpDir = await createCaseDir("openclaw-hb"); + const storePath = path.join(tmpDir, "sessions.json"); + const workspaceDir = path.join(tmpDir, "workspace"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + await fs.mkdir(workspaceDir, { recursive: true }); + // Simulate a read failure path (readFile on a directory returns EISDIR). + await fs.mkdir(path.join(workspaceDir, "HEARTBEAT.md"), { recursive: true }); + + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: workspaceDir, + heartbeat: { every: "5m", target: "whatsapp" }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + + await fs.writeFile( + storePath, + JSON.stringify( + { + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + replySpy.mockResolvedValue({ text: "Checked logs and PRs" }); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + const res = await runHeartbeatOnce({ + cfg, + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + // Read errors other than ENOENT should not disable heartbeat runs. + expect(res.status).toBe("ran"); + expect(replySpy).toHaveBeenCalled(); + expect(sendWhatsApp).toHaveBeenCalledTimes(1); } finally { replySpy.mockRestore(); } diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 70ee5e34e10..a34ccfdb7e3 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -41,7 +41,7 @@ import { CommandLane } from "../process/lanes.js"; import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { escapeRegExp } from "../utils.js"; -import { formatErrorMessage } from "./errors.js"; +import { formatErrorMessage, hasErrnoCode } from "./errors.js"; import { isWithinActiveHours } from "./heartbeat-active-hours.js"; import { buildCronEventPrompt, @@ -481,7 +481,7 @@ type HeartbeatReasonFlags = { isWakeReason: boolean; }; -type HeartbeatSkipReason = "empty-heartbeat-file" | "no-heartbeat-file"; +type HeartbeatSkipReason = "empty-heartbeat-file"; type HeartbeatPreflight = HeartbeatReasonFlags & { session: ReturnType; @@ -525,42 +525,39 @@ async function resolveHeartbeatPreflight(params: { reasonFlags.isCronEventReason || reasonFlags.isWakeReason || hasTaggedCronEvents; - - const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId); - const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME); - try { - const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8"); - if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !shouldBypassFileGates) { - return { - ...reasonFlags, - session, - pendingEventEntries, - hasTaggedCronEvents, - shouldInspectPendingEvents, - skipReason: "empty-heartbeat-file", - }; - } - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException)?.code === "ENOENT" && !shouldBypassFileGates) { - return { - ...reasonFlags, - session, - pendingEventEntries, - hasTaggedCronEvents, - shouldInspectPendingEvents, - skipReason: "no-heartbeat-file", - }; - } - // For other read errors, proceed with heartbeat as before. - } - - return { + const basePreflight = { ...reasonFlags, session, pendingEventEntries, hasTaggedCronEvents, shouldInspectPendingEvents, - }; + } satisfies Omit; + + if (shouldBypassFileGates) { + return basePreflight; + } + + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId); + const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME); + try { + const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8"); + if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) { + return { + ...basePreflight, + skipReason: "empty-heartbeat-file", + }; + } + } catch (err: unknown) { + if (hasErrnoCode(err, "ENOENT")) { + // Missing HEARTBEAT.md is intentional in some setups (for example, when + // heartbeat instructions live outside the file), so keep the run active. + // The heartbeat prompt already says "if it exists". + return basePreflight; + } + // For other read errors, proceed with heartbeat as before. + } + + return basePreflight; } export async function runHeartbeatOnce(opts: {