feat: move heartbeat config to agent.heartbeat

This commit is contained in:
Peter Steinberger
2025-12-26 01:13:13 +01:00
parent 1ef888ca23
commit 9f7b1f0942
10 changed files with 138 additions and 47 deletions

View File

@@ -21,7 +21,7 @@ import {
HEARTBEAT_TOKEN,
monitorWebProvider,
resolveHeartbeatRecipients,
resolveReplyHeartbeatMinutes,
resolveReplyHeartbeatIntervalMs,
runWebHeartbeatOnce,
SILENT_REPLY_TOKEN,
stripHeartbeatToken,
@@ -157,20 +157,25 @@ describe("heartbeat helpers", () => {
});
});
it("resolves heartbeat minutes with default and overrides", () => {
it("resolves reply heartbeat interval from config and overrides", () => {
const cfgBase: ClawdisConfig = {};
expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30);
expect(resolveReplyHeartbeatIntervalMs(cfgBase)).toBeNull();
expect(
resolveReplyHeartbeatMinutes({
agent: { heartbeatMinutes: 5 },
resolveReplyHeartbeatIntervalMs({
agent: { heartbeat: { every: "5m" } },
}),
).toBe(5);
).toBe(5 * 60_000);
expect(
resolveReplyHeartbeatMinutes({
agent: { heartbeatMinutes: 0 },
resolveReplyHeartbeatIntervalMs({
agent: { heartbeat: { every: "0m" } },
}),
).toBeNull();
expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7);
expect(resolveReplyHeartbeatIntervalMs(cfgBase, "7m")).toBe(7 * 60_000);
expect(
resolveReplyHeartbeatIntervalMs({
agent: { heartbeat: { every: "5" } },
}),
).toBe(5 * 60_000);
});
});
@@ -506,7 +511,6 @@ describe("runWebHeartbeatOnce", () => {
);
setLoadConfigMock(() => ({
agent: { heartbeatMinutes: 0.001 },
routing: {
allowFrom: ["+4367"],
},
@@ -774,7 +778,7 @@ describe("web auto-reply", () => {
replyResolver,
runtime,
controller.signal,
{ replyHeartbeatMinutes: 1, replyHeartbeatNow: true },
{ replyHeartbeatEvery: "1m", replyHeartbeatNow: true },
);
try {
@@ -833,7 +837,7 @@ describe("web auto-reply", () => {
replyResolver,
runtime,
controller.signal,
{ replyHeartbeatMinutes: 10_000 },
{ replyHeartbeatEvery: "10000m" },
);
try {

View File

@@ -7,6 +7,7 @@ import {
import { getReplyFromConfig } from "../auto-reply/reply.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { waitForever } from "../cli/wait.js";
import { loadConfig } from "../config/config.js";
import {
@@ -72,7 +73,7 @@ type WebInboundMsg = Parameters<
export type WebMonitorTuning = {
reconnect?: Partial<ReconnectPolicy>;
heartbeatSeconds?: number;
replyHeartbeatMinutes?: number;
replyHeartbeatEvery?: string;
replyHeartbeatNow?: boolean;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
statusSink?: (status: WebProviderStatus) => void;
@@ -81,7 +82,6 @@ export type WebMonitorTuning = {
const formatDuration = (ms: number) =>
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30;
export const HEARTBEAT_PROMPT = "HEARTBEAT";
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN };
@@ -188,14 +188,22 @@ function debugMention(
return { wasMentioned: result, details };
}
export function resolveReplyHeartbeatMinutes(
export function resolveReplyHeartbeatIntervalMs(
cfg: ReturnType<typeof loadConfig>,
overrideMinutes?: number,
overrideEvery?: string,
) {
const raw = overrideMinutes ?? cfg.agent?.heartbeatMinutes;
if (raw === 0) return null;
if (typeof raw === "number" && raw > 0) return raw;
return DEFAULT_REPLY_HEARTBEAT_MINUTES;
const raw = overrideEvery ?? cfg.agent?.heartbeat?.every;
if (!raw) return null;
const trimmed = String(raw).trim();
if (!trimmed) return null;
let ms: number;
try {
ms = parseDurationMs(trimmed, { defaultUnit: "m" });
} catch {
return null;
}
if (ms <= 0) return null;
return ms;
}
export function stripHeartbeatToken(raw?: string) {
@@ -767,9 +775,9 @@ export async function monitorWebProvider(
cfg,
tuning.heartbeatSeconds,
);
const replyHeartbeatMinutes = resolveReplyHeartbeatMinutes(
const replyHeartbeatIntervalMs = resolveReplyHeartbeatIntervalMs(
cfg,
tuning.replyHeartbeatMinutes,
tuning.replyHeartbeatEvery,
);
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
const mentionConfig = buildMentionConfig(cfg);
@@ -939,7 +947,7 @@ export async function monitorWebProvider(
let lastInboundMsg: WebInboundMsg | null = null;
// Watchdog to detect stuck message processing (e.g., event emitter died)
// Should be significantly longer than heartbeatMinutes to avoid false positives
// Should be significantly longer than the reply heartbeat interval to avoid false positives
const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages
const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute
@@ -1428,7 +1436,7 @@ export async function monitorWebProvider(
}
return { status: "skipped", reason: "requests-in-flight" };
}
if (!replyHeartbeatMinutes) {
if (!replyHeartbeatIntervalMs) {
return { status: "skipped", reason: "disabled" };
}
let heartbeatInboundMsg = lastInboundMsg;
@@ -1510,7 +1518,7 @@ export async function monitorWebProvider(
{
connectionId,
to: heartbeatInboundMsg.from,
intervalMinutes: replyHeartbeatMinutes,
intervalMs: replyHeartbeatIntervalMs,
sessionKey: snapshot.key,
sessionId: snapshot.entry?.sessionId ?? null,
sessionFresh: snapshot.fresh,
@@ -1635,8 +1643,8 @@ export async function monitorWebProvider(
setReplyHeartbeatWakeHandler(async () => runReplyHeartbeat());
if (replyHeartbeatMinutes && !replyHeartbeatTimer) {
const intervalMs = replyHeartbeatMinutes * 60_000;
if (replyHeartbeatIntervalMs && !replyHeartbeatTimer) {
const intervalMs = replyHeartbeatIntervalMs;
replyHeartbeatTimer = setInterval(() => {
if (!heartbeatsEnabled) return;
void runReplyHeartbeat();