Files
clawdbot/src/infra/heartbeat-wake.ts
2025-12-26 09:08:37 +00:00

76 lines
1.9 KiB
TypeScript

export type HeartbeatRunResult =
| { status: "ran"; durationMs: number }
| { status: "skipped"; reason: string }
| { status: "failed"; reason: string };
export type HeartbeatWakeHandler = (opts: {
reason?: string;
}) => Promise<HeartbeatRunResult>;
let handler: HeartbeatWakeHandler | null = null;
let pendingReason: string | null = null;
let scheduled = false;
let running = false;
let timer: NodeJS.Timeout | null = null;
const DEFAULT_COALESCE_MS = 250;
const DEFAULT_RETRY_MS = 1_000;
function schedule(coalesceMs: number) {
if (timer) return;
timer = setTimeout(async () => {
timer = null;
scheduled = false;
const active = handler;
if (!active) return;
if (running) {
scheduled = true;
schedule(coalesceMs);
return;
}
const reason = pendingReason;
pendingReason = null;
running = true;
try {
const res = await active({ reason: reason ?? undefined });
if (res.status === "skipped" && res.reason === "requests-in-flight") {
// The main lane is busy; retry soon.
pendingReason = reason ?? "retry";
schedule(DEFAULT_RETRY_MS);
}
} catch (err) {
pendingReason = reason ?? "retry";
schedule(DEFAULT_RETRY_MS);
throw err;
} finally {
running = false;
if (pendingReason || scheduled) schedule(coalesceMs);
}
}, coalesceMs);
timer.unref?.();
}
export function setHeartbeatWakeHandler(next: HeartbeatWakeHandler | null) {
handler = next;
if (handler && pendingReason) {
schedule(DEFAULT_COALESCE_MS);
}
}
export function requestHeartbeatNow(opts?: {
reason?: string;
coalesceMs?: number;
}) {
pendingReason = opts?.reason ?? pendingReason ?? "requested";
schedule(opts?.coalesceMs ?? DEFAULT_COALESCE_MS);
}
export function hasHeartbeatWakeHandler() {
return handler !== null;
}
export function hasPendingHeartbeatWake() {
return pendingReason !== null || Boolean(timer) || scheduled;
}