feat: skip heartbeat API calls when HEARTBEAT.md is effectively empty
- Added isHeartbeatContentEffectivelyEmpty() to detect files with only headers/comments - Modified runHeartbeatOnce() to check HEARTBEAT.md content before polling the LLM - Returns early with 'empty-heartbeat-file' reason when no actionable tasks exist - Preserves existing behavior when file is missing (lets LLM decide) - Added comprehensive test coverage for empty file detection - Saves API calls/costs when heartbeat file has no meaningful content
This commit is contained in:
parent
bfbeea0f20
commit
744852d313
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@ -376,6 +376,8 @@ importers:
|
|||||||
specifier: ^4.3.5
|
specifier: ^4.3.5
|
||||||
version: 4.3.5
|
version: 4.3.5
|
||||||
|
|
||||||
|
extensions/open-prose: {}
|
||||||
|
|
||||||
extensions/signal: {}
|
extensions/signal: {}
|
||||||
|
|
||||||
extensions/slack: {}
|
extensions/slack: {}
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "./heartbeat.js";
|
import {
|
||||||
|
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||||
|
isHeartbeatContentEffectivelyEmpty,
|
||||||
|
stripHeartbeatToken,
|
||||||
|
} from "./heartbeat.js";
|
||||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||||
|
|
||||||
describe("stripHeartbeatToken", () => {
|
describe("stripHeartbeatToken", () => {
|
||||||
@ -105,3 +109,76 @@ describe("stripHeartbeatToken", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isHeartbeatContentEffectivelyEmpty", () => {
|
||||||
|
it("returns false for undefined/null (missing file should not skip)", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(undefined)).toBe(false);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for empty string", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for whitespace only", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(" ")).toBe(true);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("\n\n\n")).toBe(true);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(" \n \n ")).toBe(true);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("\t\t")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for header-only content", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md")).toBe(true);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n")).toBe(true);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for comments only", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for default template content (header + comment)", () => {
|
||||||
|
const defaultTemplate = `# HEARTBEAT.md
|
||||||
|
|
||||||
|
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||||
|
`;
|
||||||
|
// Note: The template has actual text content, so it's NOT effectively empty
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(defaultTemplate)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for header with only empty lines", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n\n")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when actionable content exists", () => {
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("- Check email")).toBe(false);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n- Task 1")).toBe(false);
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty("Remind me to call mom")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for content with tasks after header", () => {
|
||||||
|
const content = `# HEARTBEAT.md
|
||||||
|
|
||||||
|
- Task 1
|
||||||
|
- Task 2
|
||||||
|
`;
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for mixed content with non-comment text", () => {
|
||||||
|
const content = `# HEARTBEAT.md
|
||||||
|
## Tasks
|
||||||
|
Check the server logs
|
||||||
|
`;
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats markdown headers as comments (effectively empty)", () => {
|
||||||
|
const content = `# HEARTBEAT.md
|
||||||
|
## Section 1
|
||||||
|
### Subsection
|
||||||
|
`;
|
||||||
|
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -7,6 +7,36 @@ export const HEARTBEAT_PROMPT =
|
|||||||
export const DEFAULT_HEARTBEAT_EVERY = "30m";
|
export const DEFAULT_HEARTBEAT_EVERY = "30m";
|
||||||
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
|
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if HEARTBEAT.md content is "effectively empty" - meaning it has no actionable tasks.
|
||||||
|
* This allows skipping heartbeat API calls when no tasks are configured.
|
||||||
|
*
|
||||||
|
* A file is considered effectively empty if it contains only:
|
||||||
|
* - Whitespace
|
||||||
|
* - Comment lines (lines starting with #)
|
||||||
|
* - Empty lines
|
||||||
|
*
|
||||||
|
* Note: A missing file returns false (not effectively empty) so the LLM can still
|
||||||
|
* decide what to do. This function is only for when the file exists but has no content.
|
||||||
|
*/
|
||||||
|
export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
|
||||||
|
if (content === undefined || content === null) return false;
|
||||||
|
if (typeof content !== "string") return false;
|
||||||
|
|
||||||
|
const lines = content.split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
// Skip empty lines
|
||||||
|
if (!trimmed) continue;
|
||||||
|
// Skip comment/header lines (markdown headers are comments for our purposes)
|
||||||
|
if (trimmed.startsWith("#")) continue;
|
||||||
|
// Found a non-empty, non-comment line - there's actionable content
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// All lines were either empty or comments
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveHeartbeatPrompt(raw?: string): string {
|
export function resolveHeartbeatPrompt(raw?: string): string {
|
||||||
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
||||||
return trimmed || HEARTBEAT_PROMPT;
|
return trimmed || HEARTBEAT_PROMPT;
|
||||||
|
|||||||
@ -793,4 +793,209 @@ describe("runHeartbeatOnce", () => {
|
|||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips heartbeat when HEARTBEAT.md is effectively empty (saves API calls)", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-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 });
|
||||||
|
|
||||||
|
// Create effectively empty HEARTBEAT.md (only header and comments)
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||||
|
"# HEARTBEAT.md\n\n## Tasks\n\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should skip without making API call
|
||||||
|
expect(res.status).toBe("skipped");
|
||||||
|
if (res.status === "skipped") {
|
||||||
|
expect(res.reason).toBe("empty-heartbeat-file");
|
||||||
|
}
|
||||||
|
expect(replySpy).not.toHaveBeenCalled();
|
||||||
|
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
replySpy.mockRestore();
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs heartbeat when HEARTBEAT.md has actionable content", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-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 });
|
||||||
|
|
||||||
|
// Create HEARTBEAT.md with actionable content
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||||
|
"# HEARTBEAT.md\n\n- Check server logs\n- Review pending PRs\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should run and make API call
|
||||||
|
expect(res.status).toBe("ran");
|
||||||
|
expect(replySpy).toHaveBeenCalled();
|
||||||
|
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
replySpy.mockRestore();
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs heartbeat when HEARTBEAT.md does not exist (lets LLM decide)", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-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 - it doesn't exist
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
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: "HEARTBEAT_OK" });
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should run (not skip) - let LLM decide since file doesn't exist
|
||||||
|
expect(res.status).toBe("ran");
|
||||||
|
expect(replySpy).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
replySpy.mockRestore();
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,9 +1,18 @@
|
|||||||
import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveAgentConfig,
|
||||||
|
resolveAgentWorkspaceDir,
|
||||||
|
resolveDefaultAgentId,
|
||||||
|
} from "../agents/agent-scope.js";
|
||||||
import { resolveUserTimezone } from "../agents/date-time.js";
|
import { resolveUserTimezone } from "../agents/date-time.js";
|
||||||
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
||||||
|
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||||
DEFAULT_HEARTBEAT_EVERY,
|
DEFAULT_HEARTBEAT_EVERY,
|
||||||
|
isHeartbeatContentEffectivelyEmpty,
|
||||||
resolveHeartbeatPrompt as resolveHeartbeatPromptText,
|
resolveHeartbeatPrompt as resolveHeartbeatPromptText,
|
||||||
stripHeartbeatToken,
|
stripHeartbeatToken,
|
||||||
} from "../auto-reply/heartbeat.js";
|
} from "../auto-reply/heartbeat.js";
|
||||||
@ -440,6 +449,25 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
return { status: "skipped", reason: "requests-in-flight" };
|
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).
|
||||||
|
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)) {
|
||||||
|
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(cfg, agentId, heartbeat);
|
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
|
||||||
const previousUpdatedAt = entry?.updatedAt;
|
const previousUpdatedAt = entry?.updatedAt;
|
||||||
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user