fix(heartbeat): run when HEARTBEAT.md is missing

This commit is contained in:
Gustavo Madeira Santana
2026-02-19 19:32:18 -05:00
parent 6bc9824735
commit cf4ffff3e1
3 changed files with 100 additions and 43 deletions

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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<typeof resolveHeartbeatSession>;
@@ -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<HeartbeatPreflight, "skipReason">;
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: {