diff --git a/CHANGELOG.md b/CHANGELOG.md index fcb9ef87270..a5f10e2914b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,6 @@ Docs: https://docs.openclaw.ai ### Fixes -- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline. -- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale. - BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y. - Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow. - Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u. @@ -52,6 +50,7 @@ Docs: https://docs.openclaw.ai - Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug. - Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug. - Heartbeat: allow explicit wake (`wake`) and hook wake (`hook:*`) reasons to run even when `HEARTBEAT.md` is effectively empty so queued system events are processed. (#14527) Thanks @arosstale. +- Heartbeat: add `agents.defaults.heartbeat.emptyFilePolicy` (`skip`|`run`) with compatibility defaults (`skip` for existing config files, `run` for fresh installs) so empty/comment-only `HEARTBEAT.md` behavior is explicit and predictable. - Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish. - Agents/Heartbeat: stop auto-creating `HEARTBEAT.md` during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238. - Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew. diff --git a/docs/automation/troubleshooting.md b/docs/automation/troubleshooting.md index 51f2aa209cf..b6a3b46f6ac 100644 --- a/docs/automation/troubleshooting.md +++ b/docs/automation/troubleshooting.md @@ -89,7 +89,7 @@ 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` → `HEARTBEAT.md` is comments-only and `heartbeat.emptyFilePolicy` is `skip`. - `alerts-disabled` → visibility settings suppress outbound heartbeat messages. ## Timezone and activeHours gotchas diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 6c467d2ae10..6702cc9ebbb 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -209,6 +209,10 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped. - `prompt`: overrides the default prompt body (not merged). - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery. +- `emptyFilePolicy`: behavior when `HEARTBEAT.md` exists but has only comments/headers. + - `run`: heartbeat still runs. + - `skip`: skip this heartbeat run with reason `empty-heartbeat-file`. + - Default when unset: `skip` for existing config files (compat), `run` for fresh installs. - `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`. - 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. @@ -297,9 +301,14 @@ If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the agent to read it. Think of it as your “heartbeat checklist”: small, stable, and safe to include every 30 minutes. -If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown -headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. -If the file is missing, the heartbeat still runs and the model decides what to do. +If `HEARTBEAT.md` exists, OpenClaw includes it as optional guidance for heartbeat runs. +If the file is missing, heartbeat still runs and the model decides what to do. +If `HEARTBEAT.md` only has comments/headers, `heartbeat.emptyFilePolicy` controls behavior: + +- `run`: heartbeat still runs (fresh-install default) +- `skip`: heartbeat skips this tick (`empty-heartbeat-file`; compat default for existing config files) + +To disable heartbeat entirely (scheduler off), set `agents.defaults.heartbeat.every: "0m"`. Keep it tiny (short checklist or reminders) to avoid prompt bloat. diff --git a/docs/reference/templates/HEARTBEAT.md b/docs/reference/templates/HEARTBEAT.md index 58b844f91bd..afbf576b64b 100644 --- a/docs/reference/templates/HEARTBEAT.md +++ b/docs/reference/templates/HEARTBEAT.md @@ -7,6 +7,10 @@ read_when: # HEARTBEAT.md -# Keep this file empty (or with only comments) to skip heartbeat API calls. +# Leave this file missing, empty, or comments-only if you don't want extra heartbeat guidance. + +# Set agents.defaults.heartbeat.emptyFilePolicy: "run" if you want heartbeat to run even when this file is comments-only. + +# Set agents.defaults.heartbeat.every: "0m" in config to disable heartbeat entirely. # Add tasks below when you want the agent to check something periodically. diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index fec776bb8f6..067472cd49b 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -159,10 +159,13 @@ Example: By default, OpenClaw runs a heartbeat every 30 minutes with the prompt: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.` -Set `agents.defaults.heartbeat.every: "0m"` to disable. +Set `agents.defaults.heartbeat.every: "0m"` to disable heartbeat scheduling entirely. -- If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. -- If the file is missing, the heartbeat still runs and the model decides what to do. +- If `HEARTBEAT.md` exists, OpenClaw includes it as optional heartbeat guidance. +- If the file is missing, heartbeat still runs and the model decides what to do. +- If `HEARTBEAT.md` only has comments/headers, behavior depends on `agents.defaults.heartbeat.emptyFilePolicy`: + - `run`: heartbeat still runs (fresh-install default) + - `skip`: heartbeat is skipped for that tick (compat default for existing config files) - If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat. - Heartbeats run full agent turns — shorter intervals burn more tokens. diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index db074d94303..9422ba83bfe 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -189,8 +189,8 @@ export async function ensureAgentWorkspace(params?: { const identityPath = path.join(dir, DEFAULT_IDENTITY_FILENAME); const userPath = path.join(dir, DEFAULT_USER_FILENAME); // HEARTBEAT.md is intentionally NOT created from template. - // Per docs: "If the file is missing, the heartbeat still runs and the model decides what to do." - // Creating it from template (which is effectively empty) would cause heartbeat to be skipped. + // It's optional workspace guidance, and heartbeat behavior is controlled by config + // (`agents.defaults.heartbeat.every`) rather than file presence/content. const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME); const isBrandNewWorkspace = await (async () => { diff --git a/src/config/config.pruning-defaults.test.ts b/src/config/config.pruning-defaults.test.ts index 00320e618c5..e701b641c5b 100644 --- a/src/config/config.pruning-defaults.test.ts +++ b/src/config/config.pruning-defaults.test.ts @@ -115,4 +115,38 @@ describe("config pruning defaults", () => { expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("off"); }); }); + + it("defaults emptyFilePolicy to skip for existing config files", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify({ agents: { defaults: { heartbeat: { every: "30m" } } } }, null, 2), + "utf-8", + ); + + const cfg = loadConfig(); + expect(cfg.agents?.defaults?.heartbeat?.emptyFilePolicy).toBe("skip"); + }); + }); + + it("keeps explicit emptyFilePolicy when configured", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify( + { agents: { defaults: { heartbeat: { every: "30m", emptyFilePolicy: "run" } } } }, + null, + 2, + ), + "utf-8", + ); + + const cfg = loadConfig(); + expect(cfg.agents?.defaults?.heartbeat?.emptyFilePolicy).toBe("run"); + }); + }); }); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 820533a0664..e18107a7a4d 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,3 +1,4 @@ +import type { AgentDefaultsConfig } from "./types.agent-defaults.js"; import type { OpenClawConfig } from "./types.js"; import type { ModelDefinitionConfig } from "./types.models.js"; import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; @@ -440,6 +441,46 @@ export function applyContextPruningDefaults(cfg: OpenClawConfig): OpenClawConfig }; } +export type HeartbeatEmptyFilePolicyDefaultsOptions = { + configFileExists: boolean; +}; + +export function applyHeartbeatEmptyFilePolicyDefaults( + cfg: OpenClawConfig, + options: HeartbeatEmptyFilePolicyDefaultsOptions, +): OpenClawConfig { + const defaults = cfg.agents?.defaults; + if (!defaults) { + return cfg; + } + + const defaultHeartbeat = defaults.heartbeat; + const hasDefaultPolicy = typeof defaultHeartbeat?.emptyFilePolicy === "string"; + if (hasDefaultPolicy) { + return cfg; + } + + // Backward compatibility: preserve legacy skip behavior for existing config files. + // Fresh installs (no config file yet) default to "run". + const fallbackPolicy: NonNullable["emptyFilePolicy"] = + options.configFileExists ? "skip" : "run"; + const nextDefaults = { + ...defaults, + heartbeat: { + ...defaultHeartbeat, + emptyFilePolicy: fallbackPolicy, + }, + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: nextDefaults, + }, + }; +} + export function applyCompactionDefaults(cfg: OpenClawConfig): OpenClawConfig { const defaults = cfg.agents?.defaults; if (!defaults) { diff --git a/src/config/io.ts b/src/config/io.ts index a2d0b4c791e..1221fd7b5fe 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -19,6 +19,7 @@ import { applyCompactionDefaults, applyContextPruningDefaults, applyAgentDefaults, + applyHeartbeatEmptyFilePolicyDefaults, applyLoggingDefaults, applyMessageDefaults, applyModelDefaults, @@ -610,10 +611,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { warnIfConfigFromFuture(validated.config, deps.logger); const cfg = applyModelDefaults( applyCompactionDefaults( - applyContextPruningDefaults( - applyAgentDefaults( - applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + applyHeartbeatEmptyFilePolicyDefaults( + applyContextPruningDefaults( + applyAgentDefaults( + applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + ), ), + { configFileExists: true }, ), ), ); @@ -663,8 +667,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const config = applyTalkApiKey( applyModelDefaults( applyCompactionDefaults( - applyContextPruningDefaults( - applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))), + applyHeartbeatEmptyFilePolicyDefaults( + applyContextPruningDefaults( + applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))), + ), + { configFileExists: false }, ), ), ), @@ -796,10 +803,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { config: normalizeConfigPaths( applyTalkApiKey( applyModelDefaults( - applyAgentDefaults( - applySessionDefaults( - applyLoggingDefaults(applyMessageDefaults(validated.config)), + applyHeartbeatEmptyFilePolicyDefaults( + applyAgentDefaults( + applySessionDefaults( + applyLoggingDefaults(applyMessageDefaults(validated.config)), + ), ), + { configFileExists: true }, ), ), ), diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 9f1fe795aff..5ad46a93e1e 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -143,6 +143,10 @@ export const FIELD_HELP: Record = { "agents.defaults.envelopeTimestamp": 'Include absolute timestamps in message envelopes ("on" or "off").', "agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").', + "agents.defaults.heartbeat.emptyFilePolicy": + 'Behavior when HEARTBEAT.md has only comments/headers: "skip" to preserve legacy behavior or "run" to keep heartbeat running.', + "agents.list[].heartbeat.emptyFilePolicy": + 'Per-agent override for empty HEARTBEAT.md behavior ("skip" or "run").', "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", "agents.defaults.memorySearch": "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 5f0b0a53528..581e383474e 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -194,6 +194,8 @@ export const FIELD_LABELS: Record = { "agents.defaults.humanDelay.mode": "Human Delay Mode", "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", + "agents.defaults.heartbeat.emptyFilePolicy": "Heartbeat Empty File Policy", + "agents.list[].heartbeat.emptyFilePolicy": "Agent Heartbeat Empty File Policy", "agents.defaults.cliBackends": "CLI Backends", "commands.native": "Native Commands", "commands.nativeSkills": "Native Skill Commands", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 217e8f12559..f42ffae39bd 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -190,6 +190,12 @@ export type AgentDefaultsConfig = { prompt?: string; /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ ackMaxChars?: number; + /** + * Behavior when HEARTBEAT.md exists but has no actionable content (comments/headers only). + * - "skip": skip this heartbeat run + * - "run": continue heartbeat run (default for fresh installs) + */ + emptyFilePolicy?: "skip" | "run"; /** * When enabled, deliver the model's reasoning payload for heartbeat runs (when available) * as a separate message prefixed with `Reasoning:` (same as `/reasoning on`). diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 8190c5bded9..02e90755887 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -28,6 +28,7 @@ export const HeartbeatSchema = z accountId: z.string().optional(), prompt: z.string().optional(), ackMaxChars: z.number().int().nonnegative().optional(), + emptyFilePolicy: z.union([z.literal("skip"), z.literal("run")]).optional(), }) .strict() .superRefine((val, ctx) => { diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 90359fadacb..b7c18fa271e 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -948,7 +948,7 @@ describe("runHeartbeatOnce", () => { } }); - it("skips heartbeat when HEARTBEAT.md is effectively empty (saves API calls)", async () => { + it("runs heartbeat when HEARTBEAT.md is effectively empty", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); const storePath = path.join(tmpDir, "sessions.json"); const workspaceDir = path.join(tmpDir, "workspace"); @@ -991,6 +991,73 @@ describe("runHeartbeatOnce", () => { ), ); + replySpy.mockResolvedValue({ text: "heartbeat ran" }); + 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, + }, + }); + + expect(res.status).toBe("ran"); + expect(replySpy).toHaveBeenCalled(); + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("skips heartbeat when HEARTBEAT.md is effectively empty and emptyFilePolicy=skip", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "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", emptyFilePolicy: "skip" }, + }, + }, + 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, + ), + ); + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid", @@ -1007,7 +1074,6 @@ describe("runHeartbeatOnce", () => { }, }); - // Should skip without making API call expect(res.status).toBe("skipped"); if (res.status === "skipped") { expect(res.reason).toBe("empty-heartbeat-file"); @@ -1020,7 +1086,7 @@ describe("runHeartbeatOnce", () => { } }); - it("does not skip wake-triggered heartbeat when HEARTBEAT.md is effectively empty", async () => { + it("runs wake-triggered heartbeat when HEARTBEAT.md is effectively empty", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); const storePath = path.join(tmpDir, "sessions.json"); const workspaceDir = path.join(tmpDir, "workspace"); @@ -1088,7 +1154,7 @@ describe("runHeartbeatOnce", () => { } }); - it("does not skip hook-triggered heartbeat when HEARTBEAT.md is effectively empty", async () => { + it("runs hook-triggered heartbeat when HEARTBEAT.md is effectively empty", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); const storePath = path.join(tmpDir, "sessions.json"); const workspaceDir = path.join(tmpDir, "workspace"); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 9ee8bcc09f6..63153f5dd3c 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -92,6 +92,7 @@ export type HeartbeatSummary = { }; const DEFAULT_HEARTBEAT_TARGET = "last"; +const DEFAULT_EMPTY_FILE_POLICY: NonNullable["emptyFilePolicy"] = "run"; // Prompt used when an async exec has completed and the result should be relayed to the user. // This overrides the standard heartbeat prompt to ensure the model responds with the exec result @@ -252,6 +253,10 @@ function resolveHeartbeatAckMaxChars(cfg: OpenClawConfig, heartbeat?: HeartbeatC ); } +function resolveHeartbeatEmptyFilePolicy(cfg: OpenClawConfig, heartbeat?: HeartbeatConfig) { + return heartbeat?.emptyFilePolicy ?? cfg.agents?.defaults?.heartbeat?.emptyFilePolicy ?? "run"; +} + function resolveHeartbeatSession( cfg: OpenClawConfig, agentId?: string, @@ -424,33 +429,32 @@ 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" }; + const emptyFilePolicy = + resolveHeartbeatEmptyFilePolicy(cfg, heartbeat) ?? DEFAULT_EMPTY_FILE_POLICY; + if (emptyFilePolicy === "skip") { + 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 missing/unreadable: proceed with heartbeat. } - } 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(cfg, agentId, heartbeat);