agentHealth.tsx: - Drop the "Disabled" label and isHeartbeatEnabled helper. Server-side heartbeat scheduling is driven by agent.state (active/running = armed), not by the legacy runtimeConfig.enabled flag, so surfacing "Disabled" for agents with a stale flag on disk was misleading. - Replace the elapsed > heartbeatTimeoutMs check with elapsed > 2× effective interval (with a 60s floor). heartbeatTimeoutMs is the per-run work budget, not a between-tick freshness window; using it made any agent configured with a multi-minute+ interval show Unresponsive the moment it got more than 60s old. The effective interval resolver now falls back to the server-side 1h default, so agents never configured via the dropdown and agents with an explicit interval get consistent treatment, differing only by scheduled cadence. AgentDetailView.tsx: - Remove Stop buttons from active/paused/running/error (mirrors the earlier AgentsView cleanup — Pause/Resume/Retry cover reversible transitions, Delete stays on idle/terminated for teardown). - Add a "Next Heartbeat" info row alongside "Last Heartbeat" for ticking agents (active/running), computed as lastHeartbeatAt + effective interval. Tests rewritten to cover the new semantics; dropped 14 obsolete cases that were pinned to the old heartbeatTimeoutMs-based check and the "Disabled" branch. 42 agentHealth tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
183 lines
6.1 KiB
TypeScript
183 lines
6.1 KiB
TypeScript
import type { JSX } from "react";
|
||
import { Bot, Heart, Activity, Pause, Square } from "lucide-react";
|
||
import type { Agent } from "../api";
|
||
import { resolveHeartbeatIntervalMs } from "./heartbeatIntervals";
|
||
|
||
// Heartbeat scheduling is driven by `agent.state` on the server — active and
|
||
// running tick, everything else does not. There is no separate "heartbeat
|
||
// enabled" flag surfaced in the UI, so this file derives freshness straight
|
||
// from state + lastHeartbeatAt and ignores any legacy `runtimeConfig.enabled`
|
||
// value that may still be persisted on older agent records.
|
||
|
||
/**
|
||
* Grace multiplier applied to an agent's configured interval before flagging
|
||
* it Unresponsive. A human reads "missed two scheduled ticks" as "something
|
||
* is wrong", which is what 2× captures; this also tolerates timer jitter and
|
||
* a paused engine restarting without causing a UI flicker.
|
||
*/
|
||
const HEARTBEAT_GRACE_MULTIPLIER = 2;
|
||
|
||
/**
|
||
* Staleness floor. Even on an agent configured for 1s heartbeats we don't
|
||
* want the UI flickering between Healthy/Unresponsive on every tick.
|
||
*/
|
||
const MIN_HEARTBEAT_STALENESS_MS = 60_000;
|
||
|
||
/** Shape of the health status returned by getAgentHealthStatus */
|
||
export interface AgentHealthStatus {
|
||
label: string;
|
||
icon: JSX.Element;
|
||
color: string;
|
||
/** True when label only mirrors agent.state and adds no extra context */
|
||
stateDerived: boolean;
|
||
}
|
||
|
||
type AgentHealthInput = Pick<
|
||
Agent,
|
||
| "state"
|
||
| "lastHeartbeatAt"
|
||
| "lastError"
|
||
| "pauseReason"
|
||
| "runtimeConfig"
|
||
| "metadata"
|
||
| "name"
|
||
| "role"
|
||
| "taskId"
|
||
>;
|
||
|
||
/**
|
||
* Compute the staleness threshold for an agent. Elapsed time beyond this is
|
||
* classified as Unresponsive.
|
||
*
|
||
* Uses the same interval resolver as the dashboard dropdown — if the agent
|
||
* has no explicit heartbeatIntervalMs persisted, the server-side default
|
||
* (1h) applies — so agents that were never configured (no dropdown write)
|
||
* and agents that were explicitly configured both get consistent treatment,
|
||
* differing only by their scheduled cadence.
|
||
*/
|
||
function getStalenessThresholdMs(runtimeConfig?: Record<string, unknown>): number {
|
||
const intervalMs = resolveHeartbeatIntervalMs(runtimeConfig?.heartbeatIntervalMs);
|
||
return Math.max(intervalMs * HEARTBEAT_GRACE_MULTIPLIER, MIN_HEARTBEAT_STALENESS_MS);
|
||
}
|
||
|
||
function isTaskWorkerAgent(agent: AgentHealthInput): boolean {
|
||
const metadata = agent.metadata as Record<string, unknown> | null | undefined;
|
||
if (metadata) {
|
||
if (metadata.agentKind === "task-worker") return true;
|
||
if (metadata.taskWorker === true) return true;
|
||
if (metadata.managedBy === "task-executor") return true;
|
||
}
|
||
|
||
return Boolean(
|
||
agent.role === "executor" &&
|
||
agent.name?.startsWith("executor-") &&
|
||
agent.taskId,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Computes a single canonical health status for an agent based on its
|
||
* state, runtimeConfig, and last heartbeat timestamp.
|
||
*
|
||
* Health labels (in priority order):
|
||
* - "Terminated" — agent.state === "terminated"
|
||
* - "Error" — agent.state === "error" (uses lastError if available)
|
||
* - "Paused" — agent.state === "paused" (uses pauseReason if available)
|
||
* - "Running" — agent.state === "running", or a detected task worker in "active"
|
||
* - "Starting..." — state === "active" && no lastHeartbeatAt
|
||
* - "Idle" — state !== "active" && no lastHeartbeatAt
|
||
* - "Healthy" — heartbeat is fresh within 2× the configured interval
|
||
* - "Unresponsive" — heartbeat exceeded 2× the configured interval
|
||
*
|
||
* @param agent - The agent object (partial Agent shape is accepted)
|
||
* @returns A health status object with label, icon, color, and stateDerived metadata
|
||
*/
|
||
export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus {
|
||
const { state, lastHeartbeatAt, lastError, pauseReason, runtimeConfig } = agent;
|
||
const isTaskWorker = isTaskWorkerAgent(agent);
|
||
|
||
// Terminal states - these always take precedence
|
||
if (state === "terminated") {
|
||
return {
|
||
label: "Terminated",
|
||
icon: <Square size={14} />,
|
||
color: "var(--state-error-text)",
|
||
stateDerived: true,
|
||
};
|
||
}
|
||
|
||
if (state === "error") {
|
||
return {
|
||
label: lastError ?? "Error",
|
||
icon: <Activity size={14} />,
|
||
color: "var(--state-error-text)",
|
||
stateDerived: !lastError,
|
||
};
|
||
}
|
||
|
||
if (state === "paused") {
|
||
const label = pauseReason ? `Paused: ${pauseReason}` : "Paused";
|
||
return {
|
||
label,
|
||
icon: <Pause size={14} />,
|
||
color: "var(--state-paused-text)",
|
||
stateDerived: !pauseReason,
|
||
};
|
||
}
|
||
|
||
if (state === "running" || (isTaskWorker && state === "active")) {
|
||
return {
|
||
label: "Running",
|
||
icon: <Activity size={14} />,
|
||
color: "var(--state-active-text)",
|
||
stateDerived: true,
|
||
};
|
||
}
|
||
|
||
// No heartbeat data yet
|
||
if (!lastHeartbeatAt) {
|
||
return {
|
||
label: state === "active" ? "Starting..." : "Idle",
|
||
icon: <Bot size={14} />,
|
||
color: "var(--text-secondary)",
|
||
stateDerived: false,
|
||
};
|
||
}
|
||
|
||
// Every non-task-worker agent has an effective interval — either explicitly
|
||
// configured, or the scheduler's 1h default. Compare elapsed time to that
|
||
// interval (with grace) rather than to `heartbeatTimeoutMs`, which is the
|
||
// per-run work budget and has nothing to do with between-tick freshness.
|
||
const lastHeartbeat = new Date(lastHeartbeatAt).getTime();
|
||
const elapsed = Date.now() - lastHeartbeat;
|
||
const stalenessThresholdMs = getStalenessThresholdMs(runtimeConfig);
|
||
|
||
if (elapsed > stalenessThresholdMs) {
|
||
return {
|
||
label: "Unresponsive",
|
||
icon: <Activity size={14} />,
|
||
color: "var(--state-error-text)",
|
||
stateDerived: false,
|
||
};
|
||
}
|
||
|
||
return {
|
||
label: "Healthy",
|
||
icon: <Heart size={14} />,
|
||
color: "var(--state-active-text)",
|
||
stateDerived: false,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Returns a CSS variable name for the health color.
|
||
* Useful when you need the raw CSS variable name for custom styling.
|
||
*/
|
||
export function getAgentHealthColorVar(agent: AgentHealthInput): string {
|
||
const status = getAgentHealthStatus(agent);
|
||
// Extract the CSS variable name from the color string
|
||
// e.g., "var(--state-error-text)" -> "--state-error-text"
|
||
const match = status.color.match(/var\((--[^)]+)\)/);
|
||
return match ? match[1] : status.color;
|
||
}
|