mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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 heade34e85864c. - Required merge gates passed before the squash merge. Prepared head SHA:e34e85864cReview: 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:
@@ -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)
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
"!docs/images/**",
|
||||
"!docs/**/*.jpg",
|
||||
"!docs/**/*.png",
|
||||
"src/agents/templates/",
|
||||
"scripts/crabbox-wrapper.mjs",
|
||||
"patches/",
|
||||
"skills/",
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
3
src/agents/templates/HEARTBEAT.md
Normal file
3
src/agents/templates/HEARTBEAT.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
221
src/commands/doctor-heartbeat-template-repair.test.ts
Normal file
221
src/commands/doctor-heartbeat-template-repair.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
185
src/commands/doctor-heartbeat-template-repair.ts
Normal file
185
src/commands/doctor-heartbeat-template-repair.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user