diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index c2d0202e28ea..61a307effa23 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -382,7 +382,7 @@ Writes are compare-and-swap guarded: pass `--expected-revision ` 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. -**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`. 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. diff --git a/src/agents/workspace-templates.test.ts b/src/agents/workspace-templates.test.ts index 7c3d2f490244..8e0570e2a397 100644 --- a/src/agents/workspace-templates.test.ts +++ b/src/agents/workspace-templates.test.ts @@ -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")); }); diff --git a/src/agents/workspace-templates.ts b/src/agents/workspace-templates.ts index ffad81a02827..17616413fc1a 100644 --- a/src/agents/workspace-templates.ts +++ b/src/agents/workspace-templates.ts @@ -21,7 +21,7 @@ let cachedTemplateDir: string | undefined; let resolvingTemplateDir: Promise | 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; diff --git a/src/commands/doctor-heartbeat-scratch-migration.ts b/src/commands/doctor-heartbeat-scratch-migration.ts index 80239a42ab99..e340e63ba196 100644 --- a/src/commands/doctor-heartbeat-scratch-migration.ts +++ b/src/commands/doctor-heartbeat-scratch-migration.ts @@ -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, diff --git a/src/commands/doctor-heartbeat-template-repair.test.ts b/src/commands/doctor-heartbeat-template-repair.test.ts deleted file mode 100644 index 4d14fd5123c9..000000000000 --- a/src/commands/doctor-heartbeat-template-repair.test.ts +++ /dev/null @@ -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 { - 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", - ); - }); -}); diff --git a/src/commands/doctor-heartbeat-template-repair.ts b/src/commands/doctor-heartbeat-template-repair.ts deleted file mode 100644 index cc3f952b715e..000000000000 --- a/src/commands/doctor-heartbeat-template-repair.ts +++ /dev/null @@ -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 { - 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, -): 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; - }, -): Promise { - 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 { - 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", - ); - } - } -} diff --git a/src/commands/doctor.fast-path-mocks.ts b/src/commands/doctor.fast-path-mocks.ts index f9ccb4ecf71f..84d4ccf3201e 100644 --- a/src/commands/doctor.fast-path-mocks.ts +++ b/src/commands/doctor.fast-path-mocks.ts @@ -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: [] }), diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 818d369351f2..659df5529264 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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"); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 5e46370d8566..1fe06d7a9608 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -965,15 +965,6 @@ 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 runHeartbeatCadenceMigrationHealth(ctx: DoctorHealthFlowContext): Promise { 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", diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 648dc6b3e47a..797b251c74f3 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -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") { diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 468feb735cb8..0ead2ed4090d 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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(); -const legacyHeartbeatDecoder = new TextDecoder("utf-8", { fatal: true }); - -async function readLegacyHeartbeatFileForMigration(params: { - cfg: OpenClawConfig; - agentId: string; -}): Promise { - 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; - 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,