From 75221e0550ec76a0df18573fc392a94751f13a2c Mon Sep 17 00:00:00 2001 From: Mason Huang Date: Wed, 27 May 2026 20:30:22 +0800 Subject: [PATCH] fix(agents): separate heartbeat runtime template (#85416) Summary: - The PR moves the runtime `HEARTBEAT.md` bootstrap template into `src/agents/templates`, keeps docs templates ... or other workspace files, adds a legacy heartbeat-template doctor repair, and updates package guards/tests. - PR surface: Source +281, Tests +283, Docs +11, Config +1, Other 0. Total +576 across 15 files. - Reproducibility: yes. from source inspection: current main loads `HEARTBEAT.md` from the docs template, and ... pty heartbeat file non-empty to the runtime. I did not run a live heartbeat repro in this read-only review. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(doctor): recognize heartbeat docs boilerplate - PR branch already contained follow-up commit before automerge: fix(agents): update heartbeat workspace test - PR branch already contained follow-up commit before automerge: fix(doctor): tighten heartbeat template repair Validation: - ClawSweeper review passed for head e34e85864c5743692a230020328e84462e8caf05. - Required merge gates passed before the squash merge. Prepared head SHA: e34e85864c5743692a230020328e84462e8caf05 Review: https://github.com/openclaw/openclaw/pull/85416#issuecomment-4519851630 Co-authored-by: Mason Huang Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: hxy91819 Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> --- docs/reference/templates/HEARTBEAT.md | 8 + package.json | 1 + scripts/lib/workspace-bootstrap-smoke.mjs | 2 +- src/agents/templates/HEARTBEAT.md | 3 + src/agents/workspace-templates.test.ts | 46 +++- src/agents/workspace-templates.ts | 62 ++++- src/agents/workspace.test.ts | 4 +- src/agents/workspace.ts | 33 ++- src/cli/gateway-cli/dev.ts | 31 ++- .../doctor-heartbeat-template-repair.test.ts | 221 ++++++++++++++++++ .../doctor-heartbeat-template-repair.ts | 185 +++++++++++++++ src/flows/doctor-health-contributions.test.ts | 9 + src/flows/doctor-health-contributions.ts | 14 ++ src/flows/doctor-health-conversion-plan.ts | 6 + test/openclaw-npm-release-check.test.ts | 15 ++ 15 files changed, 608 insertions(+), 32 deletions(-) create mode 100644 src/agents/templates/HEARTBEAT.md create mode 100644 src/commands/doctor-heartbeat-template-repair.test.ts create mode 100644 src/commands/doctor-heartbeat-template-repair.ts diff --git a/docs/reference/templates/HEARTBEAT.md b/docs/reference/templates/HEARTBEAT.md index a3950ba7aa5b..9300baa08b32 100644 --- a/docs/reference/templates/HEARTBEAT.md +++ b/docs/reference/templates/HEARTBEAT.md @@ -5,12 +5,20 @@ read_when: - Bootstrapping a workspace manually --- +# HEARTBEAT.md template + +`HEARTBEAT.md` lives in the agent workspace. Keep the file empty, or with only Markdown comments and headings, when you want OpenClaw to skip heartbeat model calls. + +The default runtime template is: + ```markdown # Keep this file empty (or with only comments) to skip heartbeat API calls. # Add tasks below when you want the agent to check something periodically. ``` +Add short tasks below the comments only when you want the agent to check something periodically. Keep heartbeat instructions small because they are read during recurring wakes. + ## Related - [Heartbeat config](/gateway/config-agents) diff --git a/package.json b/package.json index 64faec9be174..646237f60142 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "!docs/images/**", "!docs/**/*.jpg", "!docs/**/*.png", + "src/agents/templates/", "scripts/crabbox-wrapper.mjs", "patches/", "skills/", diff --git a/scripts/lib/workspace-bootstrap-smoke.mjs b/scripts/lib/workspace-bootstrap-smoke.mjs index 7bdf82e1cab8..f7f1bef5e356 100644 --- a/scripts/lib/workspace-bootstrap-smoke.mjs +++ b/scripts/lib/workspace-bootstrap-smoke.mjs @@ -9,7 +9,7 @@ export const WORKSPACE_TEMPLATE_PACK_PATHS = [ "docs/reference/templates/TOOLS.md", "docs/reference/templates/IDENTITY.md", "docs/reference/templates/USER.md", - "docs/reference/templates/HEARTBEAT.md", + "src/agents/templates/HEARTBEAT.md", "docs/reference/templates/BOOTSTRAP.md", ]; diff --git a/src/agents/templates/HEARTBEAT.md b/src/agents/templates/HEARTBEAT.md new file mode 100644 index 000000000000..e6432855ad16 --- /dev/null +++ b/src/agents/templates/HEARTBEAT.md @@ -0,0 +1,3 @@ +# Keep this file empty (or with only comments) to skip heartbeat API calls. + +# Add tasks below when you want the agent to check something periodically. diff --git a/src/agents/workspace-templates.test.ts b/src/agents/workspace-templates.test.ts index 1da24828792f..e26735edecb4 100644 --- a/src/agents/workspace-templates.test.ts +++ b/src/agents/workspace-templates.test.ts @@ -3,9 +3,11 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; +import { isHeartbeatContentEffectivelyEmpty } from "../auto-reply/heartbeat.js"; import { resetWorkspaceTemplateDirCache, resolveWorkspaceTemplateDir, + resolveWorkspaceTemplateSearchDirs, } from "./workspace-templates.js"; const tempDirs: string[] = []; @@ -28,9 +30,9 @@ describe("resolveWorkspaceTemplateDir", () => { const root = await makeTempRoot(); await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" })); - const templatesDir = path.join(root, "docs", "reference", "templates"); + const templatesDir = path.join(root, "src", "agents", "templates"); await fs.mkdir(templatesDir, { recursive: true }); - await fs.writeFile(path.join(templatesDir, "AGENTS.md"), "# ok\n"); + await fs.writeFile(path.join(templatesDir, "HEARTBEAT.md"), "# ok\n"); const distDir = path.join(root, "dist"); await fs.mkdir(distDir, { recursive: true }); @@ -40,7 +42,7 @@ describe("resolveWorkspaceTemplateDir", () => { expect(resolved).toBe(templatesDir); }); - it("falls back to package-root docs path when templates directory is missing", async () => { + it("falls back to package-root runtime path when templates directory is missing", async () => { const root = await makeTempRoot(); await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" })); @@ -49,6 +51,42 @@ describe("resolveWorkspaceTemplateDir", () => { const moduleUrl = pathToFileURL(path.join(distDir, "model-selection.mjs")).toString(); const resolved = await resolveWorkspaceTemplateDir({ cwd: distDir, moduleUrl }); - expect(path.normalize(resolved)).toBe(path.resolve("docs", "reference", "templates")); + expect(path.normalize(resolved)).toBe(path.resolve("src", "agents", "templates")); + }); + + it("includes docs templates as secondary search roots", async () => { + const root = await makeTempRoot(); + await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" })); + + const runtimeTemplatesDir = path.join(root, "src", "agents", "templates"); + const docsTemplatesDir = path.join(root, "docs", "reference", "templates"); + await fs.mkdir(runtimeTemplatesDir, { recursive: true }); + await fs.mkdir(docsTemplatesDir, { recursive: true }); + + const distDir = path.join(root, "dist"); + await fs.mkdir(distDir, { recursive: true }); + const moduleUrl = pathToFileURL(path.join(distDir, "model-selection.mjs")).toString(); + + const resolved = await resolveWorkspaceTemplateSearchDirs({ cwd: distDir, moduleUrl }); + expect(resolved.slice(0, 2)).toEqual([runtimeTemplatesDir, docsTemplatesDir]); + }); + + it("keeps runtime templates free of docs frontmatter", async () => { + const runtimeTemplatesDir = path.resolve("src", "agents", "templates"); + const entries = await fs.readdir(runtimeTemplatesDir); + const markdownFiles = entries.filter((entry) => entry.endsWith(".md")); + + expect(markdownFiles).toContain("HEARTBEAT.md"); + for (const fileName of markdownFiles) { + const content = await fs.readFile(path.join(runtimeTemplatesDir, fileName), "utf-8"); + expect(content.startsWith("---")).toBe(false); + } + }); + + it("keeps the runtime HEARTBEAT.md template effectively empty", async () => { + const runtimeTemplatesDir = path.resolve("src", "agents", "templates"); + const content = await fs.readFile(path.join(runtimeTemplatesDir, "HEARTBEAT.md"), "utf-8"); + + expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(true); }); }); diff --git a/src/agents/workspace-templates.ts b/src/agents/workspace-templates.ts index 11d733fa92c3..79ff5a2784e8 100644 --- a/src/agents/workspace-templates.ts +++ b/src/agents/workspace-templates.ts @@ -4,6 +4,10 @@ import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; import { pathExists } from "../utils.js"; const FALLBACK_TEMPLATE_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../src/agents/templates", +); +const FALLBACK_DOCS_TEMPLATE_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../docs/reference/templates", ); @@ -29,11 +33,12 @@ export async function resolveWorkspaceTemplateDir(opts?: { const cwd = opts?.cwd ?? process.cwd(); const packageRoot = await resolveOpenClawPackageRoot({ moduleUrl, argv1, cwd }); - const candidates = [ - packageRoot ? path.join(packageRoot, "docs", "reference", "templates") : null, - cwd ? path.resolve(cwd, "docs", "reference", "templates") : null, - FALLBACK_TEMPLATE_DIR, - ].filter(Boolean) as string[]; + const candidates = buildTemplateDirCandidates({ + packageRoot, + cwd, + relativeDir: path.join("src", "agents", "templates"), + fallbackDir: FALLBACK_TEMPLATE_DIR, + }); for (const candidate of candidates) { if (await pathExists(candidate)) { @@ -57,3 +62,50 @@ export function resetWorkspaceTemplateDirCache() { cachedTemplateDir = undefined; resolvingTemplateDir = undefined; } + +function buildTemplateDirCandidates(params: { + packageRoot?: string | null; + cwd?: string; + relativeDir: string; + fallbackDir: string; +}): string[] { + return [ + params.packageRoot ? path.join(params.packageRoot, params.relativeDir) : null, + params.cwd ? path.resolve(params.cwd, params.relativeDir) : null, + params.fallbackDir, + ].filter(Boolean) as string[]; +} + +async function resolveExistingTemplateDirs(candidates: readonly string[]): Promise { + const dirs: string[] = []; + for (const candidate of candidates) { + if (dirs.includes(candidate)) { + continue; + } + if (await pathExists(candidate)) { + dirs.push(candidate); + } + } + return dirs; +} + +export async function resolveWorkspaceTemplateSearchDirs(opts?: { + cwd?: string; + argv1?: string; + moduleUrl?: string; +}): Promise { + const moduleUrl = opts?.moduleUrl ?? import.meta.url; + const argv1 = opts?.argv1 ?? process.argv[1]; + const cwd = opts?.cwd ?? process.cwd(); + + const packageRoot = await resolveOpenClawPackageRoot({ moduleUrl, argv1, cwd }); + const primary = await resolveWorkspaceTemplateDir(opts); + const docsCandidates = buildTemplateDirCandidates({ + packageRoot, + cwd, + relativeDir: path.join("docs", "reference", "templates"), + fallbackDir: FALLBACK_DOCS_TEMPLATE_DIR, + }); + const docsDirs = await resolveExistingTemplateDirs(docsCandidates); + return [primary, ...docsDirs.filter((candidate) => candidate !== primary)]; +} diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 2e55f7516881..d26c3b95187d 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -342,13 +342,13 @@ describe("ensureAgentWorkspace", () => { await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false); }); - it("writes the current fenced HEARTBEAT template body into new workspaces", async () => { + it("writes the clean HEARTBEAT runtime template into new workspaces", async () => { const tempDir = await makeTempWorkspace("openclaw-workspace-"); await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }); const heartbeat = await fs.readFile(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME), "utf-8"); - expect(heartbeat).toContain("```markdown"); + expect(heartbeat).not.toContain("```"); expect(heartbeat).toContain( "# Keep this file empty (or with only comments) to skip heartbeat API calls.", ); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index eda3634e2a65..780dd4d7903d 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -13,7 +13,10 @@ import { isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.j import { readStringValue } from "../shared/string-coerce.js"; import { resolveUserPath } from "../utils.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace-default.js"; -import { resolveWorkspaceTemplateDir } from "./workspace-templates.js"; +import { + resolveWorkspaceTemplateDir, + resolveWorkspaceTemplateSearchDirs, +} from "./workspace-templates.js"; export { DEFAULT_AGENT_WORKSPACE_DIR, resolveDefaultAgentWorkspaceDir, @@ -108,16 +111,26 @@ async function loadTemplate(name: string): Promise { } const pending = (async () => { - const templateDir = await resolveWorkspaceTemplateDir(); - const templatePath = path.join(templateDir, name); - try { - const content = await fs.readFile(templatePath, "utf-8"); - return stripFrontMatter(content); - } catch { - throw new Error( - `Missing workspace template: ${name} (${templatePath}). Ensure docs/reference/templates are packaged.`, - ); + const templateDirs = + name === DEFAULT_HEARTBEAT_FILENAME + ? [await resolveWorkspaceTemplateDir()] + : await resolveWorkspaceTemplateSearchDirs(); + const triedPaths: string[] = []; + for (const templateDir of templateDirs) { + const templatePath = path.join(templateDir, name); + triedPaths.push(templatePath); + try { + const content = await fs.readFile(templatePath, "utf-8"); + return stripFrontMatter(content); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { + throw error; + } + } } + throw new Error( + `Missing workspace template: ${name} (${triedPaths.join(", ")}). Ensure workspace templates are packaged.`, + ); })(); workspaceTemplateCache.set(name, pending); diff --git a/src/cli/gateway-cli/dev.ts b/src/cli/gateway-cli/dev.ts index 0f2b3e7cd854..4113c1d2e50d 100644 --- a/src/cli/gateway-cli/dev.ts +++ b/src/cli/gateway-cli/dev.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveWorkspaceTemplateDir } from "../../agents/workspace-templates.js"; +import { resolveWorkspaceTemplateSearchDirs } from "../../agents/workspace-templates.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { handleReset } from "../../commands/onboard-helpers.js"; import { createConfigIO, replaceConfigFile } from "../../config/config.js"; @@ -16,19 +16,30 @@ const DEV_AGENT_WORKSPACE_SUFFIX = "dev"; async function loadDevTemplate(name: string, fallback: string): Promise { try { - const templateDir = await resolveWorkspaceTemplateDir(); - const raw = await fs.promises.readFile(path.join(templateDir, name), "utf-8"); - if (!raw.startsWith("---")) { - return raw; + const templateDirs = await resolveWorkspaceTemplateSearchDirs(); + for (const templateDir of templateDirs) { + let raw: string; + try { + raw = await fs.promises.readFile(path.join(templateDir, name), "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { + continue; + } + throw error; + } + if (!raw.startsWith("---")) { + return raw; + } + const endIndex = raw.indexOf("\n---", 3); + if (endIndex === -1) { + return raw; + } + return raw.slice(endIndex + "\n---".length).replace(/^\s+/, ""); } - const endIndex = raw.indexOf("\n---", 3); - if (endIndex === -1) { - return raw; - } - return raw.slice(endIndex + "\n---".length).replace(/^\s+/, ""); } catch { return fallback; } + return fallback; } const resolveDevWorkspaceDir = (env: NodeJS.ProcessEnv = process.env): string => { diff --git a/src/commands/doctor-heartbeat-template-repair.test.ts b/src/commands/doctor-heartbeat-template-repair.test.ts new file mode 100644 index 000000000000..2462747edc9e --- /dev/null +++ b/src/commands/doctor-heartbeat-template-repair.test.ts @@ -0,0 +1,221 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + analyzeHeartbeatTemplateForRepair, + maybeRepairHeartbeatTemplate, +} from "./doctor-heartbeat-template-repair.js"; + +const mocks = vi.hoisted(() => ({ + note: vi.fn(), +})); + +vi.mock("../terminal/note.js", () => ({ + note: mocks.note, +})); + +const tempDirs: string[] = []; + +async function makeTempRoot(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-heartbeat-template-")); + tempDirs.push(root); + return root; +} + +async function makeWorkspaceWithHeartbeat(content: string): Promise<{ + workspaceDir: string; + heartbeatPath: string; +}> { + const workspaceDir = await makeTempRoot(); + const heartbeatPath = path.join(workspaceDir, "HEARTBEAT.md"); + await fs.writeFile(heartbeatPath, content, "utf-8"); + return { workspaceDir, heartbeatPath }; +} + +describe("heartbeat template repair", () => { + afterEach(async () => { + mocks.note.mockReset(); + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it("recognizes the original prose docs-backed template as repairable", () => { + const analysis = analyzeHeartbeatTemplateForRepair(`# HEARTBEAT.md + +Keep this file empty unless you want a tiny checklist. Keep it small. +`); + + expect(analysis.status).toBe("dirty-template"); + }); + + it("keeps original prose templates with user tasks unchanged", async () => { + const { workspaceDir, heartbeatPath } = await makeWorkspaceWithHeartbeat(`# HEARTBEAT.md + +Keep this file empty unless you want a tiny checklist. Keep it small. + +- Check email +`); + + await maybeRepairHeartbeatTemplate({ + cfg: { agents: { defaults: { workspace: workspaceDir } } }, + shouldRepair: true, + }); + + await expect(fs.readFile(heartbeatPath, "utf-8")).resolves.toContain("- Check email"); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("custom or unrecognized content"), + "Heartbeat template", + ); + }); + + it("recognizes the docs-backed heading plus fenced template as repairable", () => { + const analysis = analyzeHeartbeatTemplateForRepair(`# HEARTBEAT.md Template + +\`\`\`markdown +# Keep this file empty (or with only comments) to skip heartbeat API calls. + +# Add tasks below when you want the agent to check something periodically. +\`\`\` +`); + + expect(analysis.status).toBe("dirty-template"); + }); + + it("recognizes the fenced docs-backed template as repairable", () => { + const analysis = analyzeHeartbeatTemplateForRepair(`\`\`\`markdown +# Keep this file empty (or with only comments) to skip heartbeat API calls. + +# Add tasks below when you want the agent to check something periodically. +\`\`\` +`); + + expect(analysis.status).toBe("dirty-template"); + }); + + it("recognizes the original docs-backed template as repairable", () => { + const analysis = analyzeHeartbeatTemplateForRepair(`\`\`\`markdown +# Keep this file empty (or with only comments) to skip heartbeat API calls. + +# Add tasks below when you want the agent to check something periodically. +\`\`\` + +## Related + +- [Heartbeat config](/gateway/config-agents) +`); + + expect(analysis.status).toBe("dirty-template"); + }); + + it("recognizes the current docs page boilerplate template as repairable", () => { + const analysis = analyzeHeartbeatTemplateForRepair(`# HEARTBEAT.md template + +\`HEARTBEAT.md\` lives in the agent workspace. Keep the file empty, or with only Markdown comments and headings, when you want OpenClaw to skip heartbeat model calls. + +The default runtime template is: + +\`\`\`markdown +# Keep this file empty (or with only comments) to skip heartbeat API calls. + +# Add tasks below when you want the agent to check something periodically. +\`\`\` + +Add short tasks below the comments only when you want the agent to check something periodically. Keep heartbeat instructions small because they are read during recurring wakes. + +## Related + +- [Heartbeat config](/gateway/config-agents) +`); + + expect(analysis.status).toBe("dirty-template"); + }); + + it("ignores user-authored fenced content without the old template body", () => { + const analysis = analyzeHeartbeatTemplateForRepair(`tasks: + - name: status + prompt: | + \`\`\`yaml + ok: true + \`\`\` +`); + + expect(analysis.status).toBe("clean"); + }); + + it("keeps dirty templates with user tasks unchanged", async () => { + const { workspaceDir, heartbeatPath } = await makeWorkspaceWithHeartbeat(`\`\`\`markdown +# Keep this file empty (or with only comments) to skip heartbeat API calls. + +# Add tasks below when you want the agent to check something periodically. +\`\`\` + +- Check email +`); + + await maybeRepairHeartbeatTemplate({ + cfg: { agents: { defaults: { workspace: workspaceDir } } }, + shouldRepair: true, + }); + + await expect(fs.readFile(heartbeatPath, "utf-8")).resolves.toContain("- Check email"); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("custom or unrecognized content"), + "Heartbeat template", + ); + }); + + it("keeps unrecognized dirty template shapes unchanged", async () => { + const content = `# HEARTBEAT.md Template + +\`\`\`markdown +# Add tasks below when you want the agent to check something periodically. + +# Keep this file empty (or with only comments) to skip heartbeat API calls. +\`\`\` +`; + const { workspaceDir, heartbeatPath } = await makeWorkspaceWithHeartbeat(content); + + await maybeRepairHeartbeatTemplate({ + cfg: { agents: { defaults: { workspace: workspaceDir } } }, + shouldRepair: true, + }); + + await expect(fs.readFile(heartbeatPath, "utf-8")).resolves.toBe(content); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("custom or unrecognized content"), + "Heartbeat template", + ); + }); + + it("rewrites pure dirty templates to the clean runtime template", async () => { + const { workspaceDir, heartbeatPath } = await makeWorkspaceWithHeartbeat(`\`\`\`markdown +# Keep this file empty (or with only comments) to skip heartbeat API calls. + +# Add tasks below when you want the agent to check something periodically. +\`\`\` + +## Related + +- [Heartbeat config](/gateway/config-agents) +`); + + await maybeRepairHeartbeatTemplate({ + cfg: { agents: { defaults: { workspace: workspaceDir } } }, + shouldRepair: true, + }); + + await expect(fs.readFile(heartbeatPath, "utf-8")).resolves.toBe( + `${[ + "# Keep this file empty (or with only comments) to skip heartbeat API calls.", + "", + "# Add tasks below when you want the agent to check something periodically.", + ].join("\n")}\n`, + ); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("clean heartbeat template"), + "Doctor changes", + ); + }); +}); diff --git a/src/commands/doctor-heartbeat-template-repair.ts b/src/commands/doctor-heartbeat-template-repair.ts new file mode 100644 index 000000000000..c1ba30f598dd --- /dev/null +++ b/src/commands/doctor-heartbeat-template-repair.ts @@ -0,0 +1,185 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveWorkspaceTemplateDir } from "../agents/workspace-templates.js"; +import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { writeTextAtomic } from "../infra/json-files.js"; +import { note } from "../terminal/note.js"; +import { shortenHomePath } from "../utils.js"; + +const LEGACY_HEARTBEAT_PROSE_TEMPLATE = [ + "# HEARTBEAT.md", + "Keep this file empty unless you want a tiny checklist. Keep it small.", +] as const; + +const LEGACY_HEARTBEAT_HEADING_FENCED_TEMPLATE = [ + "# HEARTBEAT.md Template", + "```markdown", + "# Keep this file empty (or with only comments) to skip heartbeat API calls.", + "# Add tasks below when you want the agent to check something periodically.", + "```", +] as const; + +const LEGACY_HEARTBEAT_FENCED_TEMPLATE = [ + "```markdown", + "# Keep this file empty (or with only comments) to skip heartbeat API calls.", + "# Add tasks below when you want the agent to check something periodically.", + "```", +] as const; + +const LEGACY_HEARTBEAT_FENCED_RELATED_TEMPLATE = [ + "```markdown", + "# Keep this file empty (or with only comments) to skip heartbeat API calls.", + "# Add tasks below when you want the agent to check something periodically.", + "```", + "## Related", + "- [Heartbeat config](/gateway/config-agents)", +] as const; + +const DOCS_HEARTBEAT_TEMPLATE_PAGE_AS_TEMPLATE = [ + "# HEARTBEAT.md template", + "`HEARTBEAT.md` lives in the agent workspace. Keep the file empty, or with only Markdown comments and headings, when you want OpenClaw to skip heartbeat model calls.", + "The default runtime template is:", + "```markdown", + "# Keep this file empty (or with only comments) to skip heartbeat API calls.", + "# Add tasks below when you want the agent to check something periodically.", + "```", + "Add short tasks below the comments only when you want the agent to check something periodically. Keep heartbeat instructions small because they are read during recurring wakes.", + "## Related", + "- [Heartbeat config](/gateway/config-agents)", +] as const; + +const HEARTBEAT_DEFAULT_BODY_LINES = [ + "# Keep this file empty (or with only comments) to skip heartbeat API calls.", + "# Add tasks below when you want the agent to check something periodically.", +] as const; + +const DIRTY_HEARTBEAT_DOC_WRAPPER_LINES = new Set([ + "```markdown", + "# HEARTBEAT.md Template", + "# HEARTBEAT.md template", + "- [Heartbeat config](/gateway/config-agents)", +]); + +const KNOWN_DIRTY_HEARTBEAT_TEMPLATE_LINES = new Set([ + "```markdown", + "```", + "# HEARTBEAT.md Template", + "# HEARTBEAT.md template", + "`HEARTBEAT.md` lives in the agent workspace. Keep the file empty, or with only Markdown comments and headings, when you want OpenClaw to skip heartbeat model calls.", + "The default runtime template is:", + "Add short tasks below the comments only when you want the agent to check something periodically. Keep heartbeat instructions small because they are read during recurring wakes.", + ...LEGACY_HEARTBEAT_PROSE_TEMPLATE, + "# Keep this file empty (or with only comments) to skip heartbeat API calls.", + "# Add tasks below when you want the agent to check something periodically.", + "## Related", + "- [Heartbeat config](/gateway/config-agents)", +]); + +const KNOWN_REPAIRABLE_DIRTY_HEARTBEAT_TEMPLATES = [ + LEGACY_HEARTBEAT_PROSE_TEMPLATE, + LEGACY_HEARTBEAT_HEADING_FENCED_TEMPLATE, + LEGACY_HEARTBEAT_FENCED_TEMPLATE, + LEGACY_HEARTBEAT_FENCED_RELATED_TEMPLATE, + DOCS_HEARTBEAT_TEMPLATE_PAGE_AS_TEMPLATE, +] as const; + +export type HeartbeatTemplateRepairAnalysis = + | { status: "clean" } + | { status: "dirty-template" } + | { status: "dirty-template-with-custom-content"; customLines: string[] }; + +function linesEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((line, index) => line === right[index]); +} + +export function analyzeHeartbeatTemplateForRepair( + content: string, +): HeartbeatTemplateRepairAnalysis { + const lines = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (KNOWN_REPAIRABLE_DIRTY_HEARTBEAT_TEMPLATES.some((template) => linesEqual(lines, template))) { + return { status: "dirty-template" }; + } + + const hasDefaultTemplateBody = HEARTBEAT_DEFAULT_BODY_LINES.every((line) => lines.includes(line)); + const hasDirtyDocWrapper = lines.some((line) => DIRTY_HEARTBEAT_DOC_WRAPPER_LINES.has(line)); + const hasLegacyProseTemplate = LEGACY_HEARTBEAT_PROSE_TEMPLATE.every((line) => + lines.includes(line), + ); + if ((!hasDefaultTemplateBody || !hasDirtyDocWrapper) && !hasLegacyProseTemplate) { + return { status: "clean" }; + } + const customLines = lines.filter((line) => !KNOWN_DIRTY_HEARTBEAT_TEMPLATE_LINES.has(line)); + return { status: "dirty-template-with-custom-content", customLines }; +} + +async function readCleanHeartbeatTemplate(): Promise { + const templateDir = await resolveWorkspaceTemplateDir(); + const templatePath = path.join(templateDir, DEFAULT_HEARTBEAT_FILENAME); + return await fs.readFile(templatePath, "utf-8"); +} + +export async function maybeRepairHeartbeatTemplate(params: { + cfg: OpenClawConfig; + shouldRepair: boolean; +}): Promise { + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); + const heartbeatPath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME); + let content: string; + try { + content = await fs.readFile(heartbeatPath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { + return; + } + note( + `Could not inspect ${shortenHomePath(heartbeatPath)}: ${formatErrorMessage(error)}`, + "Heartbeat template", + ); + return; + } + + const analysis = analyzeHeartbeatTemplateForRepair(content); + if (analysis.status === "clean") { + return; + } + if (analysis.status === "dirty-template-with-custom-content") { + note( + [ + `${shortenHomePath(heartbeatPath)} contains an older heartbeat template wrapper plus custom or unrecognized content.`, + "Doctor left it unchanged so it does not delete user tasks. Remove the fenced template and Related lines manually if they are not intentional.", + ].join("\n"), + "Heartbeat template", + ); + return; + } + if (!params.shouldRepair) { + note( + [ + `${shortenHomePath(heartbeatPath)} contains an older heartbeat documentation template.`, + 'Run "openclaw doctor --fix" to replace it with the clean heartbeat template.', + ].join("\n"), + "Heartbeat template", + ); + return; + } + + try { + const cleanTemplate = await readCleanHeartbeatTemplate(); + await writeTextAtomic(heartbeatPath, cleanTemplate, { mode: 0o600 }); + note( + `Replaced ${shortenHomePath(heartbeatPath)} with the clean heartbeat template.`, + "Doctor changes", + ); + } catch (error) { + note( + `Could not repair ${shortenHomePath(heartbeatPath)}: ${formatErrorMessage(error)}`, + "Heartbeat template", + ); + } +} diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 313b92f4f329..6005688c28c5 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -298,6 +298,15 @@ describe("doctor health contributions", () => { expect(mocks.loadModelCatalog).toHaveBeenCalledWith({ config: cfg, readOnly: true }); }); + it("repairs heartbeat templates before final config writes", () => { + const ids = resolveDoctorHealthContributions().map((entry) => entry.id); + + expect(ids.indexOf("doctor:heartbeat-template-repair")).toBeGreaterThan(-1); + expect(ids.indexOf("doctor:heartbeat-template-repair")).toBeLessThan( + ids.indexOf("doctor:write-config"), + ); + }); + it("runs structured repairs before legacy skill repairs and config writes", () => { const ids = resolveDoctorHealthContributions().map((entry) => entry.id); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 95dc0a169e9f..75831da4ae14 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -658,6 +658,15 @@ async function runBootstrapSizeHealth(ctx: DoctorHealthFlowContext): Promise { + const { maybeRepairHeartbeatTemplate } = + await import("../commands/doctor-heartbeat-template-repair.js"); + await maybeRepairHeartbeatTemplate({ + cfg: ctx.cfg, + shouldRepair: ctx.prompter.shouldRepair, + }); +} + async function runShellCompletionHealth(ctx: DoctorHealthFlowContext): Promise { const { doctorShellCompletion } = await import("../commands/doctor-completion.js"); await doctorShellCompletion(ctx.runtime, ctx.prompter, { @@ -1028,6 +1037,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { healthCheckIds: ["core/doctor/bootstrap-size"], run: runBootstrapSizeHealth, }), + createDoctorHealthContribution({ + id: "doctor:heartbeat-template-repair", + label: "Heartbeat template repair", + run: runHeartbeatTemplateRepairHealth, + }), createDoctorHealthContribution({ id: "doctor:shell-completion", label: "Shell completion", diff --git a/src/flows/doctor-health-conversion-plan.ts b/src/flows/doctor-health-conversion-plan.ts index 6896659048f1..8c45f1d943f6 100644 --- a/src/flows/doctor-health-conversion-plan.ts +++ b/src/flows/doctor-health-conversion-plan.ts @@ -209,6 +209,12 @@ export const doctorHealthConversionRules = [ target: ["core/doctor/bootstrap-size"], rule: "Return oversized bootstrap files as path findings.", }, + { + contributionId: "doctor:heartbeat-template-repair", + conversion: "repair-backed-detect", + target: ["core/doctor/heartbeat-template-repair"], + rule: "Detect legacy docs-wrapped heartbeat templates; repair only pure template wrappers and preserve user-authored heartbeat content.", + }, { contributionId: "doctor:shell-completion", conversion: "interactive-maintenance", diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 4ed8925d479d..0834f6182e2b 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -32,6 +32,21 @@ const REQUIRED_PACKED_PATHS = [ ...WORKSPACE_TEMPLATE_PACK_PATHS, ] as const; +describe("workspace template package paths", () => { + it("keeps the runtime heartbeat template in the npm pack guard", () => { + expect(WORKSPACE_TEMPLATE_PACK_PATHS).toContain("src/agents/templates/HEARTBEAT.md"); + expect(WORKSPACE_TEMPLATE_PACK_PATHS).not.toContain("docs/reference/templates/HEARTBEAT.md"); + }); + + it("keeps runtime heartbeat templates allowlisted in package.json", () => { + const packageJson = JSON.parse(readFileSync("package.json", "utf-8")) as { + files?: unknown; + }; + + expect(packageJson.files).toContain("src/agents/templates/"); + }); +}); + describe("parseReleaseVersion", () => { it("parses stable CalVer releases", () => { expect(parseReleaseVersion("2026.3.10")).toStrictEqual({