fix: finish model list alias + heartbeat session (#1256) (thanks @zknicker)

This commit is contained in:
Peter Steinberger
2026-01-22 01:08:11 +00:00
parent 7725dd6795
commit 39073d5196
9 changed files with 259 additions and 117 deletions

View File

@@ -478,7 +478,9 @@ describe("runHeartbeatOnce", () => {
peerKind: "group",
peerId: groupId,
});
cfg.agents?.defaults?.heartbeat && (cfg.agents.defaults.heartbeat.session = groupSessionKey);
if (cfg.agents?.defaults?.heartbeat) {
cfg.agents.defaults.heartbeat.session = groupSessionKey;
}
await fs.writeFile(
storePath,

View File

@@ -1,4 +1,5 @@
import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveUserTimezone } from "../agents/date-time.js";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
@@ -14,8 +15,8 @@ import { parseDurationMs } from "../cli/parse-duration.js";
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
canonicalizeMainSessionAlias,
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveAgentMainSessionKey,
resolveStorePath,
@@ -26,6 +27,7 @@ import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
import { emitHeartbeatEvent } from "./heartbeat-events.js";
@@ -70,6 +72,94 @@ export type HeartbeatSummary = {
};
const DEFAULT_HEARTBEAT_TARGET = "last";
const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/;
function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
const trimmed = raw?.trim();
if (!trimmed || trimmed === "user") {
return resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
}
if (trimmed === "local") {
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
return host?.trim() || "UTC";
}
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
return trimmed;
} catch {
return resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
}
}
function parseActiveHoursTime(opts: { allow24: boolean }, raw?: string): number | null {
if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null;
const [hourStr, minuteStr] = raw.split(":");
const hour = Number(hourStr);
const minute = Number(minuteStr);
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
if (hour === 24) {
if (!opts.allow24 || minute !== 0) return null;
return 24 * 60;
}
return hour * 60 + minute;
}
function resolveMinutesInTimeZone(nowMs: number, timeZone: string): number | null {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(new Date(nowMs));
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
const hour = Number(map.hour);
const minute = Number(map.minute);
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
return hour * 60 + minute;
} catch {
return null;
}
}
function isWithinActiveHours(
cfg: ClawdbotConfig,
heartbeat?: HeartbeatConfig,
nowMs?: number,
): boolean {
const active = heartbeat?.activeHours;
if (!active) return true;
const startMin = parseActiveHoursTime({ allow24: false }, active.start);
const endMin = parseActiveHoursTime({ allow24: true }, active.end);
if (startMin === null || endMin === null) return true;
if (startMin === endMin) return true;
const timeZone = resolveActiveHoursTimezone(cfg, active.timezone);
const currentMin = resolveMinutesInTimeZone(nowMs ?? Date.now(), timeZone);
if (currentMin === null) return true;
if (endMin > startMin) {
return currentMin >= startMin && currentMin < endMin;
}
return currentMin >= startMin || currentMin < endMin;
}
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 ?? [];
@@ -365,12 +455,16 @@ export async function runHeartbeatOnce(opts: {
return { status: "skipped", reason: "disabled" };
}
const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)("main");
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
if (!isWithinActiveHours(cfg, heartbeat, startedAt)) {
return { status: "skipped", reason: "quiet-hours" };
}
const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)(CommandLane.Main);
if (queueSize > 0) {
return { status: "skipped", reason: "requests-in-flight" };
}
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
const previousUpdatedAt = entry?.updatedAt;
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
@@ -576,24 +670,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;
}
@@ -603,52 +770,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 };
}