Skip to content

Commit e34e858

Browse files
hxy91819clawsweeper[bot]
authored andcommitted
fix(doctor): tighten heartbeat template repair
1 parent 0a4f4d4 commit e34e858

2 files changed

Lines changed: 140 additions & 18 deletions

File tree

src/commands/doctor-heartbeat-template-repair.test.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,49 @@ describe("heartbeat template repair", () => {
4141
);
4242
});
4343

44-
it("recognizes the fenced docs template as repairable", () => {
44+
it("recognizes the original prose docs-backed template as repairable", () => {
45+
const analysis = analyzeHeartbeatTemplateForRepair(`# HEARTBEAT.md
46+
47+
Keep this file empty unless you want a tiny checklist. Keep it small.
48+
`);
49+
50+
expect(analysis.status).toBe("dirty-template");
51+
});
52+
53+
it("keeps original prose templates with user tasks unchanged", async () => {
54+
const { workspaceDir, heartbeatPath } = await makeWorkspaceWithHeartbeat(`# HEARTBEAT.md
55+
56+
Keep this file empty unless you want a tiny checklist. Keep it small.
57+
58+
- Check email
59+
`);
60+
61+
await maybeRepairHeartbeatTemplate({
62+
cfg: { agents: { defaults: { workspace: workspaceDir } } },
63+
shouldRepair: true,
64+
});
65+
66+
await expect(fs.readFile(heartbeatPath, "utf-8")).resolves.toContain("- Check email");
67+
expect(mocks.note).toHaveBeenCalledWith(
68+
expect.stringContaining("custom or unrecognized content"),
69+
"Heartbeat template",
70+
);
71+
});
72+
73+
it("recognizes the docs-backed heading plus fenced template as repairable", () => {
74+
const analysis = analyzeHeartbeatTemplateForRepair(`# HEARTBEAT.md Template
75+
76+
\`\`\`markdown
77+
# Keep this file empty (or with only comments) to skip heartbeat API calls.
78+
79+
# Add tasks below when you want the agent to check something periodically.
80+
\`\`\`
81+
`);
82+
83+
expect(analysis.status).toBe("dirty-template");
84+
});
85+
86+
it("recognizes the fenced docs-backed template as repairable", () => {
4587
const analysis = analyzeHeartbeatTemplateForRepair(`\`\`\`markdown
4688
# Keep this file empty (or with only comments) to skip heartbeat API calls.
4789
@@ -52,7 +94,7 @@ describe("heartbeat template repair", () => {
5294
expect(analysis.status).toBe("dirty-template");
5395
});
5496

55-
it("recognizes the fenced docs template with Related as repairable", () => {
97+
it("recognizes the original docs-backed template as repairable", () => {
5698
const analysis = analyzeHeartbeatTemplateForRepair(`\`\`\`markdown
5799
# Keep this file empty (or with only comments) to skip heartbeat API calls.
58100
@@ -67,7 +109,7 @@ describe("heartbeat template repair", () => {
67109
expect(analysis.status).toBe("dirty-template");
68110
});
69111

70-
it("recognizes the current docs page boilerplate as repairable", () => {
112+
it("recognizes the current docs page boilerplate template as repairable", () => {
71113
const analysis = analyzeHeartbeatTemplateForRepair(`# HEARTBEAT.md template
72114
73115
\`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.
@@ -119,7 +161,30 @@ Add short tasks below the comments only when you want the agent to check somethi
119161

120162
await expect(fs.readFile(heartbeatPath, "utf-8")).resolves.toContain("- Check email");
121163
expect(mocks.note).toHaveBeenCalledWith(
122-
expect.stringContaining("plus custom content"),
164+
expect.stringContaining("custom or unrecognized content"),
165+
"Heartbeat template",
166+
);
167+
});
168+
169+
it("keeps unrecognized dirty template shapes unchanged", async () => {
170+
const content = `# HEARTBEAT.md Template
171+
172+
\`\`\`markdown
173+
# Add tasks below when you want the agent to check something periodically.
174+
175+
# Keep this file empty (or with only comments) to skip heartbeat API calls.
176+
\`\`\`
177+
`;
178+
const { workspaceDir, heartbeatPath } = await makeWorkspaceWithHeartbeat(content);
179+
180+
await maybeRepairHeartbeatTemplate({
181+
cfg: { agents: { defaults: { workspace: workspaceDir } } },
182+
shouldRepair: true,
183+
});
184+
185+
await expect(fs.readFile(heartbeatPath, "utf-8")).resolves.toBe(content);
186+
expect(mocks.note).toHaveBeenCalledWith(
187+
expect.stringContaining("custom or unrecognized content"),
123188
"Heartbeat template",
124189
);
125190
});

src/commands/doctor-heartbeat-template-repair.ts

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,49 @@ import { writeTextAtomic } from "../infra/json-files.js";
99
import { note } from "../terminal/note.js";
1010
import { shortenHomePath } from "../utils.js";
1111

12-
const DIRTY_HEARTBEAT_TEMPLATE_LINES = new Set([
12+
const LEGACY_HEARTBEAT_PROSE_TEMPLATE = [
13+
"# HEARTBEAT.md",
14+
"Keep this file empty unless you want a tiny checklist. Keep it small.",
15+
] as const;
16+
17+
const LEGACY_HEARTBEAT_HEADING_FENCED_TEMPLATE = [
18+
"# HEARTBEAT.md Template",
1319
"```markdown",
20+
"# Keep this file empty (or with only comments) to skip heartbeat API calls.",
21+
"# Add tasks below when you want the agent to check something periodically.",
1422
"```",
15-
"# HEARTBEAT.md Template",
23+
] as const;
24+
25+
const LEGACY_HEARTBEAT_FENCED_TEMPLATE = [
26+
"```markdown",
27+
"# Keep this file empty (or with only comments) to skip heartbeat API calls.",
28+
"# Add tasks below when you want the agent to check something periodically.",
29+
"```",
30+
] as const;
31+
32+
const LEGACY_HEARTBEAT_FENCED_RELATED_TEMPLATE = [
33+
"```markdown",
34+
"# Keep this file empty (or with only comments) to skip heartbeat API calls.",
35+
"# Add tasks below when you want the agent to check something periodically.",
36+
"```",
37+
"## Related",
38+
"- [Heartbeat config](/gateway/config-agents)",
39+
] as const;
40+
41+
const DOCS_HEARTBEAT_TEMPLATE_PAGE_AS_TEMPLATE = [
1642
"# HEARTBEAT.md template",
1743
"`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.",
1844
"The default runtime template is:",
19-
"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.",
45+
"```markdown",
2046
"# Keep this file empty (or with only comments) to skip heartbeat API calls.",
2147
"# Add tasks below when you want the agent to check something periodically.",
48+
"```",
49+
"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.",
2250
"## Related",
2351
"- [Heartbeat config](/gateway/config-agents)",
24-
]);
52+
] as const;
2553

26-
const DIRTY_HEARTBEAT_TEMPLATE_BODY_LINES = [
54+
const HEARTBEAT_DEFAULT_BODY_LINES = [
2755
"# Keep this file empty (or with only comments) to skip heartbeat API calls.",
2856
"# Add tasks below when you want the agent to check something periodically.",
2957
] as const;
@@ -35,30 +63,59 @@ const DIRTY_HEARTBEAT_DOC_WRAPPER_LINES = new Set([
3563
"- [Heartbeat config](/gateway/config-agents)",
3664
]);
3765

66+
const KNOWN_DIRTY_HEARTBEAT_TEMPLATE_LINES = new Set([
67+
"```markdown",
68+
"```",
69+
"# HEARTBEAT.md Template",
70+
"# HEARTBEAT.md template",
71+
"`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.",
72+
"The default runtime template is:",
73+
"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.",
74+
...LEGACY_HEARTBEAT_PROSE_TEMPLATE,
75+
"# Keep this file empty (or with only comments) to skip heartbeat API calls.",
76+
"# Add tasks below when you want the agent to check something periodically.",
77+
"## Related",
78+
"- [Heartbeat config](/gateway/config-agents)",
79+
]);
80+
81+
const KNOWN_REPAIRABLE_DIRTY_HEARTBEAT_TEMPLATES = [
82+
LEGACY_HEARTBEAT_PROSE_TEMPLATE,
83+
LEGACY_HEARTBEAT_HEADING_FENCED_TEMPLATE,
84+
LEGACY_HEARTBEAT_FENCED_TEMPLATE,
85+
LEGACY_HEARTBEAT_FENCED_RELATED_TEMPLATE,
86+
DOCS_HEARTBEAT_TEMPLATE_PAGE_AS_TEMPLATE,
87+
] as const;
88+
3889
export type HeartbeatTemplateRepairAnalysis =
3990
| { status: "clean" }
4091
| { status: "dirty-template" }
4192
| { status: "dirty-template-with-custom-content"; customLines: string[] };
4293

94+
function linesEqual(left: readonly string[], right: readonly string[]): boolean {
95+
return left.length === right.length && left.every((line, index) => line === right[index]);
96+
}
97+
4398
export function analyzeHeartbeatTemplateForRepair(
4499
content: string,
45100
): HeartbeatTemplateRepairAnalysis {
46101
const lines = content
47102
.split(/\r?\n/)
48103
.map((line) => line.trim())
49104
.filter((line) => line.length > 0);
50-
const hasDefaultTemplateBody = DIRTY_HEARTBEAT_TEMPLATE_BODY_LINES.every((line) =>
105+
if (KNOWN_REPAIRABLE_DIRTY_HEARTBEAT_TEMPLATES.some((template) => linesEqual(lines, template))) {
106+
return { status: "dirty-template" };
107+
}
108+
109+
const hasDefaultTemplateBody = HEARTBEAT_DEFAULT_BODY_LINES.every((line) => lines.includes(line));
110+
const hasDirtyDocWrapper = lines.some((line) => DIRTY_HEARTBEAT_DOC_WRAPPER_LINES.has(line));
111+
const hasLegacyProseTemplate = LEGACY_HEARTBEAT_PROSE_TEMPLATE.every((line) =>
51112
lines.includes(line),
52113
);
53-
const hasDirtyDocWrapper = lines.some((line) => DIRTY_HEARTBEAT_DOC_WRAPPER_LINES.has(line));
54-
if (!hasDefaultTemplateBody || !hasDirtyDocWrapper) {
114+
if ((!hasDefaultTemplateBody || !hasDirtyDocWrapper) && !hasLegacyProseTemplate) {
55115
return { status: "clean" };
56116
}
57-
const customLines = lines.filter((line) => !DIRTY_HEARTBEAT_TEMPLATE_LINES.has(line));
58-
if (customLines.length > 0) {
59-
return { status: "dirty-template-with-custom-content", customLines };
60-
}
61-
return { status: "dirty-template" };
117+
const customLines = lines.filter((line) => !KNOWN_DIRTY_HEARTBEAT_TEMPLATE_LINES.has(line));
118+
return { status: "dirty-template-with-custom-content", customLines };
62119
}
63120

64121
async function readCleanHeartbeatTemplate(): Promise<string> {
@@ -94,7 +151,7 @@ export async function maybeRepairHeartbeatTemplate(params: {
94151
if (analysis.status === "dirty-template-with-custom-content") {
95152
note(
96153
[
97-
`${shortenHomePath(heartbeatPath)} contains an older heartbeat template wrapper plus custom content.`,
154+
`${shortenHomePath(heartbeatPath)} contains an older heartbeat template wrapper plus custom or unrecognized content.`,
98155
"Doctor left it unchanged so it does not delete user tasks. Remove the fenced template and Related lines manually if they are not intentional.",
99156
].join("\n"),
100157
"Heartbeat template",

0 commit comments

Comments
 (0)