mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:11:34 +00:00
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 containing3e2b3ea4has shipped and completed its upgrade window.
This commit is contained in:
committed by
GitHub
parent
7dfb660d1f
commit
07e3855b46
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: [] }),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user