fix(dashboard): state-drive agent health + remove Stop buttons from detail view
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>
This commit is contained in:
@@ -17,7 +17,7 @@ import { getAgentHealthStatus } from "../utils/agentHealth";
|
||||
import type { AgentHealthStatus } from "../utils/agentHealth";
|
||||
import { SkillMultiselect } from "./SkillMultiselect";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { DEFAULT_HEARTBEAT_INTERVAL_MS, formatHeartbeatInterval } from "../utils/heartbeatIntervals";
|
||||
import { DEFAULT_HEARTBEAT_INTERVAL_MS, formatHeartbeatInterval, resolveHeartbeatIntervalMs } from "../utils/heartbeatIntervals";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
|
||||
/**
|
||||
@@ -410,52 +410,28 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
</>
|
||||
)}
|
||||
{agent.state === "active" && (
|
||||
<>
|
||||
<button className="btn btn--compact" onClick={() => void handleStateChange("paused")}>
|
||||
<Pause size={14} />
|
||||
Pause
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
<button className="btn btn--compact" onClick={() => void handleStateChange("paused")}>
|
||||
<Pause size={14} />
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
{agent.state === "paused" && (
|
||||
<>
|
||||
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={14} />
|
||||
Resume
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={14} />
|
||||
Resume
|
||||
</button>
|
||||
)}
|
||||
{agent.state === "running" && (
|
||||
<>
|
||||
<button className="btn btn--compact" onClick={() => void handleStateChange("paused")}>
|
||||
<Pause size={14} />
|
||||
Pause
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
<button className="btn btn--compact" onClick={() => void handleStateChange("paused")}>
|
||||
<Pause size={14} />
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
{agent.state === "error" && (
|
||||
<>
|
||||
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={14} />
|
||||
Retry
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={14} />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<>
|
||||
@@ -801,12 +777,32 @@ function DashboardTab({
|
||||
<div className="info-item">
|
||||
<span className="info-label">Last Heartbeat</span>
|
||||
<span className="info-value">
|
||||
{agent.lastHeartbeatAt
|
||||
{agent.lastHeartbeatAt
|
||||
? relativeTime(agent.lastHeartbeatAt)
|
||||
: "Never"
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
// Next heartbeat is only meaningful while the agent is in a ticking
|
||||
// state — paused/terminated/error agents have no scheduled next tick.
|
||||
const isTicking = agent.state === "active" || agent.state === "running";
|
||||
if (!isTicking || !agent.lastHeartbeatAt) return null;
|
||||
const intervalMs = resolveHeartbeatIntervalMs(
|
||||
agent.runtimeConfig?.heartbeatIntervalMs,
|
||||
);
|
||||
const nextAt = new Date(
|
||||
new Date(agent.lastHeartbeatAt).getTime() + intervalMs,
|
||||
);
|
||||
return (
|
||||
<div className="info-item">
|
||||
<span className="info-label">Next Heartbeat</span>
|
||||
<span className="info-value" title={nextAt.toLocaleString()}>
|
||||
{relativeTime(nextAt.toISOString())}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -131,41 +131,10 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Heartbeat monitoring disabled ──────────────────────────────────────────
|
||||
|
||||
describe("heartbeat monitoring disabled", () => {
|
||||
it('returns "Disabled" when runtimeConfig.enabled === false', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
runtimeConfig: { enabled: false },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Disabled");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--text-secondary)");
|
||||
});
|
||||
|
||||
it('returns "Disabled" even with stale heartbeat data', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(), // very stale
|
||||
runtimeConfig: { enabled: false, heartbeatTimeoutMs: 60000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Disabled");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "Disabled" for idle agents with monitoring disabled', () => {
|
||||
const agent = makeAgent({
|
||||
state: "idle",
|
||||
runtimeConfig: { enabled: false },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Disabled");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
});
|
||||
// Heartbeat scheduling is driven by agent.state on the server; there is no
|
||||
// separate "disabled" UI concept anymore. Non-task-worker agents with a
|
||||
// legacy `runtimeConfig.enabled === false` on disk are rendered by state
|
||||
// just like any other agent.
|
||||
|
||||
describe("task worker health classification", () => {
|
||||
it('returns "Running" for metadata-marked task workers with disabled heartbeat', () => {
|
||||
@@ -203,7 +172,7 @@ describe("getAgentHealthStatus", () => {
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
it('keeps non-task-worker disabled agents as "Disabled"', () => {
|
||||
it('ignores legacy runtimeConfig.enabled=false on non-task-worker agents', () => {
|
||||
const agent = makeAgent({
|
||||
name: "Reviewer",
|
||||
role: "reviewer",
|
||||
@@ -211,8 +180,8 @@ describe("getAgentHealthStatus", () => {
|
||||
runtimeConfig: { enabled: false },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Disabled");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
// No persisted heartbeat, no lastHeartbeatAt → Starting... not Disabled.
|
||||
expect(status.label).toBe("Starting...");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,158 +251,77 @@ describe("getAgentHealthStatus", () => {
|
||||
expect(status.color).toBe("var(--state-error-text)");
|
||||
});
|
||||
|
||||
it("uses per-agent heartbeatTimeoutMs when configured", () => {
|
||||
it("ignores heartbeatTimeoutMs — that's the per-run work budget, not freshness", () => {
|
||||
// 30s interval → staleness threshold = max(60s floor, 60s) = 60s. A
|
||||
// 45s-old heartbeat is healthy regardless of what heartbeatTimeoutMs says.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
// 90s ago - would be unresponsive with default 60s, but within 120s timeout
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 90_000).toISOString(),
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 120_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("marks as unresponsive when exceeding per-agent timeout", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
// 60s ago - would be healthy with default 60s, but exceeds 30s custom timeout
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_000).toISOString(),
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 45_000).toISOString(),
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 30_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-periodic agents (no heartbeatIntervalMs) ────────────────────────────
|
||||
// ── Agents without explicit heartbeatIntervalMs ───────────────────────────
|
||||
//
|
||||
// Agents that never had an interval persisted still get the server-side
|
||||
// default interval (1h), so they render Healthy within ~2h of the last
|
||||
// heartbeat and tip into Unresponsive beyond that.
|
||||
|
||||
describe("non-periodic agents (no heartbeatIntervalMs)", () => {
|
||||
it('returns "Healthy" for agent without heartbeatIntervalMs regardless of elapsed time', () => {
|
||||
// This is an event-driven agent - no timer-based heartbeats expected
|
||||
describe("agents without explicit heartbeatIntervalMs", () => {
|
||||
it('returns "Healthy" within the default-interval grace window', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(), // very stale heartbeat
|
||||
runtimeConfig: { enabled: true }, // no heartbeatIntervalMs - event-driven
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_000).toISOString(), // 1m ago
|
||||
runtimeConfig: {}, // no interval — falls back to 1h default
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it('returns "Healthy" for agent with stale heartbeat when no heartbeatIntervalMs is set', () => {
|
||||
it('returns "Unresponsive" once elapsed exceeds 2× the default 1h interval', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_001).toISOString(), // just over 60s ago
|
||||
runtimeConfig: {}, // empty runtimeConfig - no heartbeatIntervalMs
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 3 * 3_600_000).toISOString(), // 3h ago
|
||||
runtimeConfig: {},
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
});
|
||||
|
||||
it('returns "Healthy" for agent with heartbeatIntervalMs: 0 (invalid, treated as non-periodic)', () => {
|
||||
it("clamps invalid intervals (0/negative) to the scheduler minimum (1s)", () => {
|
||||
// 0/-5000 clamp to 1000ms → threshold falls back to the 60s floor.
|
||||
// A heartbeat 120s old is stale.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(), // very stale
|
||||
runtimeConfig: { heartbeatIntervalMs: 0 }, // 0 is invalid, treated as non-periodic
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 120_000).toISOString(),
|
||||
runtimeConfig: { heartbeatIntervalMs: 0 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "Healthy" for agent with heartbeatIntervalMs: -5000 (negative, treated as non-periodic)', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(), // stale
|
||||
runtimeConfig: { heartbeatIntervalMs: -5000 }, // negative is invalid
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "Healthy" for agent with heartbeatIntervalMs: undefined (non-periodic)', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 500_000).toISOString(), // very stale
|
||||
runtimeConfig: { heartbeatTimeoutMs: 60_000, heartbeatIntervalMs: undefined as unknown as number },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "Healthy" for periodic agent with heartbeatIntervalMs: 60000 and stale heartbeat shows "Unresponsive"', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 120_000).toISOString(), // 120s ago, exceeds 60s timeout
|
||||
runtimeConfig: { heartbeatIntervalMs: 60_000 }, // periodic with 60s interval
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Per-agent timeout overrides ────────────────────────────────────────────
|
||||
// ── Staleness floor ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Short intervals get a 60s floor so the UI doesn't flicker between
|
||||
// Healthy and Unresponsive every tick for second-level heartbeats.
|
||||
|
||||
describe("per-agent timeout overrides", () => {
|
||||
it("returns 'Healthy' for non-periodic agent regardless of elapsed time (no heartbeatIntervalMs)", () => {
|
||||
describe("staleness floor", () => {
|
||||
it("holds Healthy below the 60s floor even for sub-minute intervals", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 59_000).toISOString(), // 59s ago
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(),
|
||||
runtimeConfig: { heartbeatIntervalMs: 10_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("returns 'Healthy' for agent with runtimeConfig but no heartbeatIntervalMs", () => {
|
||||
it("tips to Unresponsive past the floor", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 59_000).toISOString(),
|
||||
runtimeConfig: { maxConcurrentRuns: 2 }, // has other config, but no heartbeatIntervalMs
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 61_000).toISOString(),
|
||||
runtimeConfig: { heartbeatIntervalMs: 10_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("handles custom timeout of 30 seconds with periodic heartbeat", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 45_000).toISOString(), // 45s ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 30_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("handles custom timeout of 120 seconds with periodic heartbeat", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 90_000).toISOString(), // 90s ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 120_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("handles very short timeout of 5 seconds with periodic heartbeat", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 6_000).toISOString(), // 6s ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 10_000, heartbeatTimeoutMs: 5_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -503,12 +391,6 @@ describe("getAgentHealthStatus", () => {
|
||||
expectedLabel: "Starting...",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
{
|
||||
name: "disabled",
|
||||
agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }),
|
||||
expectedLabel: "Disabled",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
])("sets stateDerived correctly for $name", ({ agent, expectedLabel, expectedStateDerived }) => {
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe(expectedLabel);
|
||||
@@ -541,26 +423,25 @@ describe("getAgentHealthStatus", () => {
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("treats runtimeConfig.enabled as true when undefined (non-periodic)", () => {
|
||||
it("100s stale heartbeat with no explicit interval → Healthy (default 1h applies)", () => {
|
||||
// 1h default interval → 2h threshold, so 100s is well within range.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(), // stale
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120_000 }, // no heartbeatIntervalMs, so non-periodic
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(),
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120_000 }, // no heartbeatIntervalMs
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy"); // non-periodic agents are always Healthy when they have heartbeat
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("treats runtimeConfig.enabled === true with periodic heartbeat", () => {
|
||||
it("ignores runtimeConfig.enabled and uses interval-based staleness", () => {
|
||||
// 30s interval → 60s threshold. 100s elapsed is stale regardless of any
|
||||
// legacy enabled flag or per-run timeout.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(), // stale
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(),
|
||||
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 120_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy"); // within 120s timeout
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
});
|
||||
|
||||
it("returns consistent icons for all states", () => {
|
||||
@@ -570,6 +451,7 @@ describe("getAgentHealthStatus", () => {
|
||||
{ agent: makeAgent({ state: "paused" }), expectedIconType: "Pause" },
|
||||
{ agent: makeAgent({ state: "running" }), expectedIconType: "Activity" },
|
||||
{ agent: makeAgent({ state: "idle" }), expectedIconType: "Bot" },
|
||||
// state=active + no lastHeartbeatAt → "Starting..." → Bot icon
|
||||
{ agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }), expectedIconType: "Bot" },
|
||||
{
|
||||
agent: makeAgent({
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import type { JSX } from "react";
|
||||
import { Bot, Heart, Activity, Pause, Square } from "lucide-react";
|
||||
import type { Agent } from "../api";
|
||||
import { resolveHeartbeatIntervalMs } from "./heartbeatIntervals";
|
||||
|
||||
/** Default heartbeat timeout when not configured per-agent */
|
||||
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 60_000;
|
||||
// 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 {
|
||||
@@ -28,37 +46,18 @@ type AgentHealthInput = Pick<
|
||||
>;
|
||||
|
||||
/**
|
||||
* Extract the heartbeat timeout from agent runtimeConfig.
|
||||
* Returns undefined if not set or if monitoring is disabled.
|
||||
* 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 getHeartbeatTimeoutMs(runtimeConfig?: Record<string, unknown>): number | undefined {
|
||||
if (!runtimeConfig) return undefined;
|
||||
if (runtimeConfig.enabled === false) return undefined;
|
||||
if (typeof runtimeConfig.heartbeatTimeoutMs !== "number") return undefined;
|
||||
return runtimeConfig.heartbeatTimeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if heartbeat monitoring is enabled for the agent.
|
||||
* Returns false if runtimeConfig.enabled === false, true otherwise.
|
||||
*/
|
||||
function isHeartbeatEnabled(runtimeConfig?: Record<string, unknown>): boolean {
|
||||
if (!runtimeConfig) return true;
|
||||
if (typeof runtimeConfig.enabled === "boolean") return runtimeConfig.enabled;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the agent has periodic heartbeat configuration.
|
||||
* An agent has periodic heartbeats if heartbeatIntervalMs is a positive number.
|
||||
* Agents with periodic heartbeat timers should show "Unresponsive" if no heartbeat
|
||||
* is received within the timeout window. Agents without periodic heartbeat (event-driven)
|
||||
* should not be marked "Unresponsive" based on elapsed time.
|
||||
*/
|
||||
function hasPeriodicHeartbeat(runtimeConfig?: Record<string, unknown>): boolean {
|
||||
if (!runtimeConfig) return false;
|
||||
const intervalMs = runtimeConfig.heartbeatIntervalMs;
|
||||
return typeof intervalMs === "number" && Number.isFinite(intervalMs) && intervalMs > 0;
|
||||
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 {
|
||||
@@ -85,11 +84,10 @@ function isTaskWorkerAgent(agent: AgentHealthInput): boolean {
|
||||
* - "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"
|
||||
* - "Disabled" — runtimeConfig.enabled === false for non-task-worker agents
|
||||
* - "Starting..." — state === "active" && no lastHeartbeatAt
|
||||
* - "Idle" — state !== "active" && no lastHeartbeatAt
|
||||
* - "Healthy" — heartbeat is fresh within the configured timeout
|
||||
* - "Unresponsive" — heartbeat exceeded the configured timeout
|
||||
* - "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
|
||||
@@ -136,16 +134,6 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
};
|
||||
}
|
||||
|
||||
// Check if heartbeat monitoring is enabled
|
||||
if (!isHeartbeatEnabled(runtimeConfig)) {
|
||||
return {
|
||||
label: "Disabled",
|
||||
icon: <Bot size={14} />,
|
||||
color: "var(--text-secondary)",
|
||||
stateDerived: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No heartbeat data yet
|
||||
if (!lastHeartbeatAt) {
|
||||
return {
|
||||
@@ -156,25 +144,15 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
};
|
||||
}
|
||||
|
||||
// For agents without periodic heartbeat configuration (event-driven agents),
|
||||
// return "Healthy" if they have a lastHeartbeatAt. These agents don't have
|
||||
// timer-based triggers, so absence of recent heartbeats is not a signal of
|
||||
// unresponsiveness.
|
||||
if (!hasPeriodicHeartbeat(runtimeConfig)) {
|
||||
return {
|
||||
label: "Healthy",
|
||||
icon: <Heart size={14} />,
|
||||
color: "var(--state-active-text)",
|
||||
stateDerived: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Agent has periodic heartbeat — check if within timeout window
|
||||
// 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 timeoutMs = getHeartbeatTimeoutMs(runtimeConfig) ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
||||
const stalenessThresholdMs = getStalenessThresholdMs(runtimeConfig);
|
||||
|
||||
if (elapsed > timeoutMs) {
|
||||
if (elapsed > stalenessThresholdMs) {
|
||||
return {
|
||||
label: "Unresponsive",
|
||||
icon: <Activity size={14} />,
|
||||
|
||||
Reference in New Issue
Block a user