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 e34e85864c.
- Required merge gates passed before the squash merge.

Prepared head SHA: e34e85864c
Review: https://github.com/openclaw/openclaw/pull/85416#issuecomment-4519851630

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
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>
This commit is contained in:
Mason Huang
2026-05-27 20:30:22 +08:00
committed by GitHub
parent 3e351b718e
commit 75221e0550
15 changed files with 608 additions and 32 deletions

View File

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

View File

@@ -116,6 +116,7 @@
"!docs/images/**",
"!docs/**/*.jpg",
"!docs/**/*.png",
"src/agents/templates/",
"scripts/crabbox-wrapper.mjs",
"patches/",
"skills/",

View File

@@ -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",
];

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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<string[]> {
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<string[]> {
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)];
}

View File

@@ -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.",
);

View File

@@ -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<string> {
}
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);

View File

@@ -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<string> {
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 => {

View File

@@ -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<string> {
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",
);
});
});

View File

@@ -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<string> {
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<void> {
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",
);
}
}

View File

@@ -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);

View File

@@ -658,6 +658,15 @@ async function runBootstrapSizeHealth(ctx: DoctorHealthFlowContext): Promise<voi
await noteBootstrapFileSize(ctx.cfg);
}
async function runHeartbeatTemplateRepairHealth(ctx: DoctorHealthFlowContext): Promise<void> {
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<void> {
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",

View File

@@ -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",

View File

@@ -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({