fix(heartbeat): pin HEARTBEAT.md reads to workspace path

This commit is contained in:
Vignesh Natarajan
2026-03-05 18:52:31 -08:00
parent 1efa7a88c4
commit 604f22c42a
3 changed files with 38 additions and 2 deletions

View File

@@ -1080,9 +1080,28 @@ describe("runHeartbeatOnce", () => {
reason: params.reason,
deps: createHeartbeatDeps(sendWhatsApp),
});
return { res, replySpy, sendWhatsApp };
return { res, replySpy, sendWhatsApp, workspaceDir };
}
it("adds explicit workspace HEARTBEAT.md path guidance to heartbeat prompts", async () => {
const { res, replySpy, sendWhatsApp, workspaceDir } = await runHeartbeatFileScenario({
fileState: "actionable",
reason: "interval",
replyText: "Checked logs and PRs",
});
try {
expect(res.status).toBe("ran");
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
expect(replySpy).toHaveBeenCalledTimes(1);
const calledCtx = replySpy.mock.calls[0]?.[0] as { Body?: string };
const expectedPath = path.join(workspaceDir, "HEARTBEAT.md").replace(/\\/g, "/");
expect(calledCtx.Body).toContain(`use workspace file ${expectedPath} (exact case)`);
expect(calledCtx.Body).toContain("Do not read docs/heartbeat.md.");
} finally {
replySpy.mockRestore();
}
});
it("applies HEARTBEAT.md gating rules across file states and triggers", async () => {
const cases: Array<{
name: string;

View File

@@ -560,11 +560,24 @@ type HeartbeatPromptResolution = {
hasCronEvents: boolean;
};
function appendHeartbeatWorkspacePathHint(prompt: string, workspaceDir: string): string {
if (!/heartbeat\.md/i.test(prompt)) {
return prompt;
}
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME).replace(/\\/g, "/");
const hint = `When reading HEARTBEAT.md, use workspace file ${heartbeatFilePath} (exact case). Do not read docs/heartbeat.md.`;
if (prompt.includes(hint)) {
return prompt;
}
return `${prompt}\n${hint}`;
}
function resolveHeartbeatRunPrompt(params: {
cfg: OpenClawConfig;
heartbeat?: HeartbeatConfig;
preflight: HeartbeatPreflight;
canRelayToUser: boolean;
workspaceDir: string;
}): HeartbeatPromptResolution {
const pendingEventEntries = params.preflight.pendingEventEntries;
const pendingEvents = params.preflight.shouldInspectPendingEvents
@@ -579,11 +592,12 @@ function resolveHeartbeatRunPrompt(params: {
.map((event) => event.text);
const hasExecCompletion = pendingEvents.some(isExecCompletionEvent);
const hasCronEvents = cronEvents.length > 0;
const prompt = hasExecCompletion
const basePrompt = hasExecCompletion
? buildExecEventPrompt({ deliverToUser: params.canRelayToUser })
: hasCronEvents
? buildCronEventPrompt(cronEvents, { deliverToUser: params.canRelayToUser })
: resolveHeartbeatPrompt(params.cfg, params.heartbeat);
const prompt = appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir);
return { prompt, hasExecCompletion, hasCronEvents };
}
@@ -668,11 +682,13 @@ export async function runHeartbeatOnce(opts: {
const canRelayToUser = Boolean(
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const { prompt, hasExecCompletion, hasCronEvents } = resolveHeartbeatRunPrompt({
cfg,
heartbeat,
preflight,
canRelayToUser,
workspaceDir,
});
const ctx = {
Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),