mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:10:42 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user