refactor: centralize heartbeat preflight gating

This commit is contained in:
Gustavo Madeira Santana
2026-02-19 00:58:47 -05:00
parent c8d0f14f3f
commit 85af4199ee
4 changed files with 179 additions and 55 deletions

View File

@@ -17,7 +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 and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos.
- 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.

View File

@@ -89,7 +89,7 @@ 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.

View File

@@ -1228,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");

View File

@@ -474,6 +474,93 @@ 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 {
return {
isExecEventReason: reason === "exec-event",
isCronEventReason: Boolean(reason?.startsWith("cron:")),
isWakeReason: reason === "wake" || Boolean(reason?.startsWith("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,61 +592,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 { entry, sessionKey, storePath } = resolveHeartbeatSession(
// Preflight centralizes trigger classification, event inspection, and HEARTBEAT.md gating.
const preflight = await resolveHeartbeatPreflight({
cfg,
agentId,
heartbeat,
opts.sessionKey,
);
const pendingEventEntries = peekSystemEventEntries(sessionKey);
const hasTaggedCronEvents = pendingEventEntries.some((event) =>
event.contextKey?.startsWith("cron:"),
);
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 (err: unknown) {
// If HEARTBEAT.md doesn't exist and there are no tagged cron events waiting,
// skip the heartbeat entirely to avoid unnecessary API calls/costs.
// The default prompt says "Read HEARTBEAT.md if it exists" — when it doesn't,
// there's nothing actionable for the LLM to do.
if (
(err as NodeJS.ErrnoException)?.code === "ENOENT" &&
!isExecEventReason &&
!isCronEventReason &&
!isWakeReason &&
!hasTaggedCronEvents
) {
emitHeartbeatEvent({
status: "skipped",
reason: "no-heartbeat-file",
durationMs: Date.now() - startedAt,
});
return { status: "skipped", reason: "no-heartbeat-file" };
}
// For other read errors, proceed with heartbeat as before.
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();
@@ -592,8 +642,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 shouldInspectPendingEvents = isExecEvent || isCronEventReason || hasTaggedCronEvents;
const shouldInspectPendingEvents = preflight.shouldInspectPendingEvents;
const pendingEvents = shouldInspectPendingEvents
? pendingEventEntries.map((event) => event.text)
: [];