This commit is contained in:
Kirat 2026-01-30 18:14:05 +07:00 committed by GitHub
commit c146f0d758
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 164 additions and 5 deletions

View File

@ -19,6 +19,7 @@ import {
runHeartbeatOnce, runHeartbeatOnce,
} from "./heartbeat-runner.js"; } from "./heartbeat-runner.js";
import { resolveHeartbeatDeliveryTarget } from "./outbound/targets.js"; import { resolveHeartbeatDeliveryTarget } from "./outbound/targets.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js";
import { setActivePluginRegistry } from "../plugins/runtime.js"; import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createPluginRuntime } from "../plugins/runtime/index.js"; import { createPluginRuntime } from "../plugins/runtime/index.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js";
@ -1003,4 +1004,157 @@ describe("runHeartbeatOnce", () => {
await fs.rm(tmpDir, { recursive: true, force: true }); await fs.rm(tmpDir, { recursive: true, force: true });
} }
}); });
it("runs heartbeat when HEARTBEAT.md is empty but there are pending system events (cron)", 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
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,
),
);
// Enqueue a system event (simulates cron job)
resetSystemEventsForTest();
enqueueSystemEvent("⏰ Reminder: Check your tasks", { sessionKey });
replySpy.mockResolvedValue([{ text: "Got your reminder! Checking tasks now." }]);
const sendWhatsApp = vi.fn().mockResolvedValue({
messageId: "m1",
toJid: "jid",
});
const res = await runHeartbeatOnce({
cfg,
reason: "cron:test-job",
deps: {
sendWhatsApp,
getQueueSize: () => 0,
nowMs: () => 0,
webAuthExists: async () => true,
hasActiveWebListener: () => true,
},
});
// Should run despite empty HEARTBEAT.md because there are pending system events
expect(res.status).toBe("ran");
expect(replySpy).toHaveBeenCalled();
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
} finally {
replySpy.mockRestore();
resetSystemEventsForTest();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("skips heartbeat when HEARTBEAT.md is empty and no pending system events", 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
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,
),
);
// Ensure no pending system events
resetSystemEventsForTest();
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,
},
});
// Should skip because HEARTBEAT.md is empty and no pending events
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();
resetSystemEventsForTest();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
}); });

View File

@ -35,7 +35,7 @@ import {
} from "../config/sessions.js"; } from "../config/sessions.js";
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import { peekSystemEvents } from "../infra/system-events.js"; import { hasSystemEvents, peekSystemEvents } from "../infra/system-events.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import { getQueueSize } from "../process/command-queue.js"; import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js"; import { CommandLane } from "../process/lanes.js";
@ -460,15 +460,22 @@ export async function runHeartbeatOnce(opts: {
return { status: "skipped", reason: "requests-in-flight" }; return { status: "skipped", reason: "requests-in-flight" };
} }
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
// Skip heartbeat if HEARTBEAT.md exists but has no actionable content. // 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). // This saves API calls/costs when the file is effectively empty (only comments/headers).
// EXCEPTION: Don't skip for exec events - they have pending system events to process. // EXCEPTION: Don't skip for exec events or if there are pending system events (from cron, etc.)
const isExecEventReason = opts.reason === "exec-event"; const isExecEventReason = opts.reason === "exec-event";
const hasPendingSystemEvents = hasSystemEvents(sessionKey);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME); const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
try { try {
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8"); const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !isExecEventReason) { if (
isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) &&
!isExecEventReason &&
!hasPendingSystemEvents
) {
emitHeartbeatEvent({ emitHeartbeatEvent({
status: "skipped", status: "skipped",
reason: "empty-heartbeat-file", reason: "empty-heartbeat-file",
@ -480,8 +487,6 @@ export async function runHeartbeatOnce(opts: {
// File doesn't exist or can't be read - proceed with heartbeat. // File doesn't exist or can't be read - proceed with heartbeat.
// The LLM prompt says "if it exists" so this is expected behavior. // The LLM prompt says "if it exists" so this is expected behavior.
} }
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 });
const visibility = const visibility =