fix: skip heartbeat when HEARTBEAT.md does not exist (#20461)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: f6e5f8172a
Co-authored-by: vikpos <24960005+vikpos@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
vikpos
2026-02-19 06:09:33 +00:00
committed by GitHub
parent 48e6b4fca3
commit f855d0be4f
11 changed files with 456 additions and 56 deletions

View File

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