From 40e3ab8784fc837b64d7efdc7cdf75e5e20ed481 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 20 Aug 2026 15:48:19 -0700 Subject: [PATCH] fix(heartbeat): report the cadence of active agent heartbeats (#126869) --- .../health/collector.legacy-owner.test.ts | 31 +++++++++++++++ src/gateway/health/collector.ts | 6 ++- ...tbeat-runner.returns-default-unset.test.ts | 37 ++++++++++++++++++ src/infra/heartbeat-summary.ts | 38 +++++-------------- 4 files changed, 82 insertions(+), 30 deletions(-) diff --git a/src/gateway/health/collector.legacy-owner.test.ts b/src/gateway/health/collector.legacy-owner.test.ts index bc9580f05945..def0faede1f8 100644 --- a/src/gateway/health/collector.legacy-owner.test.ts +++ b/src/gateway/health/collector.legacy-owner.test.ts @@ -107,6 +107,7 @@ describe("collectGatewayHealthSnapshot legacy owner projection", () => { expect(explicit.defaultAgentId).toBeUndefined(); expect(explicit.agents.every((agent) => !agent.isDefault)).toBe(true); expect(explicit.agents.every((agent) => !agent.heartbeat.enabled)).toBe(true); + expect(explicit.heartbeatSeconds).toBe(0); }); it("projects the configured heartbeat owner's cadence", async () => { @@ -129,4 +130,34 @@ describe("collectGatewayHealthSnapshot legacy owner projection", () => { ); expect(health.heartbeatSeconds).toBe(5 * 60); }); + + it.each([ + { label: "an earlier agent", heartbeatAgentId: undefined }, + { label: "the configured owner", heartbeatAgentId: "ops" }, + ])( + "reports the active heartbeat when $label disables its cadence", + async ({ heartbeatAgentId }) => { + testConfig = { + agents: { + ownership: "explicit", + defaults: { + heartbeat: { + every: "30m", + ...(heartbeatAgentId ? { agentId: heartbeatAgentId } : {}), + }, + }, + entries: { + ops: { heartbeat: { every: "0m" } }, + research: { heartbeat: { every: "1h" } }, + }, + }, + }; + + const health = await collectGatewayHealthSnapshot({ audience: "admin", probe: false }); + + expect(health.agents.map((agent) => agent.agentId)).toEqual(["ops", "research"]); + expect(health.agents.map((agent) => agent.heartbeat.enabled)).toEqual([false, true]); + expect(health.heartbeatSeconds).toBe(60 * 60); + }, + ); }); diff --git a/src/gateway/health/collector.ts b/src/gateway/health/collector.ts index 96ff6a78321f..629a0669bcf2 100644 --- a/src/gateway/health/collector.ts +++ b/src/gateway/health/collector.ts @@ -232,7 +232,11 @@ export async function collectGatewayHealthSnapshot(params: { ); const heartbeatSummaryAgent = (configuredHeartbeatAgentId - ? agents.find((agent) => agent.agentId === normalizeAgentId(configuredHeartbeatAgentId)) + ? agents.find( + (agent) => + agent.heartbeat.enabled && + agent.agentId === normalizeAgentId(configuredHeartbeatAgentId), + ) : undefined) ?? agents.find((agent) => agent.heartbeat.enabled) ?? summaryAgent; diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 7e58af8a909f..44b770bc49a6 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -375,6 +375,43 @@ describe("resolveHeartbeatIntervalMs", () => { expect(resolveHeartbeatSummaryForAgent(cfg, "main").session).toBe("telegram:alerts"); }); + it.each([ + { + label: "global", + cfg: { + agents: { + defaults: { + heartbeat: { every: "0m", target: "last", session: "telegram:default" }, + }, + }, + }, + session: "telegram:default", + }, + { + label: "per-agent", + cfg: { + agents: { + defaults: { + heartbeat: { every: "30m", target: "last", session: "telegram:default" }, + }, + list: [{ id: "main", heartbeat: { every: "0m", session: "telegram:alerts" } }], + }, + }, + session: "telegram:alerts", + }, + ] satisfies Array<{ label: string; cfg: OpenClawConfig; session: string }>)( + "reports a disabled $label heartbeat as disabled", + ({ cfg, session }) => { + expect(resolveHeartbeatSummaryForAgent(cfg, "main")).toMatchObject({ + enabled: false, + every: "disabled", + everyMs: null, + target: "last", + session, + }); + }, + ); + it("returns default when unset", () => { expect(resolveHeartbeatIntervalMs({})).toBe(30 * 60_000); }); diff --git a/src/infra/heartbeat-summary.ts b/src/infra/heartbeat-summary.ts index 6d9e9d790308..69c0c8beda92 100644 --- a/src/infra/heartbeat-summary.ts +++ b/src/infra/heartbeat-summary.ts @@ -97,37 +97,17 @@ export function resolveHeartbeatSummaryForAgent( const defaults = cfg.agents?.defaults?.heartbeat; const overrides = agentId ? resolveAgentConfig(cfg, agentId)?.heartbeat : undefined; const merged = defaults || overrides ? { ...defaults, ...overrides } : undefined; - const enabled = isHeartbeatEnabledForAgent(cfg, agentId); - - if (!enabled) { - return { - enabled: false, - every: "disabled", - everyMs: null, - prompt: resolveHeartbeatPromptText(merged?.prompt), - target: merged?.target ?? DEFAULT_HEARTBEAT_TARGET, - model: merged?.model, - session: merged?.session, - ackMaxChars: DEFAULT_HEARTBEAT_ACK_MAX_CHARS, - }; - } - - const every = merged?.every ?? DEFAULT_HEARTBEAT_EVERY; const everyMs = resolveHeartbeatIntervalMs(cfg, undefined, merged); - const prompt = resolveHeartbeatPromptText(merged?.prompt); - const target = merged?.target ?? DEFAULT_HEARTBEAT_TARGET; - const model = merged?.model; - const session = merged?.session; - const ackMaxChars = DEFAULT_HEARTBEAT_ACK_MAX_CHARS; + const enabled = isHeartbeatEnabledForAgent(cfg, agentId) && everyMs !== null; return { - enabled: true, - every, - everyMs, - prompt, - target, - model, - session, - ackMaxChars, + enabled, + every: enabled ? (merged?.every ?? DEFAULT_HEARTBEAT_EVERY) : "disabled", + everyMs: enabled ? everyMs : null, + prompt: resolveHeartbeatPromptText(merged?.prompt), + target: merged?.target ?? DEFAULT_HEARTBEAT_TARGET, + model: merged?.model, + session: merged?.session, + ackMaxChars: DEFAULT_HEARTBEAT_ACK_MAX_CHARS, }; }