From f855d0be4ff267d5cac24988a6997d71874d411e Mon Sep 17 00:00:00 2001 From: vikpos Date: Thu, 19 Feb 2026 06:09:33 +0000 Subject: [PATCH] fix: skip heartbeat when HEARTBEAT.md does not exist (#20461) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: f6e5f8172a334e2455ace5e93037e31567247271 Co-authored-by: vikpos <24960005+vikpos@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/automation/troubleshooting.md | 3 +- src/infra/heartbeat-reason.test.ts | 52 ++++ src/infra/heartbeat-reason.ts | 54 +++++ .../heartbeat-runner.ghost-reminder.test.ts | 1 + .../heartbeat-runner.model-override.test.ts | 5 +- ...tbeat-runner.returns-default-unset.test.ts | 227 +++++++++++++++++- ...ner.sender-prefers-delivery-target.test.ts | 1 + src/infra/heartbeat-runner.test-utils.ts | 1 + src/infra/heartbeat-runner.ts | 143 ++++++++--- src/infra/heartbeat-wake.ts | 24 +- 11 files changed, 456 insertions(+), 56 deletions(-) create mode 100644 src/infra/heartbeat-reason.test.ts create mode 100644 src/infra/heartbeat-reason.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7f945fad..3bd130a45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. - Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. +- Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos. - Telegram/Agents: gate exec/bash tool-failure warnings behind verbose mode so default Telegram replies stay clean while verbose sessions still surface diagnostics. (#20560) Thanks @obviyus. - Gateway/Daemon: forward `TMPDIR` into installed service environments so macOS LaunchAgent gateway runs can open SQLite temp/journal files reliably instead of failing with `SQLITE_CANTOPEN`. (#20512) Thanks @Clawborn. - Agents/Billing: include the active model that produced a billing error in user-facing billing messages (for example, `OpenAI (gpt-5.3)`) across payload, failover, and lifecycle error paths, so users can identify exactly which key needs credits. (#20510) Thanks @echoVic. diff --git a/docs/automation/troubleshooting.md b/docs/automation/troubleshooting.md index 51f2aa209c..a189d80522 100644 --- a/docs/automation/troubleshooting.md +++ b/docs/automation/troubleshooting.md @@ -89,7 +89,8 @@ Common signatures: - `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`. - `requests-in-flight` → main lane busy; heartbeat deferred. -- `empty-heartbeat-file` → `HEARTBEAT.md` exists but has no actionable content. +- `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-reason.test.ts b/src/infra/heartbeat-reason.test.ts new file mode 100644 index 0000000000..6c2fdc68f9 --- /dev/null +++ b/src/infra/heartbeat-reason.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + isHeartbeatActionWakeReason, + isHeartbeatEventDrivenReason, + normalizeHeartbeatWakeReason, + resolveHeartbeatReasonKind, +} from "./heartbeat-reason.js"; + +describe("heartbeat-reason", () => { + it("normalizes wake reasons with trim + requested fallback", () => { + expect(normalizeHeartbeatWakeReason(" cron:job-1 ")).toBe("cron:job-1"); + expect(normalizeHeartbeatWakeReason(" ")).toBe("requested"); + expect(normalizeHeartbeatWakeReason(undefined)).toBe("requested"); + }); + + it("classifies known reason kinds", () => { + expect(resolveHeartbeatReasonKind("retry")).toBe("retry"); + expect(resolveHeartbeatReasonKind("interval")).toBe("interval"); + expect(resolveHeartbeatReasonKind("manual")).toBe("manual"); + expect(resolveHeartbeatReasonKind("exec-event")).toBe("exec-event"); + expect(resolveHeartbeatReasonKind("wake")).toBe("wake"); + expect(resolveHeartbeatReasonKind("cron:job-1")).toBe("cron"); + expect(resolveHeartbeatReasonKind("hook:wake")).toBe("hook"); + expect(resolveHeartbeatReasonKind(" hook:wake ")).toBe("hook"); + }); + + it("classifies unknown reasons as other", () => { + expect(resolveHeartbeatReasonKind("requested")).toBe("other"); + expect(resolveHeartbeatReasonKind("slow")).toBe("other"); + expect(resolveHeartbeatReasonKind("")).toBe("other"); + expect(resolveHeartbeatReasonKind(undefined)).toBe("other"); + }); + + it("matches event-driven behavior used by heartbeat preflight", () => { + expect(isHeartbeatEventDrivenReason("exec-event")).toBe(true); + expect(isHeartbeatEventDrivenReason("cron:job-1")).toBe(true); + expect(isHeartbeatEventDrivenReason("wake")).toBe(true); + expect(isHeartbeatEventDrivenReason("hook:gmail:sync")).toBe(true); + expect(isHeartbeatEventDrivenReason("interval")).toBe(false); + expect(isHeartbeatEventDrivenReason("manual")).toBe(false); + expect(isHeartbeatEventDrivenReason("other")).toBe(false); + }); + + it("matches action-priority wake behavior", () => { + expect(isHeartbeatActionWakeReason("manual")).toBe(true); + expect(isHeartbeatActionWakeReason("exec-event")).toBe(true); + expect(isHeartbeatActionWakeReason("hook:wake")).toBe(true); + expect(isHeartbeatActionWakeReason("interval")).toBe(false); + expect(isHeartbeatActionWakeReason("cron:job-1")).toBe(false); + expect(isHeartbeatActionWakeReason("retry")).toBe(false); + }); +}); diff --git a/src/infra/heartbeat-reason.ts b/src/infra/heartbeat-reason.ts new file mode 100644 index 0000000000..968b1e2406 --- /dev/null +++ b/src/infra/heartbeat-reason.ts @@ -0,0 +1,54 @@ +export type HeartbeatReasonKind = + | "retry" + | "interval" + | "manual" + | "exec-event" + | "wake" + | "cron" + | "hook" + | "other"; + +function trimReason(reason?: string): string { + return typeof reason === "string" ? reason.trim() : ""; +} + +export function normalizeHeartbeatWakeReason(reason?: string): string { + const trimmed = trimReason(reason); + return trimmed.length > 0 ? trimmed : "requested"; +} + +export function resolveHeartbeatReasonKind(reason?: string): HeartbeatReasonKind { + const trimmed = trimReason(reason); + if (trimmed === "retry") { + return "retry"; + } + if (trimmed === "interval") { + return "interval"; + } + if (trimmed === "manual") { + return "manual"; + } + if (trimmed === "exec-event") { + return "exec-event"; + } + if (trimmed === "wake") { + return "wake"; + } + if (trimmed.startsWith("cron:")) { + return "cron"; + } + if (trimmed.startsWith("hook:")) { + return "hook"; + } + return "other"; +} + +export function isHeartbeatEventDrivenReason(reason?: string): boolean { + const kind = resolveHeartbeatReasonKind(reason); + return kind === "exec-event" || kind === "cron" || kind === "wake" || kind === "hook"; +} + +export function isHeartbeatActionWakeReason(reason?: string): boolean { + const kind = resolveHeartbeatReasonKind(reason); + return kind === "manual" || kind === "exec-event" || kind === "hook"; +} diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index 28bf9a310a..e0e66dd310 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -182,6 +182,7 @@ describe("Ghost reminder bug (issue #13317)", () => { it("uses CRON_EVENT_PROMPT for tagged cron events on interval wake", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-interval-")); + await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8"); const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "155462274", diff --git a/src/infra/heartbeat-runner.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts index f3fe5ea6e6..fd5aa40fd2 100644 --- a/src/infra/heartbeat-runner.model-override.test.ts +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -51,6 +51,8 @@ async function withHeartbeatFixture( ); }; + await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8"); + try { return await run({ tmpDir, storePath, seedSession }); } finally { @@ -136,7 +138,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { }); it("passes per-agent heartbeat model override (merged with defaults)", async () => { - await withHeartbeatFixture(async ({ storePath, seedSession }) => { + await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -149,6 +151,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { { id: "main", default: true }, { id: "ops", + workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp", diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 299a738219..c9c302141a 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -25,6 +25,7 @@ import { resolveHeartbeatDeliveryTarget, resolveHeartbeatSenderContext, } from "./outbound/targets.js"; +import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js"; // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); @@ -35,9 +36,12 @@ let testRegistry: ReturnType | null = null; let fixtureRoot = ""; let fixtureCount = 0; -const createCaseDir = async (prefix: string) => { +const createCaseDir = async (prefix: string, { skipHeartbeatFile = false } = {}) => { const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); await fs.mkdir(dir, { recursive: true }); + if (!skipHeartbeatFile) { + await fs.writeFile(path.join(dir, "HEARTBEAT.md"), "- Check status\n", "utf-8"); + } return dir; }; @@ -101,6 +105,7 @@ beforeAll(async () => { }); beforeEach(() => { + resetSystemEventsForTest(); if (testRegistry) { setActivePluginRegistry(testRegistry); } @@ -542,6 +547,7 @@ describe("runHeartbeatOnce", () => { { id: "main", default: true }, { id: "ops", + workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" }, }, ], @@ -611,6 +617,7 @@ describe("runHeartbeatOnce", () => { { id: "main", default: true }, { id: agentId, + workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" }, }, ], @@ -1221,6 +1228,81 @@ describe("runHeartbeatOnce", () => { } }); + it("does not skip interval heartbeat when HEARTBEAT.md is empty but tagged cron events are queued", 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 }); + await fs.writeFile( + path.join(workspaceDir, "HEARTBEAT.md"), + "# HEARTBEAT.md\n\n## Tasks\n\n", + "utf-8", + ); + + 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, + ), + ); + + enqueueSystemEvent("Cron: QMD maintenance completed", { + sessionKey, + contextKey: "cron:qmd-maintenance", + }); + + replySpy.mockResolvedValue({ text: "Relay this cron update now" }); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + const res = await runHeartbeatOnce({ + cfg, + reason: "interval", + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + expect(res.status).toBe("ran"); + expect(replySpy).toHaveBeenCalledTimes(1); + const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string }; + expect(calledCtx.Provider).toBe("cron-event"); + expect(calledCtx.Body).toContain("scheduled reminder has been triggered"); + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + } finally { + replySpy.mockRestore(); + } + }); + it("runs heartbeat when HEARTBEAT.md has actionable content", async () => { const tmpDir = await createCaseDir("openclaw-hb"); const storePath = path.join(tmpDir, "sessions.json"); @@ -1290,7 +1372,7 @@ describe("runHeartbeatOnce", () => { } }); - it("runs heartbeat when HEARTBEAT.md does not exist (lets LLM decide)", async () => { + it("skips heartbeat when HEARTBEAT.md does not exist (saves API calls)", async () => { const tmpDir = await createCaseDir("openclaw-hb"); const storePath = path.join(tmpDir, "sessions.json"); const workspaceDir = path.join(tmpDir, "workspace"); @@ -1344,9 +1426,148 @@ describe("runHeartbeatOnce", () => { }, }); - // Should run (not skip) - let LLM decide since file doesn't exist + // 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(); + } finally { + replySpy.mockRestore(); + } + }); + + it("does not skip wake-triggered 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"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + await fs.mkdir(workspaceDir, { recursive: true }); + // Don't create HEARTBEAT.md + + 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: "wake event processed" }); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + const res = await runHeartbeatOnce({ + cfg, + reason: "wake", + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + // Wake events should still run even without HEARTBEAT.md expect(res.status).toBe("ran"); expect(replySpy).toHaveBeenCalled(); + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + } finally { + replySpy.mockRestore(); + } + }); + + it("does not skip interval heartbeat when tagged cron events are queued and HEARTBEAT.md is missing", 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 }); + // Don't create HEARTBEAT.md + + 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, + ), + ); + + enqueueSystemEvent("Cron: QMD maintenance completed", { + sessionKey, + contextKey: "cron:qmd-maintenance", + }); + + replySpy.mockResolvedValue({ text: "Relay this cron update now" }); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + const res = await runHeartbeatOnce({ + cfg, + reason: "interval", + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + expect(res.status).toBe("ran"); + expect(replySpy).toHaveBeenCalledTimes(1); + const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string }; + expect(calledCtx.Provider).toBe("cron-event"); + expect(calledCtx.Body).toContain("scheduled reminder has been triggered"); + expect(sendWhatsApp).toHaveBeenCalledTimes(1); } finally { replySpy.mockRestore(); } diff --git a/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts b/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts index b244ef669e..6c13476cd3 100644 --- a/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts +++ b/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts @@ -16,6 +16,7 @@ installHeartbeatRunnerTestRuntime({ includeSlack: true }); describe("runHeartbeatOnce", () => { it("uses the delivery target as sender when lastTo differs", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); + await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8"); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { diff --git a/src/infra/heartbeat-runner.test-utils.ts b/src/infra/heartbeat-runner.test-utils.ts index 8a187423e5..7e7ccdc211 100644 --- a/src/infra/heartbeat-runner.test-utils.ts +++ b/src/infra/heartbeat-runner.test-utils.ts @@ -45,6 +45,7 @@ export async function withTempHeartbeatSandbox( }, ): Promise { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), options?.prefix ?? "openclaw-hb-")); + await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8"); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); const previousEnv = new Map(); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index d8b0f5db92..70ee5e34e1 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -49,6 +49,7 @@ import { isExecCompletionEvent, } from "./heartbeat-events-filter.js"; import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js"; +import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js"; import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js"; import { type HeartbeatRunResult, @@ -474,6 +475,94 @@ function normalizeHeartbeatReply( return { shouldSkip: false, text: finalText, hasMedia }; } +type HeartbeatReasonFlags = { + isExecEventReason: boolean; + isCronEventReason: boolean; + isWakeReason: boolean; +}; + +type HeartbeatSkipReason = "empty-heartbeat-file" | "no-heartbeat-file"; + +type HeartbeatPreflight = HeartbeatReasonFlags & { + session: ReturnType; + pendingEventEntries: ReturnType; + hasTaggedCronEvents: boolean; + shouldInspectPendingEvents: boolean; + skipReason?: HeartbeatSkipReason; +}; + +function resolveHeartbeatReasonFlags(reason?: string): HeartbeatReasonFlags { + const reasonKind = resolveHeartbeatReasonKind(reason); + return { + isExecEventReason: reasonKind === "exec-event", + isCronEventReason: reasonKind === "cron", + isWakeReason: reasonKind === "wake" || reasonKind === "hook", + }; +} + +async function resolveHeartbeatPreflight(params: { + cfg: OpenClawConfig; + agentId: string; + heartbeat?: HeartbeatConfig; + forcedSessionKey?: string; + reason?: string; +}): Promise { + const reasonFlags = resolveHeartbeatReasonFlags(params.reason); + const session = resolveHeartbeatSession( + params.cfg, + params.agentId, + params.heartbeat, + params.forcedSessionKey, + ); + const pendingEventEntries = peekSystemEventEntries(session.sessionKey); + const hasTaggedCronEvents = pendingEventEntries.some((event) => + event.contextKey?.startsWith("cron:"), + ); + const shouldInspectPendingEvents = + reasonFlags.isExecEventReason || reasonFlags.isCronEventReason || hasTaggedCronEvents; + const shouldBypassFileGates = + reasonFlags.isExecEventReason || + 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 { + ...reasonFlags, + session, + pendingEventEntries, + hasTaggedCronEvents, + shouldInspectPendingEvents, + }; +} + export async function runHeartbeatOnce(opts: { cfg?: OpenClawConfig; agentId?: string; @@ -505,41 +594,24 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: "requests-in-flight" }; } - // Skip heartbeat if HEARTBEAT.md exists but has no actionable content. - // This saves API calls/costs when the file is effectively empty (only comments/headers). - // EXCEPTION: Don't skip for exec events, cron events, or explicit wake requests - - // they have pending system events to process regardless of HEARTBEAT.md content. - const isExecEventReason = opts.reason === "exec-event"; - const isCronEventReason = Boolean(opts.reason?.startsWith("cron:")); - const isWakeReason = opts.reason === "wake" || Boolean(opts.reason?.startsWith("hook:")); - const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); - const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME); - try { - const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8"); - if ( - isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && - !isExecEventReason && - !isCronEventReason && - !isWakeReason - ) { - emitHeartbeatEvent({ - status: "skipped", - reason: "empty-heartbeat-file", - durationMs: Date.now() - startedAt, - }); - return { status: "skipped", reason: "empty-heartbeat-file" }; - } - } catch { - // File doesn't exist or can't be read - proceed with heartbeat. - // The LLM prompt says "if it exists" so this is expected behavior. - } - - const { entry, sessionKey, storePath } = resolveHeartbeatSession( + // Preflight centralizes trigger classification, event inspection, and HEARTBEAT.md gating. + const preflight = await resolveHeartbeatPreflight({ cfg, agentId, heartbeat, - opts.sessionKey, - ); + forcedSessionKey: opts.sessionKey, + reason: opts.reason, + }); + if (preflight.skipReason) { + emitHeartbeatEvent({ + status: "skipped", + reason: preflight.skipReason, + durationMs: Date.now() - startedAt, + }); + return { status: "skipped", reason: preflight.skipReason }; + } + const { entry, sessionKey, storePath } = preflight.session; + const { isCronEventReason, pendingEventEntries } = preflight; const previousUpdatedAt = entry?.updatedAt; const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); const heartbeatAccountId = heartbeat?.accountId?.trim(); @@ -572,12 +644,7 @@ export async function runHeartbeatOnce(opts: { // Check if this is an exec event or cron event with pending system events. // If so, use a specialized prompt that instructs the model to relay the result // instead of the standard heartbeat prompt with "reply HEARTBEAT_OK". - const isExecEvent = opts.reason === "exec-event"; - const pendingEventEntries = peekSystemEventEntries(sessionKey); - const hasTaggedCronEvents = pendingEventEntries.some((event) => - event.contextKey?.startsWith("cron:"), - ); - const shouldInspectPendingEvents = isExecEvent || isCronEventReason || hasTaggedCronEvents; + const shouldInspectPendingEvents = preflight.shouldInspectPendingEvents; const pendingEvents = shouldInspectPendingEvents ? pendingEventEntries.map((event) => event.text) : []; diff --git a/src/infra/heartbeat-wake.ts b/src/infra/heartbeat-wake.ts index d1dcfb0395..bccfdfe982 100644 --- a/src/infra/heartbeat-wake.ts +++ b/src/infra/heartbeat-wake.ts @@ -1,3 +1,9 @@ +import { + isHeartbeatActionWakeReason, + normalizeHeartbeatWakeReason, + resolveHeartbeatReasonKind, +} from "./heartbeat-reason.js"; + export type HeartbeatRunResult = | { status: "ran"; durationMs: number } | { status: "skipped"; reason: string } @@ -29,7 +35,6 @@ let timerKind: WakeTimerKind | null = null; const DEFAULT_COALESCE_MS = 250; const DEFAULT_RETRY_MS = 1_000; -const HOOK_REASON_PREFIX = "hook:"; const REASON_PRIORITY = { RETRY: 0, INTERVAL: 1, @@ -37,29 +42,22 @@ const REASON_PRIORITY = { ACTION: 3, } as const; -function isActionWakeReason(reason: string): boolean { - return reason === "manual" || reason === "exec-event" || reason.startsWith(HOOK_REASON_PREFIX); -} - function resolveReasonPriority(reason: string): number { - if (reason === "retry") { + const kind = resolveHeartbeatReasonKind(reason); + if (kind === "retry") { return REASON_PRIORITY.RETRY; } - if (reason === "interval") { + if (kind === "interval") { return REASON_PRIORITY.INTERVAL; } - if (isActionWakeReason(reason)) { + if (isHeartbeatActionWakeReason(reason)) { return REASON_PRIORITY.ACTION; } return REASON_PRIORITY.DEFAULT; } function normalizeWakeReason(reason?: string): string { - if (typeof reason !== "string") { - return "requested"; - } - const trimmed = reason.trim(); - return trimmed.length > 0 ? trimmed : "requested"; + return normalizeHeartbeatWakeReason(reason); } function normalizeWakeTarget(value?: string): string | undefined {