mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix: skip heartbeat when HEARTBEAT.md does not exist (#20461)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: f6e5f8172a
Co-authored-by: vikpos <24960005+vikpos@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr.
|
||||
- Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus.
|
||||
- Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos.
|
||||
- Telegram/Agents: gate exec/bash tool-failure warnings behind verbose mode so default Telegram replies stay clean while verbose sessions still surface diagnostics. (#20560) Thanks @obviyus.
|
||||
- Gateway/Daemon: forward `TMPDIR` into installed service environments so macOS LaunchAgent gateway runs can open SQLite temp/journal files reliably instead of failing with `SQLITE_CANTOPEN`. (#20512) Thanks @Clawborn.
|
||||
- Agents/Billing: include the active model that produced a billing error in user-facing billing messages (for example, `OpenAI (gpt-5.3)`) across payload, failover, and lifecycle error paths, so users can identify exactly which key needs credits. (#20510) Thanks @echoVic.
|
||||
|
||||
@@ -89,7 +89,8 @@ Common signatures:
|
||||
|
||||
- `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`.
|
||||
- `requests-in-flight` → main lane busy; heartbeat deferred.
|
||||
- `empty-heartbeat-file` → `HEARTBEAT.md` exists but has no actionable content.
|
||||
- `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.
|
||||
|
||||
## Timezone and activeHours gotchas
|
||||
|
||||
52
src/infra/heartbeat-reason.test.ts
Normal file
52
src/infra/heartbeat-reason.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHeartbeatActionWakeReason,
|
||||
isHeartbeatEventDrivenReason,
|
||||
normalizeHeartbeatWakeReason,
|
||||
resolveHeartbeatReasonKind,
|
||||
} from "./heartbeat-reason.js";
|
||||
|
||||
describe("heartbeat-reason", () => {
|
||||
it("normalizes wake reasons with trim + requested fallback", () => {
|
||||
expect(normalizeHeartbeatWakeReason(" cron:job-1 ")).toBe("cron:job-1");
|
||||
expect(normalizeHeartbeatWakeReason(" ")).toBe("requested");
|
||||
expect(normalizeHeartbeatWakeReason(undefined)).toBe("requested");
|
||||
});
|
||||
|
||||
it("classifies known reason kinds", () => {
|
||||
expect(resolveHeartbeatReasonKind("retry")).toBe("retry");
|
||||
expect(resolveHeartbeatReasonKind("interval")).toBe("interval");
|
||||
expect(resolveHeartbeatReasonKind("manual")).toBe("manual");
|
||||
expect(resolveHeartbeatReasonKind("exec-event")).toBe("exec-event");
|
||||
expect(resolveHeartbeatReasonKind("wake")).toBe("wake");
|
||||
expect(resolveHeartbeatReasonKind("cron:job-1")).toBe("cron");
|
||||
expect(resolveHeartbeatReasonKind("hook:wake")).toBe("hook");
|
||||
expect(resolveHeartbeatReasonKind(" hook:wake ")).toBe("hook");
|
||||
});
|
||||
|
||||
it("classifies unknown reasons as other", () => {
|
||||
expect(resolveHeartbeatReasonKind("requested")).toBe("other");
|
||||
expect(resolveHeartbeatReasonKind("slow")).toBe("other");
|
||||
expect(resolveHeartbeatReasonKind("")).toBe("other");
|
||||
expect(resolveHeartbeatReasonKind(undefined)).toBe("other");
|
||||
});
|
||||
|
||||
it("matches event-driven behavior used by heartbeat preflight", () => {
|
||||
expect(isHeartbeatEventDrivenReason("exec-event")).toBe(true);
|
||||
expect(isHeartbeatEventDrivenReason("cron:job-1")).toBe(true);
|
||||
expect(isHeartbeatEventDrivenReason("wake")).toBe(true);
|
||||
expect(isHeartbeatEventDrivenReason("hook:gmail:sync")).toBe(true);
|
||||
expect(isHeartbeatEventDrivenReason("interval")).toBe(false);
|
||||
expect(isHeartbeatEventDrivenReason("manual")).toBe(false);
|
||||
expect(isHeartbeatEventDrivenReason("other")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches action-priority wake behavior", () => {
|
||||
expect(isHeartbeatActionWakeReason("manual")).toBe(true);
|
||||
expect(isHeartbeatActionWakeReason("exec-event")).toBe(true);
|
||||
expect(isHeartbeatActionWakeReason("hook:wake")).toBe(true);
|
||||
expect(isHeartbeatActionWakeReason("interval")).toBe(false);
|
||||
expect(isHeartbeatActionWakeReason("cron:job-1")).toBe(false);
|
||||
expect(isHeartbeatActionWakeReason("retry")).toBe(false);
|
||||
});
|
||||
});
|
||||
54
src/infra/heartbeat-reason.ts
Normal file
54
src/infra/heartbeat-reason.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type HeartbeatReasonKind =
|
||||
| "retry"
|
||||
| "interval"
|
||||
| "manual"
|
||||
| "exec-event"
|
||||
| "wake"
|
||||
| "cron"
|
||||
| "hook"
|
||||
| "other";
|
||||
|
||||
function trimReason(reason?: string): string {
|
||||
return typeof reason === "string" ? reason.trim() : "";
|
||||
}
|
||||
|
||||
export function normalizeHeartbeatWakeReason(reason?: string): string {
|
||||
const trimmed = trimReason(reason);
|
||||
return trimmed.length > 0 ? trimmed : "requested";
|
||||
}
|
||||
|
||||
export function resolveHeartbeatReasonKind(reason?: string): HeartbeatReasonKind {
|
||||
const trimmed = trimReason(reason);
|
||||
if (trimmed === "retry") {
|
||||
return "retry";
|
||||
}
|
||||
if (trimmed === "interval") {
|
||||
return "interval";
|
||||
}
|
||||
if (trimmed === "manual") {
|
||||
return "manual";
|
||||
}
|
||||
if (trimmed === "exec-event") {
|
||||
return "exec-event";
|
||||
}
|
||||
if (trimmed === "wake") {
|
||||
return "wake";
|
||||
}
|
||||
if (trimmed.startsWith("cron:")) {
|
||||
return "cron";
|
||||
}
|
||||
if (trimmed.startsWith("hook:")) {
|
||||
return "hook";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function isHeartbeatEventDrivenReason(reason?: string): boolean {
|
||||
const kind = resolveHeartbeatReasonKind(reason);
|
||||
return kind === "exec-event" || kind === "cron" || kind === "wake" || kind === "hook";
|
||||
}
|
||||
|
||||
export function isHeartbeatActionWakeReason(reason?: string): boolean {
|
||||
const kind = resolveHeartbeatReasonKind(reason);
|
||||
return kind === "manual" || kind === "exec-event" || kind === "hook";
|
||||
}
|
||||
@@ -182,6 +182,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
|
||||
|
||||
it("uses CRON_EVENT_PROMPT for tagged cron events on interval wake", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-interval-"));
|
||||
await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8");
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
chatId: "155462274",
|
||||
|
||||
@@ -51,6 +51,8 @@ async function withHeartbeatFixture(
|
||||
);
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8");
|
||||
|
||||
try {
|
||||
return await run({ tmpDir, storePath, seedSession });
|
||||
} finally {
|
||||
@@ -136,7 +138,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => {
|
||||
});
|
||||
|
||||
it("passes per-agent heartbeat model override (merged with defaults)", async () => {
|
||||
await withHeartbeatFixture(async ({ storePath, seedSession }) => {
|
||||
await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -149,6 +151,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => {
|
||||
{ id: "main", default: true },
|
||||
{
|
||||
id: "ops",
|
||||
workspace: tmpDir,
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
resolveHeartbeatDeliveryTarget,
|
||||
resolveHeartbeatSenderContext,
|
||||
} from "./outbound/targets.js";
|
||||
import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js";
|
||||
|
||||
// Avoid pulling optional runtime deps during isolated runs.
|
||||
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
|
||||
@@ -35,9 +36,12 @@ let testRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
|
||||
let fixtureRoot = "";
|
||||
let fixtureCount = 0;
|
||||
|
||||
const createCaseDir = async (prefix: string) => {
|
||||
const createCaseDir = async (prefix: string, { skipHeartbeatFile = false } = {}) => {
|
||||
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
if (!skipHeartbeatFile) {
|
||||
await fs.writeFile(path.join(dir, "HEARTBEAT.md"), "- Check status\n", "utf-8");
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
@@ -101,6 +105,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetSystemEventsForTest();
|
||||
if (testRegistry) {
|
||||
setActivePluginRegistry(testRegistry);
|
||||
}
|
||||
@@ -542,6 +547,7 @@ describe("runHeartbeatOnce", () => {
|
||||
{ id: "main", default: true },
|
||||
{
|
||||
id: "ops",
|
||||
workspace: tmpDir,
|
||||
heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" },
|
||||
},
|
||||
],
|
||||
@@ -611,6 +617,7 @@ describe("runHeartbeatOnce", () => {
|
||||
{ id: "main", default: true },
|
||||
{
|
||||
id: agentId,
|
||||
workspace: tmpDir,
|
||||
heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" },
|
||||
},
|
||||
],
|
||||
@@ -1221,6 +1228,81 @@ describe("runHeartbeatOnce", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not skip interval heartbeat when HEARTBEAT.md is empty but tagged cron events are queued", 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 });
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
enqueueSystemEvent("Cron: QMD maintenance completed", {
|
||||
sessionKey,
|
||||
contextKey: "cron:qmd-maintenance",
|
||||
});
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Relay this cron update now" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
reason: "interval",
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ran");
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string };
|
||||
expect(calledCtx.Provider).toBe("cron-event");
|
||||
expect(calledCtx.Body).toContain("scheduled reminder has been triggered");
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs heartbeat when HEARTBEAT.md has actionable content", async () => {
|
||||
const tmpDir = await createCaseDir("openclaw-hb");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
@@ -1290,7 +1372,7 @@ describe("runHeartbeatOnce", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("runs heartbeat when HEARTBEAT.md does not exist (lets LLM decide)", async () => {
|
||||
it("skips heartbeat when HEARTBEAT.md does not exist (saves API calls)", async () => {
|
||||
const tmpDir = await createCaseDir("openclaw-hb");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const workspaceDir = path.join(tmpDir, "workspace");
|
||||
@@ -1344,9 +1426,148 @@ describe("runHeartbeatOnce", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Should run (not skip) - let LLM decide since file doesn't exist
|
||||
// Should skip - no HEARTBEAT.md means nothing actionable
|
||||
expect(res.status).toBe("skipped");
|
||||
if (res.status === "skipped") {
|
||||
expect(res.reason).toBe("no-heartbeat-file");
|
||||
}
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not skip wake-triggered heartbeat when HEARTBEAT.md does not exist", 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 });
|
||||
// Don't create HEARTBEAT.md
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// Wake events should still run even without HEARTBEAT.md
|
||||
expect(res.status).toBe("ran");
|
||||
expect(replySpy).toHaveBeenCalled();
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not skip interval heartbeat when tagged cron events are queued and HEARTBEAT.md is missing", 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 });
|
||||
// Don't create HEARTBEAT.md
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
enqueueSystemEvent("Cron: QMD maintenance completed", {
|
||||
sessionKey,
|
||||
contextKey: "cron:qmd-maintenance",
|
||||
});
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Relay this cron update now" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
reason: "interval",
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ran");
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string };
|
||||
expect(calledCtx.Provider).toBe("cron-event");
|
||||
expect(calledCtx.Body).toContain("scheduled reminder has been triggered");
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ installHeartbeatRunnerTestRuntime({ includeSlack: true });
|
||||
describe("runHeartbeatOnce", () => {
|
||||
it("uses the delivery target as sender when lastTo differs", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||
await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
|
||||
@@ -45,6 +45,7 @@ export async function withTempHeartbeatSandbox<T>(
|
||||
},
|
||||
): Promise<T> {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), options?.prefix ?? "openclaw-hb-"));
|
||||
await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
const previousEnv = new Map<string, string | undefined>();
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
isExecCompletionEvent,
|
||||
} from "./heartbeat-events-filter.js";
|
||||
import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js";
|
||||
import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js";
|
||||
import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
|
||||
import {
|
||||
type HeartbeatRunResult,
|
||||
@@ -474,6 +475,94 @@ function normalizeHeartbeatReply(
|
||||
return { shouldSkip: false, text: finalText, hasMedia };
|
||||
}
|
||||
|
||||
type HeartbeatReasonFlags = {
|
||||
isExecEventReason: boolean;
|
||||
isCronEventReason: boolean;
|
||||
isWakeReason: boolean;
|
||||
};
|
||||
|
||||
type HeartbeatSkipReason = "empty-heartbeat-file" | "no-heartbeat-file";
|
||||
|
||||
type HeartbeatPreflight = HeartbeatReasonFlags & {
|
||||
session: ReturnType<typeof resolveHeartbeatSession>;
|
||||
pendingEventEntries: ReturnType<typeof peekSystemEventEntries>;
|
||||
hasTaggedCronEvents: boolean;
|
||||
shouldInspectPendingEvents: boolean;
|
||||
skipReason?: HeartbeatSkipReason;
|
||||
};
|
||||
|
||||
function resolveHeartbeatReasonFlags(reason?: string): HeartbeatReasonFlags {
|
||||
const reasonKind = resolveHeartbeatReasonKind(reason);
|
||||
return {
|
||||
isExecEventReason: reasonKind === "exec-event",
|
||||
isCronEventReason: reasonKind === "cron",
|
||||
isWakeReason: reasonKind === "wake" || reasonKind === "hook",
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveHeartbeatPreflight(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
heartbeat?: HeartbeatConfig;
|
||||
forcedSessionKey?: string;
|
||||
reason?: string;
|
||||
}): Promise<HeartbeatPreflight> {
|
||||
const reasonFlags = resolveHeartbeatReasonFlags(params.reason);
|
||||
const session = resolveHeartbeatSession(
|
||||
params.cfg,
|
||||
params.agentId,
|
||||
params.heartbeat,
|
||||
params.forcedSessionKey,
|
||||
);
|
||||
const pendingEventEntries = peekSystemEventEntries(session.sessionKey);
|
||||
const hasTaggedCronEvents = pendingEventEntries.some((event) =>
|
||||
event.contextKey?.startsWith("cron:"),
|
||||
);
|
||||
const shouldInspectPendingEvents =
|
||||
reasonFlags.isExecEventReason || reasonFlags.isCronEventReason || hasTaggedCronEvents;
|
||||
const shouldBypassFileGates =
|
||||
reasonFlags.isExecEventReason ||
|
||||
reasonFlags.isCronEventReason ||
|
||||
reasonFlags.isWakeReason ||
|
||||
hasTaggedCronEvents;
|
||||
|
||||
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,
|
||||
session,
|
||||
pendingEventEntries,
|
||||
hasTaggedCronEvents,
|
||||
shouldInspectPendingEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runHeartbeatOnce(opts: {
|
||||
cfg?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
@@ -505,41 +594,24 @@ export async function runHeartbeatOnce(opts: {
|
||||
return { status: "skipped", reason: "requests-in-flight" };
|
||||
}
|
||||
|
||||
// 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).
|
||||
// EXCEPTION: Don't skip for exec events, cron events, or explicit wake requests -
|
||||
// they have pending system events to process regardless of HEARTBEAT.md content.
|
||||
const isExecEventReason = opts.reason === "exec-event";
|
||||
const isCronEventReason = Boolean(opts.reason?.startsWith("cron:"));
|
||||
const isWakeReason = opts.reason === "wake" || Boolean(opts.reason?.startsWith("hook:"));
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||
try {
|
||||
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
|
||||
if (
|
||||
isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) &&
|
||||
!isExecEventReason &&
|
||||
!isCronEventReason &&
|
||||
!isWakeReason
|
||||
) {
|
||||
emitHeartbeatEvent({
|
||||
status: "skipped",
|
||||
reason: "empty-heartbeat-file",
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return { status: "skipped", reason: "empty-heartbeat-file" };
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist or can't be read - proceed with heartbeat.
|
||||
// The LLM prompt says "if it exists" so this is expected behavior.
|
||||
}
|
||||
|
||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(
|
||||
// Preflight centralizes trigger classification, event inspection, and HEARTBEAT.md gating.
|
||||
const preflight = await resolveHeartbeatPreflight({
|
||||
cfg,
|
||||
agentId,
|
||||
heartbeat,
|
||||
opts.sessionKey,
|
||||
);
|
||||
forcedSessionKey: opts.sessionKey,
|
||||
reason: opts.reason,
|
||||
});
|
||||
if (preflight.skipReason) {
|
||||
emitHeartbeatEvent({
|
||||
status: "skipped",
|
||||
reason: preflight.skipReason,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return { status: "skipped", reason: preflight.skipReason };
|
||||
}
|
||||
const { entry, sessionKey, storePath } = preflight.session;
|
||||
const { isCronEventReason, pendingEventEntries } = preflight;
|
||||
const previousUpdatedAt = entry?.updatedAt;
|
||||
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
||||
const heartbeatAccountId = heartbeat?.accountId?.trim();
|
||||
@@ -572,12 +644,7 @@ export async function runHeartbeatOnce(opts: {
|
||||
// Check if this is an exec event or cron event with pending system events.
|
||||
// If so, use a specialized prompt that instructs the model to relay the result
|
||||
// instead of the standard heartbeat prompt with "reply HEARTBEAT_OK".
|
||||
const isExecEvent = opts.reason === "exec-event";
|
||||
const pendingEventEntries = peekSystemEventEntries(sessionKey);
|
||||
const hasTaggedCronEvents = pendingEventEntries.some((event) =>
|
||||
event.contextKey?.startsWith("cron:"),
|
||||
);
|
||||
const shouldInspectPendingEvents = isExecEvent || isCronEventReason || hasTaggedCronEvents;
|
||||
const shouldInspectPendingEvents = preflight.shouldInspectPendingEvents;
|
||||
const pendingEvents = shouldInspectPendingEvents
|
||||
? pendingEventEntries.map((event) => event.text)
|
||||
: [];
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
isHeartbeatActionWakeReason,
|
||||
normalizeHeartbeatWakeReason,
|
||||
resolveHeartbeatReasonKind,
|
||||
} from "./heartbeat-reason.js";
|
||||
|
||||
export type HeartbeatRunResult =
|
||||
| { status: "ran"; durationMs: number }
|
||||
| { status: "skipped"; reason: string }
|
||||
@@ -29,7 +35,6 @@ let timerKind: WakeTimerKind | null = null;
|
||||
|
||||
const DEFAULT_COALESCE_MS = 250;
|
||||
const DEFAULT_RETRY_MS = 1_000;
|
||||
const HOOK_REASON_PREFIX = "hook:";
|
||||
const REASON_PRIORITY = {
|
||||
RETRY: 0,
|
||||
INTERVAL: 1,
|
||||
@@ -37,29 +42,22 @@ const REASON_PRIORITY = {
|
||||
ACTION: 3,
|
||||
} as const;
|
||||
|
||||
function isActionWakeReason(reason: string): boolean {
|
||||
return reason === "manual" || reason === "exec-event" || reason.startsWith(HOOK_REASON_PREFIX);
|
||||
}
|
||||
|
||||
function resolveReasonPriority(reason: string): number {
|
||||
if (reason === "retry") {
|
||||
const kind = resolveHeartbeatReasonKind(reason);
|
||||
if (kind === "retry") {
|
||||
return REASON_PRIORITY.RETRY;
|
||||
}
|
||||
if (reason === "interval") {
|
||||
if (kind === "interval") {
|
||||
return REASON_PRIORITY.INTERVAL;
|
||||
}
|
||||
if (isActionWakeReason(reason)) {
|
||||
if (isHeartbeatActionWakeReason(reason)) {
|
||||
return REASON_PRIORITY.ACTION;
|
||||
}
|
||||
return REASON_PRIORITY.DEFAULT;
|
||||
}
|
||||
|
||||
function normalizeWakeReason(reason?: string): string {
|
||||
if (typeof reason !== "string") {
|
||||
return "requested";
|
||||
}
|
||||
const trimmed = reason.trim();
|
||||
return trimmed.length > 0 ? trimmed : "requested";
|
||||
return normalizeHeartbeatWakeReason(reason);
|
||||
}
|
||||
|
||||
function normalizeWakeTarget(value?: string): string | undefined {
|
||||
|
||||
Reference in New Issue
Block a user