Skip to content

Commit cf4ffff

Browse files
committed
fix(heartbeat): run when HEARTBEAT.md is missing
1 parent 6bc9824 commit cf4ffff

3 files changed

Lines changed: 91 additions & 34 deletions

File tree

docs/automation/troubleshooting.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ Common signatures:
9090
- `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`.
9191
- `requests-in-flight` → main lane busy; heartbeat deferred.
9292
- `empty-heartbeat-file` → interval heartbeat skipped because `HEARTBEAT.md` has no actionable content and no tagged cron event is queued.
93-
- `no-heartbeat-file` → interval heartbeat skipped because `HEARTBEAT.md` is missing and no tagged cron event is queued.
9493
- `alerts-disabled` → visibility settings suppress outbound heartbeat messages.
9594

9695
## Timezone and activeHours gotchas

src/infra/heartbeat-runner.returns-default-unset.test.ts

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,7 +1372,7 @@ describe("runHeartbeatOnce", () => {
13721372
}
13731373
});
13741374

1375-
it("skips heartbeat when HEARTBEAT.md does not exist (saves API calls)", async () => {
1375+
it("runs heartbeat when HEARTBEAT.md does not exist", async () => {
13761376
const tmpDir = await createCaseDir("openclaw-hb");
13771377
const storePath = path.join(tmpDir, "sessions.json");
13781378
const workspaceDir = path.join(tmpDir, "workspace");
@@ -1409,7 +1409,7 @@ describe("runHeartbeatOnce", () => {
14091409
),
14101410
);
14111411

1412-
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
1412+
replySpy.mockResolvedValue({ text: "Checked logs and PRs" });
14131413
const sendWhatsApp = vi.fn().mockResolvedValue({
14141414
messageId: "m1",
14151415
toJid: "jid",
@@ -1426,13 +1426,74 @@ describe("runHeartbeatOnce", () => {
14261426
},
14271427
});
14281428

1429-
// Should skip - no HEARTBEAT.md means nothing actionable
1430-
expect(res.status).toBe("skipped");
1431-
if (res.status === "skipped") {
1432-
expect(res.reason).toBe("no-heartbeat-file");
1433-
}
1434-
expect(replySpy).not.toHaveBeenCalled();
1435-
expect(sendWhatsApp).not.toHaveBeenCalled();
1429+
// Missing HEARTBEAT.md should still run so prompt/system instructions can drive work.
1430+
expect(res.status).toBe("ran");
1431+
expect(replySpy).toHaveBeenCalled();
1432+
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
1433+
} finally {
1434+
replySpy.mockRestore();
1435+
}
1436+
});
1437+
1438+
it("runs heartbeat when HEARTBEAT.md read fails with a non-ENOENT error", async () => {
1439+
const tmpDir = await createCaseDir("openclaw-hb");
1440+
const storePath = path.join(tmpDir, "sessions.json");
1441+
const workspaceDir = path.join(tmpDir, "workspace");
1442+
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
1443+
try {
1444+
await fs.mkdir(workspaceDir, { recursive: true });
1445+
// Simulate a read failure path (readFile on a directory returns EISDIR).
1446+
await fs.mkdir(path.join(workspaceDir, "HEARTBEAT.md"), { recursive: true });
1447+
1448+
const cfg: OpenClawConfig = {
1449+
agents: {
1450+
defaults: {
1451+
workspace: workspaceDir,
1452+
heartbeat: { every: "5m", target: "whatsapp" },
1453+
},
1454+
},
1455+
channels: { whatsapp: { allowFrom: ["*"] } },
1456+
session: { store: storePath },
1457+
};
1458+
const sessionKey = resolveMainSessionKey(cfg);
1459+
1460+
await fs.writeFile(
1461+
storePath,
1462+
JSON.stringify(
1463+
{
1464+
[sessionKey]: {
1465+
sessionId: "sid",
1466+
updatedAt: Date.now(),
1467+
lastChannel: "whatsapp",
1468+
lastTo: "+1555",
1469+
},
1470+
},
1471+
null,
1472+
2,
1473+
),
1474+
);
1475+
1476+
replySpy.mockResolvedValue({ text: "Checked logs and PRs" });
1477+
const sendWhatsApp = vi.fn().mockResolvedValue({
1478+
messageId: "m1",
1479+
toJid: "jid",
1480+
});
1481+
1482+
const res = await runHeartbeatOnce({
1483+
cfg,
1484+
deps: {
1485+
sendWhatsApp,
1486+
getQueueSize: () => 0,
1487+
nowMs: () => 0,
1488+
webAuthExists: async () => true,
1489+
hasActiveWebListener: () => true,
1490+
},
1491+
});
1492+
1493+
// Read errors other than ENOENT should not disable heartbeat runs.
1494+
expect(res.status).toBe("ran");
1495+
expect(replySpy).toHaveBeenCalled();
1496+
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
14361497
} finally {
14371498
replySpy.mockRestore();
14381499
}

src/infra/heartbeat-runner.ts

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import { CommandLane } from "../process/lanes.js";
4141
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
4242
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
4343
import { escapeRegExp } from "../utils.js";
44-
import { formatErrorMessage } from "./errors.js";
44+
import { formatErrorMessage, hasErrnoCode } from "./errors.js";
4545
import { isWithinActiveHours } from "./heartbeat-active-hours.js";
4646
import {
4747
buildCronEventPrompt,
@@ -481,7 +481,7 @@ type HeartbeatReasonFlags = {
481481
isWakeReason: boolean;
482482
};
483483

484-
type HeartbeatSkipReason = "empty-heartbeat-file" | "no-heartbeat-file";
484+
type HeartbeatSkipReason = "empty-heartbeat-file";
485485

486486
type HeartbeatPreflight = HeartbeatReasonFlags & {
487487
session: ReturnType<typeof resolveHeartbeatSession>;
@@ -525,42 +525,39 @@ async function resolveHeartbeatPreflight(params: {
525525
reasonFlags.isCronEventReason ||
526526
reasonFlags.isWakeReason ||
527527
hasTaggedCronEvents;
528+
const basePreflight = {
529+
...reasonFlags,
530+
session,
531+
pendingEventEntries,
532+
hasTaggedCronEvents,
533+
shouldInspectPendingEvents,
534+
} satisfies Omit<HeartbeatPreflight, "skipReason">;
535+
536+
if (shouldBypassFileGates) {
537+
return basePreflight;
538+
}
528539

529540
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
530541
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
531542
try {
532543
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
533-
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !shouldBypassFileGates) {
544+
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) {
534545
return {
535-
...reasonFlags,
536-
session,
537-
pendingEventEntries,
538-
hasTaggedCronEvents,
539-
shouldInspectPendingEvents,
546+
...basePreflight,
540547
skipReason: "empty-heartbeat-file",
541548
};
542549
}
543550
} catch (err: unknown) {
544-
if ((err as NodeJS.ErrnoException)?.code === "ENOENT" && !shouldBypassFileGates) {
545-
return {
546-
...reasonFlags,
547-
session,
548-
pendingEventEntries,
549-
hasTaggedCronEvents,
550-
shouldInspectPendingEvents,
551-
skipReason: "no-heartbeat-file",
552-
};
551+
if (hasErrnoCode(err, "ENOENT")) {
552+
// Missing HEARTBEAT.md is intentional in some setups (for example, when
553+
// heartbeat instructions live outside the file), so keep the run active.
554+
// The heartbeat prompt already says "if it exists".
555+
return basePreflight;
553556
}
554557
// For other read errors, proceed with heartbeat as before.
555558
}
556559

557-
return {
558-
...reasonFlags,
559-
session,
560-
pendingEventEntries,
561-
hasTaggedCronEvents,
562-
shouldInspectPendingEvents,
563-
};
560+
return basePreflight;
564561
}
565562

566563
export async function runHeartbeatOnce(opts: {

0 commit comments

Comments
 (0)