feat(FN-4020): surface timer repair stale health in agent health utilities

Adds health-surfacing logic for timer-triggered stale heartbeats in the engine, a new `agentHealth.tsx` dashboard utility with tests, and documentation updates for the repair behavior.

Fusion-Task-Id: FN-4020
This commit is contained in:
Fusion
2026-05-11 11:45:54 -07:00
committed by gsxdsm
parent 98aa09f822
commit 0157e66ff9
5 changed files with 158 additions and 2 deletions

View File

@@ -38,6 +38,10 @@ describe("HeartbeatTriggerScheduler", () => {
listAgents: vi.fn().mockResolvedValue([]),
on: vi.fn(),
off: vi.fn(),
updateAgent: vi.fn().mockImplementation(async (_id: string, updates: { metadata: Record<string, unknown> }) => ({
id: "agent-001",
metadata: updates.metadata,
})),
} as unknown as AgentStore;
});
@@ -104,6 +108,37 @@ describe("HeartbeatTriggerScheduler", () => {
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
});
it("marks repaired agent metadata as stale when last heartbeat is old", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T02:00:00.000Z"));
const agent = {
id: "agent-001",
name: "Agent 001",
role: "executor",
state: "active",
lastHeartbeatAt: "2026-01-01T00:00:00.000Z",
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
} as Agent;
vi.mocked(store.listAgents).mockResolvedValue([agent]);
vi.mocked(store.getActiveHeartbeatRun).mockResolvedValue(null);
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
await vi.advanceTimersByTimeAsync(0);
expect(store.updateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
metadata: expect.objectContaining({
heartbeatTimerRepair: expect.objectContaining({ staleAtRepair: true }),
}),
}),
);
});
it("skips audit re-arm when the agent already has an active heartbeat run", async () => {
vi.useFakeTimers();
const agent = {

View File

@@ -2855,6 +2855,26 @@ function isHeartbeatManaged(agent: Agent): boolean {
* - `heartbeatIntervalMs`: Timer interval (default 1h)
* - `maxConcurrentRuns`: Skip tick if agent already has an active run
*/
type HeartbeatTimerRepairMetadata = {
repairedAt?: string;
staleAtRepair?: boolean;
staleRepairReason?: string;
};
function readHeartbeatTimerRepairMetadata(agent: Agent): HeartbeatTimerRepairMetadata {
const metadata = (agent.metadata ?? {}) as Record<string, unknown>;
const raw = metadata.heartbeatTimerRepair;
if (!raw || typeof raw !== "object") {
return {};
}
const candidate = raw as Record<string, unknown>;
return {
repairedAt: typeof candidate.repairedAt === "string" ? candidate.repairedAt : undefined,
staleAtRepair: typeof candidate.staleAtRepair === "boolean" ? candidate.staleAtRepair : undefined,
staleRepairReason: typeof candidate.staleRepairReason === "string" ? candidate.staleRepairReason : undefined,
};
}
export class HeartbeatTriggerScheduler {
private store: AgentStore;
private callback: TriggerCallback;
@@ -2871,6 +2891,7 @@ export class HeartbeatTriggerScheduler {
private timerAuditIntervalHandle: ReturnType<typeof setInterval> | null = null;
private static readonly TIMER_AUDIT_INTERVAL_MS = 60_000;
private static readonly REPAIR_STALE_GRACE_MULTIPLIER = 1.5;
constructor(store: AgentStore, callback: TriggerCallback, taskStore?: TaskStore, options?: { isTaskExecuting?: (taskId: string) => boolean }) {
this.store = store;
@@ -3364,6 +3385,43 @@ export class HeartbeatTriggerScheduler {
}
}
private getRepairStaleThresholdMs(agent: Agent): number {
const config = this.getAgentTimerConfig(agent);
let rawIntervalMs = config.heartbeatIntervalMs;
if (!rawIntervalMs || typeof rawIntervalMs !== "number" || !Number.isFinite(rawIntervalMs) || rawIntervalMs <= 0) {
rawIntervalMs = HeartbeatTriggerScheduler.DEFAULT_HEARTBEAT_INTERVAL_MS;
}
const intervalMs = Math.max(1000, Math.round(rawIntervalMs));
return Math.round(intervalMs * HeartbeatTriggerScheduler.REPAIR_STALE_GRACE_MULTIPLIER);
}
private async markRepairMetadata(agent: Agent, staleAtRepair: boolean, staleRepairReason?: string): Promise<void> {
const updater = (this.store as { updateAgent?: (agentId: string, updates: { metadata: Record<string, unknown> }) => Promise<unknown> }).updateAgent;
if (typeof updater !== "function") {
return;
}
const existing = readHeartbeatTimerRepairMetadata(agent);
const repairedAt = new Date().toISOString();
const nextRepair: HeartbeatTimerRepairMetadata = {
repairedAt,
staleAtRepair,
...(staleAtRepair && staleRepairReason ? { staleRepairReason } : {}),
};
const didChange =
existing.repairedAt !== nextRepair.repairedAt ||
existing.staleAtRepair !== nextRepair.staleAtRepair ||
existing.staleRepairReason !== nextRepair.staleRepairReason;
if (!didChange) {
return;
}
const metadata = { ...(agent.metadata ?? {}) } as Record<string, unknown>;
metadata.heartbeatTimerRepair = nextRepair;
await updater.call(this.store, agent.id, { metadata });
}
async auditTimerRegistrations(reason: "start" | "interval" = "interval"): Promise<void> {
if (!this.running) return;
@@ -3383,8 +3441,18 @@ export class HeartbeatTriggerScheduler {
this.registerAgent(agent.id, this.getAgentTimerConfig(agent), {
lastHeartbeatAt: agent.lastHeartbeatAt,
});
const staleThresholdMs = this.getRepairStaleThresholdMs(agent);
const lastHeartbeatMs = agent.lastHeartbeatAt ? Date.parse(agent.lastHeartbeatAt) : Number.NaN;
const elapsedMs = Number.isFinite(lastHeartbeatMs) ? Date.now() - lastHeartbeatMs : Number.NaN;
const staleAtRepair = Number.isFinite(elapsedMs) && elapsedMs > staleThresholdMs;
const staleRepairReason = staleAtRepair
? `No heartbeat for ${Math.round(elapsedMs / 1000)}s before timer audit repair (threshold ${Math.round(staleThresholdMs / 1000)}s)`
: undefined;
await this.markRepairMetadata(agent, staleAtRepair, staleRepairReason);
rearmedCount++;
heartbeatLog.log(`Timer re-armed for ${agent.id} (audit:${reason})`);
heartbeatLog.log(`Timer re-armed for ${agent.id} (audit:${reason}${staleAtRepair ? ", stale" : ""})`);
}
if (rearmedCount > 0) {