mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 05:02:04 +03:00
fix(heartbeat): exempt wake and hook reasons from empty-heartbeat skip (openclaw#14532) thanks @arosstale
Verified: - pnpm build - pnpm check - pnpm test Co-authored-by: arosstale <117890364+arosstale@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.
|
- Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.
|
||||||
- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
|
- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
|
||||||
- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.
|
- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.
|
||||||
|
- Heartbeat: allow explicit wake (`wake`) and hook wake (`hook:*`) reasons to run even when `HEARTBEAT.md` is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.
|
||||||
- Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.
|
- Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.
|
||||||
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
||||||
- Discord/Agents: apply channel/group `historyLimit` during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.
|
- Discord/Agents: apply channel/group `historyLimit` during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.
|
||||||
|
|||||||
@@ -1020,6 +1020,142 @@ describe("runHeartbeatOnce", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not skip wake-triggered heartbeat when HEARTBEAT.md is effectively empty", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
const workspaceDir = path.join(tmpDir, "workspace");
|
||||||
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
|
try {
|
||||||
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||||
|
"# HEARTBEAT.md\n\n## Tasks\n\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
workspace: workspaceDir,
|
||||||
|
heartbeat: { every: "5m", target: "whatsapp" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
session: { store: storePath },
|
||||||
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
replySpy.mockResolvedValue({ text: "wake event processed" });
|
||||||
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
|
messageId: "m1",
|
||||||
|
toJid: "jid",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runHeartbeatOnce({
|
||||||
|
cfg,
|
||||||
|
reason: "wake",
|
||||||
|
deps: {
|
||||||
|
sendWhatsApp,
|
||||||
|
getQueueSize: () => 0,
|
||||||
|
nowMs: () => 0,
|
||||||
|
webAuthExists: async () => true,
|
||||||
|
hasActiveWebListener: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe("ran");
|
||||||
|
expect(replySpy).toHaveBeenCalled();
|
||||||
|
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
replySpy.mockRestore();
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not skip hook-triggered heartbeat when HEARTBEAT.md is effectively empty", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
const workspaceDir = path.join(tmpDir, "workspace");
|
||||||
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
|
try {
|
||||||
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||||
|
"# HEARTBEAT.md\n\n## Tasks\n\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
workspace: workspaceDir,
|
||||||
|
heartbeat: { every: "5m", target: "whatsapp" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
session: { store: storePath },
|
||||||
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
replySpy.mockResolvedValue({ text: "hook event processed" });
|
||||||
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
|
messageId: "m1",
|
||||||
|
toJid: "jid",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runHeartbeatOnce({
|
||||||
|
cfg,
|
||||||
|
reason: "hook:wake",
|
||||||
|
deps: {
|
||||||
|
sendWhatsApp,
|
||||||
|
getQueueSize: () => 0,
|
||||||
|
nowMs: () => 0,
|
||||||
|
webAuthExists: async () => true,
|
||||||
|
hasActiveWebListener: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe("ran");
|
||||||
|
expect(replySpy).toHaveBeenCalled();
|
||||||
|
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
replySpy.mockRestore();
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("runs heartbeat when HEARTBEAT.md has actionable content", async () => {
|
it("runs heartbeat when HEARTBEAT.md has actionable content", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
|||||||
@@ -426,10 +426,11 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
|
|
||||||
// Skip heartbeat if HEARTBEAT.md exists but has no actionable content.
|
// Skip heartbeat if HEARTBEAT.md exists but has no actionable content.
|
||||||
// This saves API calls/costs when the file is effectively empty (only comments/headers).
|
// This saves API calls/costs when the file is effectively empty (only comments/headers).
|
||||||
// EXCEPTION: Don't skip for exec events or cron events - they have pending system events
|
// EXCEPTION: Don't skip for exec events, cron events, or explicit wake requests -
|
||||||
// to process regardless of HEARTBEAT.md content.
|
// they have pending system events to process regardless of HEARTBEAT.md content.
|
||||||
const isExecEventReason = opts.reason === "exec-event";
|
const isExecEventReason = opts.reason === "exec-event";
|
||||||
const isCronEventReason = Boolean(opts.reason?.startsWith("cron:"));
|
const isCronEventReason = Boolean(opts.reason?.startsWith("cron:"));
|
||||||
|
const isWakeReason = opts.reason === "wake" || Boolean(opts.reason?.startsWith("hook:"));
|
||||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||||
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||||
try {
|
try {
|
||||||
@@ -437,7 +438,8 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
if (
|
if (
|
||||||
isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) &&
|
isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) &&
|
||||||
!isExecEventReason &&
|
!isExecEventReason &&
|
||||||
!isCronEventReason
|
!isCronEventReason &&
|
||||||
|
!isWakeReason
|
||||||
) {
|
) {
|
||||||
emitHeartbeatEvent({
|
emitHeartbeatEvent({
|
||||||
status: "skipped",
|
status: "skipped",
|
||||||
|
|||||||
Reference in New Issue
Block a user