From 604f22c42a9303211571b11babed074c1f1d510f Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 5 Mar 2026 18:52:31 -0800 Subject: [PATCH] fix(heartbeat): pin HEARTBEAT.md reads to workspace path --- CHANGELOG.md | 1 + ...tbeat-runner.returns-default-unset.test.ts | 21 ++++++++++++++++++- src/infra/heartbeat-runner.ts | 18 +++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b6c06f191..4a726bb1b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index aa4278a75b7..2ac6a8be0f3 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -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; diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 2d0bee48f0c..71953e1da78 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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),