From 12a82aa78855f73377dc4233d3875a6576ba69c3 Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:09:11 -0700 Subject: [PATCH] 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 --- ...tbeat-runner.returns-default-unset.test.ts | 74 +++++++++++++++++++ src/infra/heartbeat-runner.scheduler.test.ts | 43 +++++++++++ src/infra/heartbeat-runner.ts | 14 ++-- 3 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index d41feb55fa6..d614783d810 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -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; diff --git a/src/infra/heartbeat-runner.scheduler.test.ts b/src/infra/heartbeat-runner.scheduler.test.ts index 038e4f652e1..cc1ec00f58d 100644 --- a/src/infra/heartbeat-runner.scheduler.test.ts +++ b/src/infra/heartbeat-runner.scheduler.test.ts @@ -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 }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index b75082039c7..6203018f167 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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;