feat(FN-3319): enrich agent heartbeat callbacks with reason in UI
Enriched agent heartbeat callbacks with a `reason` parameter propagated through the engine, in-process runtime, and dashboard components (AgentDetailView, AgentListModal, AgentsView). Added a new `agentHealth.tsx` utility and corresponding test file to surface the reason in UI health status, with te Fusion-Task-Id: FN-3319
This commit is contained in:
@@ -657,7 +657,7 @@ describe("HeartbeatMonitor", () => {
|
||||
// Wait for async checkMissedHeartbeats
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(onMissed).toHaveBeenCalledWith("agent-001");
|
||||
expect(onMissed).toHaveBeenCalledWith("agent-001", expect.any(String));
|
||||
|
||||
customMonitor.stop();
|
||||
vi.useRealTimers();
|
||||
@@ -711,7 +711,7 @@ describe("HeartbeatMonitor", () => {
|
||||
|
||||
expect(session.dispose).toHaveBeenCalled();
|
||||
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "terminated");
|
||||
expect(onTerminated).toHaveBeenCalledWith("agent-001");
|
||||
expect(onTerminated).toHaveBeenCalledWith("agent-001", expect.any(String));
|
||||
|
||||
customMonitor.stop();
|
||||
vi.useRealTimers();
|
||||
@@ -769,7 +769,7 @@ describe("HeartbeatMonitor", () => {
|
||||
const warnMessages = warnSpy.mock.calls.map(([message]) => String(message));
|
||||
expect(warnMessages.some((message) => message.includes("Error disposing session for agent-001") && message.includes("dispose exploded"))).toBe(true);
|
||||
expect(updateAgentState).toHaveBeenCalledWith("agent-001", "terminated");
|
||||
expect(onTerminated).toHaveBeenCalledWith("agent-001");
|
||||
expect(onTerminated).toHaveBeenCalledWith("agent-001", expect.any(String));
|
||||
expect(customMonitor.getTrackedAgents()).toHaveLength(0);
|
||||
|
||||
customMonitor.stop();
|
||||
@@ -800,7 +800,7 @@ describe("HeartbeatMonitor", () => {
|
||||
|
||||
const warnMessages = warnSpy.mock.calls.map(([message]) => String(message));
|
||||
expect(warnMessages.some((message) => message.includes("Error terminating agent agent-001") && message.includes("db connection lost"))).toBe(true);
|
||||
expect(onTerminated).toHaveBeenCalledWith("agent-001");
|
||||
expect(onTerminated).toHaveBeenCalledWith("agent-001", expect.any(String));
|
||||
expect(customMonitor.getTrackedAgents()).toHaveLength(0);
|
||||
|
||||
customMonitor.stop();
|
||||
@@ -834,10 +834,11 @@ describe("HeartbeatMonitor", () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const warnMessages = warnSpy.mock.calls.map(([message]) => String(message));
|
||||
expect(warnMessages).toHaveLength(2);
|
||||
expect(warnMessages).toHaveLength(3);
|
||||
expect(warnMessages.some((message) => message.includes("Terminating unresponsive agent agent-001"))).toBe(true);
|
||||
expect(warnMessages.some((message) => message.includes("Error disposing session for agent-001") && message.includes("dispose exploded"))).toBe(true);
|
||||
expect(warnMessages.some((message) => message.includes("Error terminating agent agent-001") && message.includes("db connection lost"))).toBe(true);
|
||||
expect(onTerminated).toHaveBeenCalledWith("agent-001");
|
||||
expect(onTerminated).toHaveBeenCalledWith("agent-001", expect.any(String));
|
||||
expect(customMonitor.getTrackedAgents()).toHaveLength(0);
|
||||
|
||||
customMonitor.stop();
|
||||
@@ -1137,7 +1138,7 @@ describe("HeartbeatMonitor", () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(onMissed).toHaveBeenCalledWith("agent-001");
|
||||
expect(onMissed).toHaveBeenCalledWith("agent-001", expect.any(String));
|
||||
|
||||
monitor.stop();
|
||||
vi.useRealTimers();
|
||||
@@ -1167,7 +1168,7 @@ describe("HeartbeatMonitor", () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(session.dispose).toHaveBeenCalled();
|
||||
expect(onTerminated).toHaveBeenCalledWith("agent-001");
|
||||
expect(onTerminated).toHaveBeenCalledWith("agent-001", expect.any(String));
|
||||
|
||||
monitor.stop();
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -52,11 +52,11 @@ export interface HeartbeatMonitorOptions {
|
||||
/** Max concurrent runs per agent (default: 1) */
|
||||
maxConcurrentRuns?: number;
|
||||
/** Callback when an agent misses its heartbeat */
|
||||
onMissed?: (agentId: string) => void;
|
||||
onMissed?: (agentId: string, reason: string) => void;
|
||||
/** Callback when an agent recovers after a missed heartbeat */
|
||||
onRecovered?: (agentId: string) => void;
|
||||
/** Callback when an unresponsive agent is terminated */
|
||||
onTerminated?: (agentId: string) => void;
|
||||
onTerminated?: (agentId: string, reason: string) => void;
|
||||
/** Callback when a run starts */
|
||||
onRunStarted?: (agentId: string, run: AgentHeartbeatRun) => void;
|
||||
/** Callback when a run completes */
|
||||
@@ -116,6 +116,17 @@ interface TrackedAgent {
|
||||
sessionIdBefore?: string;
|
||||
}
|
||||
|
||||
/** Format milliseconds into a human-readable duration string (e.g. "5m", "1h 20m", "2h"). */
|
||||
export function formatDuration(ms: number): string {
|
||||
const totalMinutes = Math.floor(ms / 60_000);
|
||||
if (totalMinutes < 1) return "<1m";
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `${hours}h`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
/** Compare blocked-state snapshots to decide whether blocked messaging is duplicate noise. */
|
||||
export function isBlockedStateDuplicate(current: BlockedStateSnapshot, previous: BlockedStateSnapshot): boolean {
|
||||
return current.blockedBy === previous.blockedBy && current.contextHash === previous.contextHash;
|
||||
@@ -429,9 +440,9 @@ export class HeartbeatMonitor {
|
||||
private pollIntervalMs: number;
|
||||
private heartbeatTimeoutMs: number;
|
||||
private maxConcurrentRuns: number;
|
||||
private onMissed?: (agentId: string) => void;
|
||||
private onMissed?: (agentId: string, reason: string) => void;
|
||||
private onRecovered?: (agentId: string) => void;
|
||||
private onTerminated?: (agentId: string) => void;
|
||||
private onTerminated?: (agentId: string, reason: string) => void;
|
||||
private onRunStarted?: (agentId: string, run: AgentHeartbeatRun) => void;
|
||||
private onRunCompleted?: (agentId: string, run: AgentHeartbeatRun) => void;
|
||||
private taskStore?: TaskStore;
|
||||
@@ -1975,30 +1986,37 @@ export class HeartbeatMonitor {
|
||||
const elapsed = now - tracked.lastSeen;
|
||||
|
||||
if (elapsed >= config.heartbeatTimeoutMs) {
|
||||
const reason = `No heartbeat for ${formatDuration(elapsed)} (threshold: ${formatDuration(config.heartbeatTimeoutMs)})`;
|
||||
// Missed heartbeat detected
|
||||
if (!tracked.missedHeartbeatReported) {
|
||||
tracked.missedHeartbeatReported = true;
|
||||
await this.handleMissedHeartbeat(tracked);
|
||||
await this.handleMissedHeartbeat(tracked, reason);
|
||||
} else {
|
||||
// Already reported - check if we should terminate
|
||||
// Give 2x timeout for recovery before auto-terminate
|
||||
if (elapsed >= config.heartbeatTimeoutMs * 2) {
|
||||
await this.terminateUnresponsive(tracked);
|
||||
await this.terminateUnresponsive(tracked, config.heartbeatTimeoutMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMissedHeartbeat(tracked: TrackedAgent): Promise<void> {
|
||||
private async handleMissedHeartbeat(tracked: TrackedAgent, reason: string): Promise<void> {
|
||||
// Record missed heartbeat
|
||||
await this.store.recordHeartbeat(tracked.agentId, "missed", tracked.runId);
|
||||
|
||||
// Notify callback
|
||||
this.onMissed?.(tracked.agentId);
|
||||
this.onMissed?.(tracked.agentId, reason);
|
||||
}
|
||||
|
||||
private async terminateUnresponsive(tracked: TrackedAgent): Promise<void> {
|
||||
private async terminateUnresponsive(tracked: TrackedAgent, heartbeatTimeoutMs: number): Promise<void> {
|
||||
const now = Date.now();
|
||||
const elapsed = now - tracked.lastSeen;
|
||||
const reason = `No heartbeat for ${formatDuration(elapsed)} (2× timeout threshold: ${formatDuration(heartbeatTimeoutMs * 2)})`;
|
||||
|
||||
heartbeatLog.warn(`Terminating unresponsive agent ${tracked.agentId}: ${reason}`);
|
||||
|
||||
// Dispose the session
|
||||
try {
|
||||
tracked.session.dispose();
|
||||
@@ -2018,7 +2036,7 @@ export class HeartbeatMonitor {
|
||||
this.trackedAgents.delete(tracked.agentId);
|
||||
|
||||
// Notify callback
|
||||
this.onTerminated?.(tracked.agentId);
|
||||
this.onTerminated?.(tracked.agentId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -478,11 +478,11 @@ export class InProcessRuntime
|
||||
rootDir: this.config.workingDirectory,
|
||||
messageStore: this.messageStore,
|
||||
pluginRunner: this.pluginRunner,
|
||||
onMissed: (agentId) => {
|
||||
runtimeLog.warn(`Agent ${agentId} missed heartbeat`);
|
||||
onMissed: (agentId, reason) => {
|
||||
runtimeLog.warn(`Agent ${agentId} missed heartbeat: ${reason}`);
|
||||
},
|
||||
onTerminated: (agentId) => {
|
||||
runtimeLog.warn(`Agent ${agentId} terminated (unresponsive)`);
|
||||
onTerminated: (agentId, reason) => {
|
||||
runtimeLog.warn(`Agent ${agentId} terminated (unresponsive): ${reason}`);
|
||||
},
|
||||
});
|
||||
this.heartbeatMonitor.start();
|
||||
|
||||
Reference in New Issue
Block a user