fix: reschedule heartbeat on hot reload
Co-authored-by: Seb Slight <sebslight@users.noreply.github.com>
This commit is contained in:
@@ -70,6 +70,19 @@ export type HeartbeatSummary = {
|
||||
|
||||
const DEFAULT_HEARTBEAT_TARGET = "last";
|
||||
|
||||
type HeartbeatAgentState = {
|
||||
agentId: string;
|
||||
heartbeat?: HeartbeatConfig;
|
||||
intervalMs: number;
|
||||
lastRunMs?: number;
|
||||
nextDueMs: number;
|
||||
};
|
||||
|
||||
export type HeartbeatRunner = {
|
||||
stop: () => void;
|
||||
updateConfig: (cfg: ClawdbotConfig) => void;
|
||||
};
|
||||
|
||||
function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) {
|
||||
const list = cfg.agents?.list ?? [];
|
||||
return list.some((entry) => Boolean(entry?.heartbeat));
|
||||
@@ -539,24 +552,97 @@ export function startHeartbeatRunner(opts: {
|
||||
cfg?: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
}) {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const heartbeatAgents = resolveHeartbeatAgents(cfg);
|
||||
const intervals = heartbeatAgents
|
||||
.map((agent) => resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat))
|
||||
.filter((value): value is number => typeof value === "number");
|
||||
const intervalMs = intervals.length > 0 ? Math.min(...intervals) : null;
|
||||
if (!intervalMs) {
|
||||
log.info("heartbeat: disabled", { enabled: false });
|
||||
}
|
||||
|
||||
runOnce?: typeof runHeartbeatOnce;
|
||||
}): HeartbeatRunner {
|
||||
const runtime = opts.runtime ?? defaultRuntime;
|
||||
const lastRunByAgent = new Map<string, number>();
|
||||
const runOnce = opts.runOnce ?? runHeartbeatOnce;
|
||||
const state = {
|
||||
cfg: opts.cfg ?? loadConfig(),
|
||||
runtime,
|
||||
agents: new Map<string, HeartbeatAgentState>(),
|
||||
timer: null as NodeJS.Timeout | null,
|
||||
stopped: false,
|
||||
};
|
||||
let initialized = false;
|
||||
|
||||
const resolveNextDue = (now: number, intervalMs: number, prevState?: HeartbeatAgentState) => {
|
||||
if (typeof prevState?.lastRunMs === "number") {
|
||||
return prevState.lastRunMs + intervalMs;
|
||||
}
|
||||
if (prevState && prevState.intervalMs === intervalMs && prevState.nextDueMs > now) {
|
||||
return prevState.nextDueMs;
|
||||
}
|
||||
return now + intervalMs;
|
||||
};
|
||||
|
||||
const scheduleNext = () => {
|
||||
if (state.stopped) return;
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
if (state.agents.size === 0) return;
|
||||
const now = Date.now();
|
||||
let nextDue = Number.POSITIVE_INFINITY;
|
||||
for (const agent of state.agents.values()) {
|
||||
if (agent.nextDueMs < nextDue) nextDue = agent.nextDueMs;
|
||||
}
|
||||
if (!Number.isFinite(nextDue)) return;
|
||||
const delay = Math.max(0, nextDue - now);
|
||||
state.timer = setTimeout(() => {
|
||||
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
||||
}, delay);
|
||||
state.timer.unref?.();
|
||||
};
|
||||
|
||||
const updateConfig = (cfg: ClawdbotConfig) => {
|
||||
if (state.stopped) return;
|
||||
const now = Date.now();
|
||||
const prevAgents = state.agents;
|
||||
const prevEnabled = prevAgents.size > 0;
|
||||
const nextAgents = new Map<string, HeartbeatAgentState>();
|
||||
const intervals: number[] = [];
|
||||
for (const agent of resolveHeartbeatAgents(cfg)) {
|
||||
const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
|
||||
if (!intervalMs) continue;
|
||||
intervals.push(intervalMs);
|
||||
const prevState = prevAgents.get(agent.agentId);
|
||||
const nextDueMs = resolveNextDue(now, intervalMs, prevState);
|
||||
nextAgents.set(agent.agentId, {
|
||||
agentId: agent.agentId,
|
||||
heartbeat: agent.heartbeat,
|
||||
intervalMs,
|
||||
lastRunMs: prevState?.lastRunMs,
|
||||
nextDueMs,
|
||||
});
|
||||
}
|
||||
|
||||
state.cfg = cfg;
|
||||
state.agents = nextAgents;
|
||||
const nextEnabled = nextAgents.size > 0;
|
||||
if (!initialized) {
|
||||
if (!nextEnabled) {
|
||||
log.info("heartbeat: disabled", { enabled: false });
|
||||
} else {
|
||||
log.info("heartbeat: started", { intervalMs: Math.min(...intervals) });
|
||||
}
|
||||
initialized = true;
|
||||
} else if (prevEnabled !== nextEnabled) {
|
||||
if (!nextEnabled) {
|
||||
log.info("heartbeat: disabled", { enabled: false });
|
||||
} else {
|
||||
log.info("heartbeat: started", { intervalMs: Math.min(...intervals) });
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNext();
|
||||
};
|
||||
|
||||
const run: HeartbeatWakeHandler = async (params) => {
|
||||
if (!heartbeatsEnabled) {
|
||||
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
||||
}
|
||||
if (heartbeatAgents.length === 0) {
|
||||
if (state.agents.size === 0) {
|
||||
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
||||
}
|
||||
|
||||
@@ -566,52 +652,44 @@ export function startHeartbeatRunner(opts: {
|
||||
const now = startedAt;
|
||||
let ran = false;
|
||||
|
||||
for (const agent of heartbeatAgents) {
|
||||
const agentIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
|
||||
if (!agentIntervalMs) continue;
|
||||
const lastRun = lastRunByAgent.get(agent.agentId);
|
||||
if (isInterval && typeof lastRun === "number" && now - lastRun < agentIntervalMs) {
|
||||
for (const agent of state.agents.values()) {
|
||||
if (isInterval && now < agent.nextDueMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
const res = await runOnce({
|
||||
cfg: state.cfg,
|
||||
agentId: agent.agentId,
|
||||
heartbeat: agent.heartbeat,
|
||||
reason,
|
||||
deps: { runtime },
|
||||
deps: { runtime: state.runtime },
|
||||
});
|
||||
if (res.status === "skipped" && res.reason === "requests-in-flight") {
|
||||
return res;
|
||||
}
|
||||
if (res.status !== "skipped" || res.reason !== "disabled") {
|
||||
lastRunByAgent.set(agent.agentId, now);
|
||||
agent.lastRunMs = now;
|
||||
agent.nextDueMs = now + agent.intervalMs;
|
||||
}
|
||||
if (res.status === "ran") ran = true;
|
||||
}
|
||||
|
||||
scheduleNext();
|
||||
if (ran) return { status: "ran", durationMs: Date.now() - startedAt };
|
||||
return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
|
||||
};
|
||||
|
||||
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (intervalMs) {
|
||||
timer = setInterval(() => {
|
||||
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
||||
}, intervalMs);
|
||||
timer.unref?.();
|
||||
log.info("heartbeat: started", { intervalMs });
|
||||
}
|
||||
updateConfig(state.cfg);
|
||||
|
||||
const cleanup = () => {
|
||||
state.stopped = true;
|
||||
setHeartbeatWakeHandler(null);
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
if (state.timer) clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
};
|
||||
|
||||
opts.abortSignal?.addEventListener("abort", cleanup, { once: true });
|
||||
|
||||
return { stop: cleanup };
|
||||
return { stop: cleanup, updateConfig };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user