mirror of https://github.com/openclaw/openclaw.git
fix(heartbeat): run when HEARTBEAT.md is missing
This commit is contained in:
parent
6bc9824735
commit
cf4ffff3e1
|
|
@ -90,7 +90,6 @@ Common signatures:
|
||||||
- `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`.
|
- `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`.
|
||||||
- `requests-in-flight` → main lane busy; heartbeat deferred.
|
- `requests-in-flight` → main lane busy; heartbeat deferred.
|
||||||
- `empty-heartbeat-file` → interval heartbeat skipped because `HEARTBEAT.md` has no actionable content and no tagged cron event is queued.
|
- `empty-heartbeat-file` → interval heartbeat skipped because `HEARTBEAT.md` has no actionable content and no tagged cron event is queued.
|
||||||
- `no-heartbeat-file` → interval heartbeat skipped because `HEARTBEAT.md` is missing and no tagged cron event is queued.
|
|
||||||
- `alerts-disabled` → visibility settings suppress outbound heartbeat messages.
|
- `alerts-disabled` → visibility settings suppress outbound heartbeat messages.
|
||||||
|
|
||||||
## Timezone and activeHours gotchas
|
## Timezone and activeHours gotchas
|
||||||
|
|
|
||||||
|
|
@ -1372,7 +1372,7 @@ describe("runHeartbeatOnce", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips heartbeat when HEARTBEAT.md does not exist (saves API calls)", async () => {
|
it("runs heartbeat when HEARTBEAT.md does not exist", async () => {
|
||||||
const tmpDir = await createCaseDir("openclaw-hb");
|
const tmpDir = await createCaseDir("openclaw-hb");
|
||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
const workspaceDir = path.join(tmpDir, "workspace");
|
const workspaceDir = path.join(tmpDir, "workspace");
|
||||||
|
|
@ -1409,7 +1409,7 @@ describe("runHeartbeatOnce", () => {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
|
replySpy.mockResolvedValue({ text: "Checked logs and PRs" });
|
||||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
messageId: "m1",
|
messageId: "m1",
|
||||||
toJid: "jid",
|
toJid: "jid",
|
||||||
|
|
@ -1426,13 +1426,74 @@ describe("runHeartbeatOnce", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should skip - no HEARTBEAT.md means nothing actionable
|
// Missing HEARTBEAT.md should still run so prompt/system instructions can drive work.
|
||||||
expect(res.status).toBe("skipped");
|
expect(res.status).toBe("ran");
|
||||||
if (res.status === "skipped") {
|
expect(replySpy).toHaveBeenCalled();
|
||||||
expect(res.reason).toBe("no-heartbeat-file");
|
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||||
}
|
} finally {
|
||||||
expect(replySpy).not.toHaveBeenCalled();
|
replySpy.mockRestore();
|
||||||
expect(sendWhatsApp).not.toHaveBeenCalled();
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs heartbeat when HEARTBEAT.md read fails with a non-ENOENT error", async () => {
|
||||||
|
const tmpDir = await createCaseDir("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 });
|
||||||
|
// Simulate a read failure path (readFile on a directory returns EISDIR).
|
||||||
|
await fs.mkdir(path.join(workspaceDir, "HEARTBEAT.md"), { recursive: true });
|
||||||
|
|
||||||
|
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: "Checked logs and PRs" });
|
||||||
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
|
messageId: "m1",
|
||||||
|
toJid: "jid",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runHeartbeatOnce({
|
||||||
|
cfg,
|
||||||
|
deps: {
|
||||||
|
sendWhatsApp,
|
||||||
|
getQueueSize: () => 0,
|
||||||
|
nowMs: () => 0,
|
||||||
|
webAuthExists: async () => true,
|
||||||
|
hasActiveWebListener: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read errors other than ENOENT should not disable heartbeat runs.
|
||||||
|
expect(res.status).toBe("ran");
|
||||||
|
expect(replySpy).toHaveBeenCalled();
|
||||||
|
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||||
} finally {
|
} finally {
|
||||||
replySpy.mockRestore();
|
replySpy.mockRestore();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ import { CommandLane } from "../process/lanes.js";
|
||||||
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
|
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { escapeRegExp } from "../utils.js";
|
import { escapeRegExp } from "../utils.js";
|
||||||
import { formatErrorMessage } from "./errors.js";
|
import { formatErrorMessage, hasErrnoCode } from "./errors.js";
|
||||||
import { isWithinActiveHours } from "./heartbeat-active-hours.js";
|
import { isWithinActiveHours } from "./heartbeat-active-hours.js";
|
||||||
import {
|
import {
|
||||||
buildCronEventPrompt,
|
buildCronEventPrompt,
|
||||||
|
|
@ -481,7 +481,7 @@ type HeartbeatReasonFlags = {
|
||||||
isWakeReason: boolean;
|
isWakeReason: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HeartbeatSkipReason = "empty-heartbeat-file" | "no-heartbeat-file";
|
type HeartbeatSkipReason = "empty-heartbeat-file";
|
||||||
|
|
||||||
type HeartbeatPreflight = HeartbeatReasonFlags & {
|
type HeartbeatPreflight = HeartbeatReasonFlags & {
|
||||||
session: ReturnType<typeof resolveHeartbeatSession>;
|
session: ReturnType<typeof resolveHeartbeatSession>;
|
||||||
|
|
@ -525,42 +525,39 @@ async function resolveHeartbeatPreflight(params: {
|
||||||
reasonFlags.isCronEventReason ||
|
reasonFlags.isCronEventReason ||
|
||||||
reasonFlags.isWakeReason ||
|
reasonFlags.isWakeReason ||
|
||||||
hasTaggedCronEvents;
|
hasTaggedCronEvents;
|
||||||
|
const basePreflight = {
|
||||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
|
|
||||||
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
|
||||||
try {
|
|
||||||
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
|
|
||||||
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !shouldBypassFileGates) {
|
|
||||||
return {
|
|
||||||
...reasonFlags,
|
|
||||||
session,
|
|
||||||
pendingEventEntries,
|
|
||||||
hasTaggedCronEvents,
|
|
||||||
shouldInspectPendingEvents,
|
|
||||||
skipReason: "empty-heartbeat-file",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT" && !shouldBypassFileGates) {
|
|
||||||
return {
|
|
||||||
...reasonFlags,
|
|
||||||
session,
|
|
||||||
pendingEventEntries,
|
|
||||||
hasTaggedCronEvents,
|
|
||||||
shouldInspectPendingEvents,
|
|
||||||
skipReason: "no-heartbeat-file",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// For other read errors, proceed with heartbeat as before.
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...reasonFlags,
|
...reasonFlags,
|
||||||
session,
|
session,
|
||||||
pendingEventEntries,
|
pendingEventEntries,
|
||||||
hasTaggedCronEvents,
|
hasTaggedCronEvents,
|
||||||
shouldInspectPendingEvents,
|
shouldInspectPendingEvents,
|
||||||
};
|
} satisfies Omit<HeartbeatPreflight, "skipReason">;
|
||||||
|
|
||||||
|
if (shouldBypassFileGates) {
|
||||||
|
return basePreflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
|
||||||
|
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||||
|
try {
|
||||||
|
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
|
||||||
|
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) {
|
||||||
|
return {
|
||||||
|
...basePreflight,
|
||||||
|
skipReason: "empty-heartbeat-file",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (hasErrnoCode(err, "ENOENT")) {
|
||||||
|
// Missing HEARTBEAT.md is intentional in some setups (for example, when
|
||||||
|
// heartbeat instructions live outside the file), so keep the run active.
|
||||||
|
// The heartbeat prompt already says "if it exists".
|
||||||
|
return basePreflight;
|
||||||
|
}
|
||||||
|
// For other read errors, proceed with heartbeat as before.
|
||||||
|
}
|
||||||
|
|
||||||
|
return basePreflight;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runHeartbeatOnce(opts: {
|
export async function runHeartbeatOnce(opts: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue