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

@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
- OpenAI Codex OAuth/scope request parity: augment the OAuth authorize URL with required API scopes (`api.responses.write`, `model.request`, `api.model.read`) before browser handoff so OAuth tokens include runtime model/request permissions expected by OpenAI API calls. (#24720) Thanks @Skippy-Gunboat.
- Onboarding/API key input hardening: strip non-Latin1 Unicode artifacts from normalized secret input (while preserving Latin-1 content and internal spaces) so malformed copied API keys cannot trigger HTTP header `ByteString` construction crashes; adds regression coverage for shared normalization and MiniMax auth header usage. (#24496) Thanks @fa6maalassaf.
- Kimi Coding/Anthropic tools compatibility: normalize `anthropic-messages` tool payloads to OpenAI-style `tools[].function` + compatible `tool_choice` when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub.
- Heartbeat/workspace-path guardrails: append explicit workspace `HEARTBEAT.md` path guidance (and `docs/heartbeat.md` avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy.
- Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn.
- Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao.
- Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693.

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),