refactor(heartbeat): remove legacy HEARTBEAT.md runtime fallback and template repair (#113131)

The database scratch migration (3e2b3ea4) left two named upgrade bridges:
the revision-0 read-only HEARTBEAT.md fallback in the heartbeat runner and
the doctor heartbeat-template repair contribution. This deletes both; the
doctor scratch migration remains the sole upgrade path.

Merge gate: hold until the next stable release containing 3e2b3ea4 has
shipped and completed its upgrade window.
This commit is contained in:
Peter Steinberger
2026-07-24 07:11:19 -07:00
committed by GitHub
parent 7dfb660d1f
commit 07e3855b46
11 changed files with 24 additions and 825 deletions

View File

@@ -382,7 +382,7 @@ Writes are compare-and-swap guarded: pass `--expected-revision <n>` to fail inst
The agent can also update its own scratch: during a heartbeat turn, `heartbeat_respond` accepts an optional `scratch` string that fully replaces the monitor's scratch for future heartbeats.
<Note>
**Migrating from HEARTBEAT.md or config-only cadence?** Run `openclaw doctor --fix`. Doctor first creates or updates the system-owned monitor rows from `agents.*.heartbeat`, then imports each agent's workspace `HEARTBEAT.md` into the monitor's scratch, converts any valid legacy `tasks:` entries into cron jobs, archives the original under the state directory (`backups/heartbeat-migration/`), and removes the file. For one stable upgrade window, an unmigrated legacy file remains a read-only fallback when no scratch revision exists, with a Gateway warning directing you to Doctor; new workspaces and completed migrations use database scratch only.
**Migrating from HEARTBEAT.md or config-only cadence?** Run `openclaw doctor --fix`. Doctor first creates or updates the system-owned monitor rows from `agents.*.heartbeat`, then imports each agent's workspace `HEARTBEAT.md` into the monitor's scratch, converts any valid legacy `tasks:` entries into cron jobs, archives the original under the state directory (`backups/heartbeat-migration/`), and removes the file. Runtime heartbeat instructions come from database scratch only; the runtime never reads `HEARTBEAT.md`.
</Note>
If scratch exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls. That skip is reported as `reason=empty-heartbeat-file`. If no scratch exists, the heartbeat still runs and the model decides what to do.

View File

@@ -22,7 +22,7 @@ async function loadWorkspaceTemplateResolvers() {
return import("./workspace-templates.js");
}
describe("resolveWorkspaceTemplateDir", () => {
describe("resolveWorkspaceTemplateSearchDirs", () => {
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
@@ -30,7 +30,7 @@ describe("resolveWorkspaceTemplateDir", () => {
});
it("resolves templates from package root when module url is dist-rooted", async () => {
const { resolveWorkspaceTemplateDir } = await loadWorkspaceTemplateResolvers();
const { resolveWorkspaceTemplateSearchDirs } = await loadWorkspaceTemplateResolvers();
const root = await makeTempRoot();
await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }));
@@ -42,12 +42,13 @@ describe("resolveWorkspaceTemplateDir", () => {
await fs.mkdir(distDir, { recursive: true });
const moduleUrl = pathToFileURL(path.join(distDir, "model-selection.mjs")).toString();
const resolved = await resolveWorkspaceTemplateDir({ cwd: distDir, moduleUrl });
// The primary template dir is the first search root of the public resolver.
const [resolved] = await resolveWorkspaceTemplateSearchDirs({ cwd: distDir, moduleUrl });
expect(resolved).toBe(templatesDir);
});
it("falls back to package-root runtime path when templates directory is missing", async () => {
const { resolveWorkspaceTemplateDir } = await loadWorkspaceTemplateResolvers();
const { resolveWorkspaceTemplateSearchDirs } = await loadWorkspaceTemplateResolvers();
const root = await makeTempRoot();
await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }));
@@ -55,7 +56,7 @@ describe("resolveWorkspaceTemplateDir", () => {
await fs.mkdir(distDir, { recursive: true });
const moduleUrl = pathToFileURL(path.join(distDir, "model-selection.mjs")).toString();
const resolved = await resolveWorkspaceTemplateDir({ cwd: distDir, moduleUrl });
const [resolved = ""] = await resolveWorkspaceTemplateSearchDirs({ cwd: distDir, moduleUrl });
expect(path.normalize(resolved)).toBe(path.resolve("src", "agents", "templates"));
});

View File

@@ -21,7 +21,7 @@ let cachedTemplateDir: string | undefined;
let resolvingTemplateDir: Promise<string> | undefined;
/** Resolves the primary workspace-template directory from package, cwd, or fallback paths. */
export async function resolveWorkspaceTemplateDir(opts?: {
async function resolveWorkspaceTemplateDir(opts?: {
cwd?: string;
argv1?: string;
moduleUrl?: string;

View File

@@ -574,16 +574,16 @@ export async function maybeMigrateHeartbeatFilesToScratch(params: {
// this run's scratch imports must revert too — otherwise those agents keep
// serving the imported copy and ignore later edits to the restored file.
// A monitor that had no row before must return to no-row (not a tombstone),
// or the runner's legacy fallback and future migrations stay suppressed.
// or a future migration retry treats the rolled-back import as explicitly unset.
const rollbackCommitted = () => {
for (const commit of committedThisRun.toReversed()) {
if (!commit.previous) {
// Revision-guarded atomic delete restores the pre-migration no-row
// state so the runner's legacy fallback stays available. Accepted
// state so a future migration retry can import it. Accepted
// tradeoff: this resets the revision counter to 0, so a writer still
// holding a pre-migration expectedRevision:0 token could CAS through
// after the rollback; that requires a third concurrent writer racing
// doctor and is preferred over permanently disabling the fallback.
// doctor and is preferred over permanently blocking migration.
const deleted = deleteCronJobScratch(
storePath,
commit.monitor.id,

View File

@@ -1,331 +0,0 @@
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 {
collectHeartbeatTemplateHealthFindings,
maybeRepairHeartbeatTemplate,
} from "./doctor-heartbeat-template-repair.js";
const mocks = vi.hoisted(() => ({
note: vi.fn(),
}));
vi.mock("../../packages/terminal-core/src/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 };
}
async function collectFindingsForContent(content: string) {
return collectHeartbeatTemplateHealthFindings(
{ agents: { defaults: { workspace: "/tmp/openclaw-heartbeat-template-test" } } },
{ readFile: async () => content },
);
}
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", async () => {
const findings = await collectFindingsForContent(`# HEARTBEAT.md
Keep this file empty unless you want a tiny checklist. Keep it small.
`);
expect(findings).toEqual([expect.objectContaining({ requirement: "legacy-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", async () => {
const findings = await collectFindingsForContent(`# 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(findings).toEqual([expect.objectContaining({ requirement: "legacy-template" })]);
});
it("recognizes the fenced docs-backed template as repairable", async () => {
const findings = await collectFindingsForContent(`\`\`\`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(findings).toEqual([expect.objectContaining({ requirement: "legacy-template" })]);
});
it("recognizes the original docs-backed template as repairable", async () => {
const findings = await collectFindingsForContent(`\`\`\`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(findings).toEqual([expect.objectContaining({ requirement: "legacy-template" })]);
});
it("recognizes the current docs page boilerplate template as repairable", async () => {
const findings = await collectFindingsForContent(`# 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(findings).toEqual([expect.objectContaining({ requirement: "legacy-template" })]);
});
it("ignores user-authored fenced content without the old template body", async () => {
const findings = await collectFindingsForContent(`tasks:
- name: status
prompt: |
\`\`\`yaml
ok: true
\`\`\`
`);
expect(findings).toEqual([]);
});
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("collects a finding for pure dirty templates", 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.
\`\`\`
`);
const findings = await collectHeartbeatTemplateHealthFindings({
agents: { defaults: { workspace: workspaceDir } },
});
expect(findings).toEqual([
expect.objectContaining({
checkId: "core/doctor/heartbeat-template",
severity: "warning",
path: heartbeatPath,
requirement: "legacy-template",
fixHint: expect.stringContaining("openclaw doctor --fix"),
}),
]);
});
it("collects a manual finding when dirty templates include user content", 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
`);
const findings = await collectHeartbeatTemplateHealthFindings({
agents: { defaults: { workspace: workspaceDir } },
});
expect(findings).toEqual([
expect.objectContaining({
checkId: "core/doctor/heartbeat-template",
severity: "warning",
path: heartbeatPath,
requirement: "legacy-template-with-custom-content",
fixHint: expect.stringContaining("Remove the fenced template"),
}),
]);
});
it("returns no findings for clean templates or missing heartbeat files", async () => {
const { workspaceDir } = await makeWorkspaceWithHeartbeat(`# Keep this file empty.
`);
const missingWorkspaceDir = await makeTempRoot();
await expect(
collectHeartbeatTemplateHealthFindings({
agents: { defaults: { workspace: workspaceDir } },
}),
).resolves.toEqual([]);
await expect(
collectHeartbeatTemplateHealthFindings({
agents: { defaults: { workspace: missingWorkspaceDir } },
}),
).resolves.toEqual([]);
});
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,
});
const cleanTemplate = await fs.readFile(
path.resolve("src", "agents", "templates", "HEARTBEAT.md"),
"utf-8",
);
await expect(fs.readFile(heartbeatPath, "utf-8")).resolves.toBe(cleanTemplate);
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("clean heartbeat template"),
"Doctor changes",
);
});
it("labels and repairs only the secondary agent with a stale template", async () => {
const main = await makeWorkspaceWithHeartbeat("# Main heartbeat task\n");
const secondary = 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.
\`\`\`
`);
const cfg = {
agents: {
list: [
{ id: "main", default: true, workspace: main.workspaceDir },
{ id: "secondary", workspace: secondary.workspaceDir },
],
},
};
const findings = await collectHeartbeatTemplateHealthFindings(cfg);
expect(findings).toEqual([
expect.objectContaining({
message: expect.stringContaining('Agent "secondary"'),
path: secondary.heartbeatPath,
target: "secondary",
}),
]);
await maybeRepairHeartbeatTemplate({ cfg, shouldRepair: true });
await expect(fs.readFile(main.heartbeatPath, "utf-8")).resolves.toBe("# Main heartbeat task\n");
const cleanTemplate = await fs.readFile(
path.resolve("src", "agents", "templates", "HEARTBEAT.md"),
"utf-8",
);
await expect(fs.readFile(secondary.heartbeatPath, "utf-8")).resolves.toBe(cleanTemplate);
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining('Agent "secondary"'),
"Doctor changes",
);
});
});

View File

@@ -1,272 +0,0 @@
/** Doctor repair for HEARTBEAT.md files that accidentally contain docs template wrappers. */
import fs from "node:fs/promises";
import path from "node:path";
import { note } from "../../packages/terminal-core/src/note.js";
import { listAgentIds, resolveAgentWorkspaceDir } 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 type { HealthFinding } from "../flows/health-checks.js";
import { formatErrorMessage } from "../infra/errors.js";
import { writeTextAtomic } from "../infra/json-files.js";
import { shortenHomePath } from "../utils.js";
const HEARTBEAT_TEMPLATE_CHECK_ID = "core/doctor/heartbeat-template";
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;
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]);
}
/** Classifies heartbeat template content as clean, repairable, or risky because it has user text. */
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");
}
function heartbeatTemplateAnalysisToHealthFinding(
agentId: string,
labelAgent: boolean,
heartbeatPath: string,
analysis: Exclude<HeartbeatTemplateRepairAnalysis, { status: "clean" }>,
): HealthFinding {
if (analysis.status === "dirty-template-with-custom-content") {
return {
checkId: HEARTBEAT_TEMPLATE_CHECK_ID,
severity: "warning",
message: `${labelAgent ? `Agent "${agentId}": ` : ""}HEARTBEAT.md contains an older heartbeat template wrapper plus custom or unrecognized content.`,
path: heartbeatPath,
...(labelAgent ? { target: agentId } : {}),
requirement: "legacy-template-with-custom-content",
fixHint: "Remove the fenced template and Related lines manually if they are not intentional.",
};
}
return {
checkId: HEARTBEAT_TEMPLATE_CHECK_ID,
severity: "warning",
message: `${labelAgent ? `Agent "${agentId}": ` : ""}HEARTBEAT.md contains an older heartbeat documentation template.`,
path: heartbeatPath,
...(labelAgent ? { target: agentId } : {}),
requirement: "legacy-template",
fixHint: 'Run "openclaw doctor --fix" to replace it with the clean heartbeat template.',
};
}
/** Collects read-only structured findings for legacy HEARTBEAT.md template wrappers. */
export async function collectHeartbeatTemplateHealthFindings(
cfg: OpenClawConfig,
deps?: {
readFile?: (filePath: string) => Promise<string>;
},
): Promise<readonly HealthFinding[]> {
const agentIds = listAgentIds(cfg);
const labelAgents = agentIds.length > 1;
const targets = agentIds.map((agentId) => ({
agentId,
heartbeatPath: path.join(resolveAgentWorkspaceDir(cfg, agentId), DEFAULT_HEARTBEAT_FILENAME),
}));
const readFile = deps?.readFile ?? ((filePath: string) => fs.readFile(filePath, "utf-8"));
const findings: HealthFinding[] = [];
for (const { agentId, heartbeatPath } of targets) {
let content: string;
try {
content = await readFile(heartbeatPath);
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
continue;
}
findings.push({
checkId: HEARTBEAT_TEMPLATE_CHECK_ID,
severity: "warning",
message: `${labelAgents ? `Agent "${agentId}": ` : ""}Could not inspect HEARTBEAT.md: ${formatErrorMessage(error)}`,
path: heartbeatPath,
...(labelAgents ? { target: agentId } : {}),
requirement: "inspect-failed",
fixHint: "Check file permissions, then rerun doctor.",
});
continue;
}
const analysis = analyzeHeartbeatTemplateForRepair(content);
if (analysis.status !== "clean") {
findings.push(
heartbeatTemplateAnalysisToHealthFinding(agentId, labelAgents, heartbeatPath, analysis),
);
}
}
return findings;
}
/** Replaces known dirty heartbeat templates with the clean runtime template when repair is enabled. */
export async function maybeRepairHeartbeatTemplate(params: {
cfg: OpenClawConfig;
shouldRepair: boolean;
}): Promise<void> {
const agentIds = listAgentIds(params.cfg);
const labelAgents = agentIds.length > 1;
const targets = agentIds.map((agentId) => ({
agentId,
heartbeatPath: path.join(
resolveAgentWorkspaceDir(params.cfg, agentId),
DEFAULT_HEARTBEAT_FILENAME,
),
}));
for (const { agentId, heartbeatPath } of targets) {
const prefix = labelAgents ? `Agent "${agentId}": ` : "";
let content: string;
try {
content = await fs.readFile(heartbeatPath, "utf-8");
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
continue;
}
note(
`${prefix}Could not inspect ${shortenHomePath(heartbeatPath)}: ${formatErrorMessage(error)}`,
"Heartbeat template",
);
continue;
}
const analysis = analyzeHeartbeatTemplateForRepair(content);
if (analysis.status === "clean") {
continue;
}
if (analysis.status === "dirty-template-with-custom-content") {
note(
[
`${prefix}${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",
);
continue;
}
if (!params.shouldRepair) {
note(
[
`${prefix}${shortenHomePath(heartbeatPath)} contains an older heartbeat documentation template.`,
'Run "openclaw doctor --fix" to replace it with the clean heartbeat template.',
].join("\n"),
"Heartbeat template",
);
continue;
}
try {
const cleanTemplate = await readCleanHeartbeatTemplate();
await writeTextAtomic(heartbeatPath, cleanTemplate, { mode: 0o600 });
note(
`${prefix}Replaced ${shortenHomePath(heartbeatPath)} with the clean heartbeat template.`,
"Doctor changes",
);
} catch (error) {
note(
`${prefix}Could not repair ${shortenHomePath(heartbeatPath)}: ${formatErrorMessage(error)}`,
"Heartbeat template",
);
}
}
}

View File

@@ -149,10 +149,6 @@ vi.mock("../flows/doctor-startup-channel-maintenance.js", () => ({
maybeRunDoctorStartupChannelMaintenance: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./doctor-heartbeat-template-repair.js", () => ({
maybeRepairHeartbeatTemplate: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./doctor-heartbeat-cadence-migration.js", () => ({
collectHeartbeatCadenceMigrationFindings: vi.fn().mockResolvedValue([]),
maybeMigrateHeartbeatCadenceToCron: vi.fn().mockResolvedValue({ changes: [], warnings: [] }),

View File

@@ -103,8 +103,6 @@ const mocks = vi.hoisted(() => ({
collectWorkspaceBackupTip: vi.fn((): string | undefined => undefined),
shouldSuggestMemorySystem: vi.fn(async () => false),
collectDiskSpaceHealthFindings: vi.fn((): readonly HealthFinding[] => []),
collectHeartbeatTemplateHealthFindings: vi.fn(async () => [] as unknown[]),
maybeRepairHeartbeatTemplate: vi.fn().mockResolvedValue(undefined),
collectHeartbeatCadenceMigrationFindings: vi.fn(async () => [] as unknown[]),
maybeMigrateHeartbeatCadenceToCron: vi.fn().mockResolvedValue({ changes: [], warnings: [] }),
collectHeartbeatScratchMigrationFindings: vi.fn(async () => [] as unknown[]),
@@ -403,11 +401,6 @@ vi.mock("../commands/doctor-disk-space.js", () => ({
collectDiskSpaceHealthFindings: mocks.collectDiskSpaceHealthFindings,
}));
vi.mock("../commands/doctor-heartbeat-template-repair.js", () => ({
collectHeartbeatTemplateHealthFindings: mocks.collectHeartbeatTemplateHealthFindings,
maybeRepairHeartbeatTemplate: mocks.maybeRepairHeartbeatTemplate,
}));
vi.mock("../commands/doctor-heartbeat-cadence-migration.js", () => ({
collectHeartbeatCadenceMigrationFindings: mocks.collectHeartbeatCadenceMigrationFindings,
maybeMigrateHeartbeatCadenceToCron: mocks.maybeMigrateHeartbeatCadenceToCron,
@@ -684,10 +677,6 @@ describe("doctor health contributions", () => {
mocks.collectWorkspaceStatusHealthFindings.mockResolvedValue([]);
mocks.collectDiskSpaceHealthFindings.mockReset();
mocks.collectDiskSpaceHealthFindings.mockReturnValue([]);
mocks.collectHeartbeatTemplateHealthFindings.mockReset();
mocks.collectHeartbeatTemplateHealthFindings.mockResolvedValue([]);
mocks.maybeRepairHeartbeatTemplate.mockReset();
mocks.maybeRepairHeartbeatTemplate.mockResolvedValue(undefined);
mocks.collectHeartbeatCadenceMigrationFindings.mockReset();
mocks.collectHeartbeatCadenceMigrationFindings.mockResolvedValue([]);
mocks.maybeMigrateHeartbeatCadenceToCron.mockReset();
@@ -1337,15 +1326,6 @@ 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("materializes heartbeat cadence before scratch migration and final config writes", async () => {
const ids = resolveDoctorHealthContributions().map((entry) => entry.id);
const cadenceIndex = ids.indexOf("doctor:heartbeat-cadence-migration");
@@ -1421,47 +1401,6 @@ describe("doctor health contributions", () => {
expect(mocks.collectHeartbeatTaskMigrationFindings).toHaveBeenCalledWith(cfg, env);
});
it("keeps heartbeat template lint opt-in for default lint selection", async () => {
const contributionChecks = await resolveDoctorContributionHealthChecks();
const heartbeatTemplateCheck = contributionChecks.find(
(check) => check.id === "core/doctor/heartbeat-template",
);
expect(heartbeatTemplateCheck).toMatchObject({ defaultEnabled: false });
expect(heartbeatTemplateCheck).toBeDefined();
const ctx = {
cfg: { agents: { defaults: { workspace: "/tmp/openclaw-workspace" } } },
mode: "lint",
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
} as const;
const checks = [heartbeatTemplateCheck!];
await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({
checksRun: 0,
checksSkipped: 1,
});
expect(mocks.collectHeartbeatTemplateHealthFindings).not.toHaveBeenCalled();
mocks.collectHeartbeatTemplateHealthFindings.mockResolvedValueOnce([
{
checkId: "core/doctor/heartbeat-template",
severity: "warning",
message: "HEARTBEAT.md contains an older heartbeat documentation template.",
path: "/tmp/openclaw-workspace/HEARTBEAT.md",
requirement: "legacy-template",
},
]);
await expect(
runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/heartbeat-template"] }),
).resolves.toMatchObject({
checksRun: 1,
checksSkipped: 0,
findings: [expect.objectContaining({ checkId: "core/doctor/heartbeat-template" })],
});
expect(mocks.collectHeartbeatTemplateHealthFindings).toHaveBeenCalledWith(ctx.cfg);
});
it("exposes the Skill Workshop tool-policy check to doctor lint", async () => {
const contributionChecks = await resolveDoctorContributionHealthChecks();
const check = contributionChecks.find(
@@ -1859,7 +1798,6 @@ describe("doctor health contributions", () => {
expect(contributionIds).toContain("core/doctor/legacy-plugin-dependencies");
expect(contributionIds).toContain("core/doctor/stale-plugin-runtime-symlinks");
expect(contributionIds).toContain("core/doctor/disk-space");
expect(contributionIds).toContain("core/doctor/heartbeat-template");
expect(contributionIds).toContain("core/doctor/whatsapp-responsiveness");
expect(contributionIds).toContain("core/doctor/device-pairing");
expect(contributionIds).toContain("core/doctor/channel-plugin-blockers");

View File

@@ -965,15 +965,6 @@ 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 runHeartbeatCadenceMigrationHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeMigrateHeartbeatCadenceToCron } =
await import("../commands/doctor-heartbeat-cadence-migration.js");
@@ -2038,21 +2029,6 @@ function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
healthCheckIds: ["core/doctor/bootstrap-size"],
run: runBootstrapSizeHealth,
}),
createDoctorHealthContribution({
id: "doctor:heartbeat-template-repair",
label: "Heartbeat template repair",
healthChecks: {
id: "core/doctor/heartbeat-template",
description: "Legacy HEARTBEAT.md documentation templates are findings.",
defaultEnabled: false,
async detect(ctx) {
const { collectHeartbeatTemplateHealthFindings } =
await import("../commands/doctor-heartbeat-template-repair.js");
return await collectHeartbeatTemplateHealthFindings(ctx.cfg);
},
},
run: runHeartbeatTemplateRepairHealth,
}),
createDoctorHealthContribution({
id: "doctor:heartbeat-cadence-migration",
label: "Heartbeat cadence migration",

View File

@@ -1460,12 +1460,11 @@ describe("runHeartbeatOnce", () => {
type HeartbeatScratchState =
| "empty"
| "actionable"
| "legacy-comment-only"
| "fenced-empty"
| "fenced-actionable"
| "missing";
async function runHeartbeatFileScenario(params: {
async function runHeartbeatScratchScenario(params: {
fileState: HeartbeatScratchState;
source?: "notifications-event";
reason?: "interval" | "wake";
@@ -1482,13 +1481,8 @@ describe("runHeartbeatOnce", () => {
const scratchContent =
params.fileState === "empty"
? "# Heartbeat scratch\n\n## Tasks\n\n"
: params.fileState === "legacy-comment-only"
? `# Keep this empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.
`
: params.fileState === "fenced-empty"
? `# Heartbeat scratch template
: params.fileState === "fenced-empty"
? `# Heartbeat scratch template
\`\`\`markdown
# Keep this empty (or with only comments) to skip heartbeat API calls.
@@ -1496,16 +1490,16 @@ describe("runHeartbeatOnce", () => {
# Add tasks below when you want the agent to check something periodically.
\`\`\`
`
: params.fileState === "actionable"
? "# Heartbeat scratch\n\n- Check server logs\n- Review pending PRs\n"
: params.fileState === "fenced-actionable"
? `\`\`\`markdown
: params.fileState === "actionable"
? "# Heartbeat scratch\n\n- Check server logs\n- Review pending PRs\n"
: params.fileState === "fenced-actionable"
? `\`\`\`markdown
# Keep this empty when you want to skip.
- Check server logs
\`\`\`
`
: null;
: null;
const cfg: OpenClawConfig = {
agents: {
@@ -1555,7 +1549,7 @@ describe("runHeartbeatOnce", () => {
}
it("injects actionable monitor scratch without workspace file guidance", async () => {
const { res, replySpy, sendWhatsApp } = await runHeartbeatFileScenario({
const { res, replySpy, sendWhatsApp } = await runHeartbeatScratchScenario({
fileState: "actionable",
reason: "interval",
replyText: "Checked logs and PRs",
@@ -1573,35 +1567,6 @@ describe("runHeartbeatOnce", () => {
}
});
it("keeps legacy HEARTBEAT.md active until doctor migrates it", async () => {
const tmpDir = await createCaseDir("openclaw-hb-legacy-fallback");
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(
path.join(workspaceDir, "HEARTBEAT.md"),
"# Legacy instructions\n\n- Check the deployment\n",
"utf8",
);
const legacyCronStore = path.join(tmpDir, "legacy-cron", "jobs.json");
await seedHeartbeatScratchForTest({ content: null, storePath: legacyCronStore });
const cfg = {
agents: { defaults: { workspace: workspaceDir, heartbeat: { every: "5m" } } },
cron: { store: legacyCronStore },
session: { store: storePath },
} as unknown as OpenClawConfig;
await seedWhatsAppSession(storePath, resolveMainSessionKey(cfg));
const replySpy = vi.fn().mockResolvedValue({ text: "Checked deployment" });
const result = await runHeartbeatOnce({
cfg,
deps: createHeartbeatDeps(vi.fn(), { getReplyFromConfig: replySpy }),
});
expect(result.status).toBe("ran");
expect(replyBody(replySpy).Body).toContain("Check the deployment");
});
it("reads heartbeat scratch from a configured cron store partition", async () => {
const tmpDir = await createCaseDir("openclaw-hb-custom-store");
const storePath = path.join(tmpDir, "sessions.json");
@@ -1773,14 +1738,6 @@ tasks:
expectedSendCalls: 0,
expectedReplyCalls: 0,
},
{
name: "legacy comment-only template + interval skips",
fileState: "legacy-comment-only",
expectedStatus: "skipped",
expectedSkipReason: "empty-heartbeat-file",
expectedSendCalls: 0,
expectedReplyCalls: 0,
},
{
name: "fenced empty template + interval skips",
fileState: "fenced-empty",
@@ -1873,7 +1830,7 @@ tasks:
expectCronContext,
...scenario
} of cases) {
const { res, replySpy, sendWhatsApp } = await runHeartbeatFileScenario(scenario);
const { res, replySpy, sendWhatsApp } = await runHeartbeatScratchScenario(scenario);
try {
expect(res.status, name).toBe(expectedStatus);
if (res.status === "skipped") {

View File

@@ -1,8 +1,5 @@
// Runs heartbeat checks and emits status updates for configured agents.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { TextDecoder } from "node:util";
import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
import {
normalizeLowercaseStringOrEmpty,
@@ -13,18 +10,12 @@ import {
hasOutboundReplyContent,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import {
listAgentIds,
resolveAgentConfig,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { listAgentIds, resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js";
import { resolveEmbeddedSessionLane } from "../agents/embedded-agent-runner/lanes.js";
import { listActiveEmbeddedRunSessionKeys } from "../agents/embedded-agent-runner/run-state.js";
import { resolveModelRefFromString, type ModelRef } from "../agents/model-selection.js";
import { resolveEffectiveAgentRuntime } from "../agents/thinking-runtime.js";
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
import {
resolveHeartbeatReplyPayload,
resolveHeartbeatTerminalToolFailure,
@@ -116,7 +107,7 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { readStoredDeviceIdentityReadOnly } from "./device-identity-store.js";
import { loadOrCreateDeviceIdentity } from "./device-identity.js";
import { formatErrorMessage, hasErrnoCode } from "./errors.js";
import { formatErrorMessage } from "./errors.js";
import { resolveMainScopedEventSessionKey } from "./event-session-routing.js";
import {
createActiveHoursPredicate,
@@ -177,8 +168,6 @@ import {
resolveHeartbeatDeliveryTargetWithSessionRoute,
resolveHeartbeatSenderContext,
} from "./outbound/targets.js";
import { isPathInside } from "./path-guards.js";
import { readRegularFile } from "./regular-file.js";
import {
consumeSelectedSystemEventEntries,
peekSystemEventEntries,
@@ -199,42 +188,6 @@ export type HeartbeatDeps = OutboundSendDeps &
};
const log = createSubsystemLogger("gateway/heartbeat");
const LEGACY_HEARTBEAT_FILE_MAX_BYTES = 16 * 1024 * 1024;
const legacyHeartbeatFallbackWarnings = new Set<string>();
const legacyHeartbeatDecoder = new TextDecoder("utf-8", { fatal: true });
async function readLegacyHeartbeatFileForMigration(params: {
cfg: OpenClawConfig;
agentId: string;
}): Promise<string | undefined> {
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
const heartbeatPath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
try {
const workspaceRealPath = await fs.realpath(workspaceDir);
const sourceRealPath = await fs.realpath(heartbeatPath);
if (sourceRealPath !== workspaceRealPath && !isPathInside(workspaceRealPath, sourceRealPath)) {
throw new Error("HEARTBEAT.md symlink target escapes the agent workspace");
}
const file = await readRegularFile({
filePath: sourceRealPath,
maxBytes: LEGACY_HEARTBEAT_FILE_MAX_BYTES,
});
const content = legacyHeartbeatDecoder.decode(file.buffer);
if (!legacyHeartbeatFallbackWarnings.has(heartbeatPath)) {
legacyHeartbeatFallbackWarnings.add(heartbeatPath);
log.warn(
`heartbeat: using legacy ${DEFAULT_HEARTBEAT_FILENAME}; run openclaw doctor --fix to migrate it into cron scratch`,
);
}
return content;
} catch (error) {
if (hasErrnoCode(error, "ENOENT")) {
return undefined;
}
log.warn(`heartbeat: legacy file migration fallback failed: ${formatErrorMessage(error)}`);
return undefined;
}
}
const loadHeartbeatRunnerRuntime = createLazyRuntimeModule(
() => import("./heartbeat-runner.runtime.js"),
@@ -933,34 +886,15 @@ async function resolveHeartbeatPreflight(params: {
wakeFlags.isWakePayload ||
hasTaggedCronEvents;
let monitorScratch: ReturnType<typeof readHeartbeatMonitorScratch>;
let scratchReadOk = false;
try {
monitorScratch = readHeartbeatMonitorScratch(
resolveCronJobsStorePathFromConfig(params.cfg),
params.agentId,
);
scratchReadOk = true;
} catch (error) {
log.warn(`heartbeat: scratch read failed: ${formatErrorMessage(error)}`);
}
let heartbeatScratchContent = monitorScratch?.state.scratch?.content;
if (
!shouldBypassFileGates &&
// The legacy fallback needs a proven revision-0 state: a failed database
// read must not resurrect retired file instructions past a tombstone.
scratchReadOk &&
heartbeatScratchContent === undefined &&
(monitorScratch?.state.currentRevision ?? 0) === 0
) {
// Named upgrade bridge: tagged builds shipped HEARTBEAT.md as the only
// instruction store. Doctor owns the migration; this read-only fallback
// prevents silent loss until one full stable upgrade window has shipped,
// after which the fallback and legacy template repair can be deleted.
heartbeatScratchContent = await readLegacyHeartbeatFileForMigration({
cfg: params.cfg,
agentId: params.agentId,
});
}
const heartbeatScratchContent = monitorScratch?.state.scratch?.content;
const basePreflight = {
...wakeFlags,
session,