fix: Found two heartbeat regressions in the changed behavior: default (#74523)

* fix: Found two heartbeat regressions in the changed behavior: default

* fix: repair heartbeat task-block stripping

---------

Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper-repair <clawsweeper-repair@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-04-29 14:09:11 -07:00
committed by GitHub
parent 0b48a69ff2
commit 12a82aa788
3 changed files with 125 additions and 6 deletions

View File

@@ -1435,6 +1435,8 @@ tasks:
prompt: Check calendar changes
Some global directive after tasks.
- Keep this top-level directive too.
`,
"utf-8",
);
@@ -1481,11 +1483,83 @@ Some global directive after tasks.
expect(calledCtx.Body).toContain("# Keep this header");
expect(calledCtx.Body).toContain("Remember escalation policy.");
expect(calledCtx.Body).toContain("Some global directive after tasks.");
expect(calledCtx.Body).toContain("- Keep this top-level directive too.");
expect(calledCtx.Body).not.toContain("name: inbox");
expect(calledCtx.Body).not.toContain("name: calendar");
replySpy.mockReset();
});
it("strips documented unindented task entries while keeping following top-level bullets", async () => {
const tmpDir = await createCaseDir("openclaw-hb-unindented-tasks-context");
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"),
`# Keep this header
tasks:
- name: inbox
interval: 5m
prompt: Check urgent inbox items
- name: calendar
interval: 5m
prompt: Check calendar changes
- Keep this top-level directive after tasks.
`,
"utf-8",
);
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: workspaceDir,
heartbeat: { every: "5m", target: "whatsapp" },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
await fs.writeFile(
storePath,
JSON.stringify({
[resolveMainSessionKey(cfg)]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "120363401234567890@g.us",
},
}),
);
const replySpy = vi.fn().mockResolvedValue({ text: "Handled due heartbeat tasks" });
const sendWhatsApp = vi
.fn<
(to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
const res = await runHeartbeatOnce({
cfg,
deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }),
});
expect(res.status).toBe("ran");
expect(replySpy).toHaveBeenCalledTimes(1);
const calledCtx = replySpy.mock.calls[0]?.[0] as { Body?: string };
expect(calledCtx.Body).toContain("- inbox: Check urgent inbox items");
expect(calledCtx.Body).toContain("- calendar: Check calendar changes");
expect(calledCtx.Body).toContain("Additional context from HEARTBEAT.md");
expect(calledCtx.Body).toContain("# Keep this header");
expect(calledCtx.Body).toContain("- Keep this top-level directive after tasks.");
expect(calledCtx.Body).not.toContain("name: inbox");
expect(calledCtx.Body).not.toContain("name: calendar");
expect(calledCtx.Body).not.toContain("interval: 5m");
expect(calledCtx.Body).not.toContain("prompt: Check urgent");
replySpy.mockReset();
});
it("applies HEARTBEAT.md gating rules across file states and triggers", async () => {
const cases: Array<{
name: string;

View File

@@ -137,6 +137,27 @@ describe("startHeartbeatRunner", () => {
runner.stop();
});
it("schedules every configured agent when only global heartbeat defaults exist", async () => {
useFakeHeartbeatTime();
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
const runner = startHeartbeatRunner({
cfg: heartbeatConfig([{ id: "main" }, { id: "ops" }]),
runOnce: runSpy,
stableSchedulerSeed: TEST_SCHEDULER_SEED,
});
const mainDueMs = resolveDueFromNow(0, 30 * 60_000, "main");
const opsDueMs = resolveDueFromNow(0, 30 * 60_000, "ops");
await vi.advanceTimersByTimeAsync(Math.max(mainDueMs, opsDueMs) + 1);
expect(runSpy.mock.calls.map((call) => call[0]?.agentId)).toEqual(
expect.arrayContaining(["main", "ops"]),
);
runner.stop();
});
it("continues scheduling after runOnce throws an unhandled error", async () => {
useFakeHeartbeatTime();
@@ -332,6 +353,28 @@ describe("startHeartbeatRunner", () => {
runner.stop();
});
it("routes targeted wake requests to agents enabled by global defaults", async () => {
useFakeHeartbeatTime();
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
const runner = await expectWakeDispatch({
cfg: heartbeatConfig([{ id: "main" }, { id: "ops" }]),
runSpy,
wake: {
reason: "cron:job-123",
agentId: "ops",
sessionKey: "agent:ops:discord:channel:alerts",
coalesceMs: 0,
},
expectedCall: {
agentId: "ops",
reason: "cron:job-123",
sessionKey: "agent:ops:discord:channel:alerts",
},
});
runner.stop();
});
it("merges targeted wake heartbeat overrides onto the agent heartbeat config", async () => {
useFakeHeartbeatTime();
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });

View File

@@ -6,6 +6,7 @@ import {
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import {
listAgentIds,
resolveAgentConfig,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
@@ -252,6 +253,12 @@ function resolveHeartbeatAgents(cfg: OpenClawConfig): HeartbeatAgent[] {
})
.filter((entry) => entry.agentId);
}
if (cfg.agents?.defaults?.heartbeat) {
return listAgentIds(cfg).map((agentId) => ({
agentId,
heartbeat: resolveHeartbeatConfig(cfg, agentId),
}));
}
const fallbackId = resolveDefaultAgentId(cfg);
return [{ agentId: fallbackId, heartbeat: resolveHeartbeatConfig(cfg, fallbackId) }];
}
@@ -717,12 +724,7 @@ function stripHeartbeatTasksBlock(content: string): string {
continue;
}
const isIndented = /^[\s]/.test(line);
const isTaskListItem = trimmed.startsWith("-");
const isTaskField =
trimmed.startsWith("interval:") ||
trimmed.startsWith("prompt:") ||
trimmed.startsWith("name:");
if (isIndented || isTaskListItem || isTaskField) {
if (isIndented || trimmed.startsWith("- name:")) {
continue;
}
inTasksBlock = false;