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:
gsxdsm
2026-04-22 21:46:37 -07:00
parent 41ea18bd90
commit e40fec4356
3 changed files with 133 additions and 277 deletions

View File

@@ -17,7 +17,7 @@ import { getAgentHealthStatus } from "../utils/agentHealth";
import type { AgentHealthStatus } from "../utils/agentHealth"; import type { AgentHealthStatus } from "../utils/agentHealth";
import { SkillMultiselect } from "./SkillMultiselect"; import { SkillMultiselect } from "./SkillMultiselect";
import { subscribeSse } from "../sse-bus"; 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"; import { CustomModelDropdown } from "./CustomModelDropdown";
/** /**
@@ -410,52 +410,28 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
</> </>
)} )}
{agent.state === "active" && ( {agent.state === "active" && (
<> <button className="btn btn--compact" onClick={() => void handleStateChange("paused")}>
<button className="btn btn--compact" onClick={() => void handleStateChange("paused")}> <Pause size={14} />
<Pause size={14} /> Pause
Pause </button>
</button>
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
<Square size={14} />
Stop
</button>
</>
)} )}
{agent.state === "paused" && ( {agent.state === "paused" && (
<> <button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}> <Play size={14} />
<Play size={14} /> Resume
Resume </button>
</button>
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
<Square size={14} />
Stop
</button>
</>
)} )}
{agent.state === "running" && ( {agent.state === "running" && (
<> <button className="btn btn--compact" onClick={() => void handleStateChange("paused")}>
<button className="btn btn--compact" onClick={() => void handleStateChange("paused")}> <Pause size={14} />
<Pause size={14} /> Pause
Pause </button>
</button>
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
<Square size={14} />
Stop
</button>
</>
)} )}
{agent.state === "error" && ( {agent.state === "error" && (
<> <button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}> <Play size={14} />
<Play size={14} /> Retry
Retry </button>
</button>
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
<Square size={14} />
Stop
</button>
</>
)} )}
{agent.state === "terminated" && ( {agent.state === "terminated" && (
<> <>
@@ -807,6 +783,26 @@ function DashboardTab({
} }
</span> </span>
</div> </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>
</div> </div>

View File

@@ -131,41 +131,10 @@ describe("getAgentHealthStatus", () => {
}); });
}); });
// ── Heartbeat monitoring disabled ────────────────────────────────────────── // Heartbeat scheduling is driven by agent.state on the server; there is no
// separate "disabled" UI concept anymore. Non-task-worker agents with a
describe("heartbeat monitoring disabled", () => { // legacy `runtimeConfig.enabled === false` on disk are rendered by state
it('returns "Disabled" when runtimeConfig.enabled === false', () => { // just like any other agent.
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);
});
});
describe("task worker health classification", () => { describe("task worker health classification", () => {
it('returns "Running" for metadata-marked task workers with disabled heartbeat', () => { 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)"); 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({ const agent = makeAgent({
name: "Reviewer", name: "Reviewer",
role: "reviewer", role: "reviewer",
@@ -211,8 +180,8 @@ describe("getAgentHealthStatus", () => {
runtimeConfig: { enabled: false }, runtimeConfig: { enabled: false },
}); });
const status = getAgentHealthStatus(agent); const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Disabled"); // No persisted heartbeat, no lastHeartbeatAt → Starting... not Disabled.
expect(status.stateDerived).toBe(false); expect(status.label).toBe("Starting...");
}); });
}); });
@@ -282,158 +251,77 @@ describe("getAgentHealthStatus", () => {
expect(status.color).toBe("var(--state-error-text)"); 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({ const agent = makeAgent({
state: "active", state: "active",
// 90s ago - would be unresponsive with default 60s, but within 120s timeout lastHeartbeatAt: new Date(FIXED_NOW - 45_000).toISOString(),
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(),
runtimeConfig: { heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 30_000 }, runtimeConfig: { heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 30_000 },
}); });
const status = getAgentHealthStatus(agent); expect(getAgentHealthStatus(agent).label).toBe("Healthy");
expect(status.label).toBe("Unresponsive");
expect(status.stateDerived).toBe(false);
}); });
}); });
// ── 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)", () => { describe("agents without explicit heartbeatIntervalMs", () => {
it('returns "Healthy" for agent without heartbeatIntervalMs regardless of elapsed time', () => { it('returns "Healthy" within the default-interval grace window', () => {
// This is an event-driven agent - no timer-based heartbeats expected
const agent = makeAgent({ const agent = makeAgent({
state: "active", state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(), // very stale heartbeat lastHeartbeatAt: new Date(FIXED_NOW - 60_000).toISOString(), // 1m ago
runtimeConfig: { enabled: true }, // no heartbeatIntervalMs - event-driven runtimeConfig: {}, // no interval — falls back to 1h default
}); });
const status = getAgentHealthStatus(agent); expect(getAgentHealthStatus(agent).label).toBe("Healthy");
expect(status.label).toBe("Healthy");
expect(status.stateDerived).toBe(false);
expect(status.color).toBe("var(--state-active-text)");
}); });
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({ const agent = makeAgent({
state: "active", state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 60_001).toISOString(), // just over 60s ago lastHeartbeatAt: new Date(FIXED_NOW - 3 * 3_600_000).toISOString(), // 3h ago
runtimeConfig: {}, // empty runtimeConfig - no heartbeatIntervalMs runtimeConfig: {},
}); });
const status = getAgentHealthStatus(agent); expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
expect(status.label).toBe("Healthy");
expect(status.stateDerived).toBe(false);
}); });
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({ const agent = makeAgent({
state: "active", state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(), // very stale lastHeartbeatAt: new Date(FIXED_NOW - 120_000).toISOString(),
runtimeConfig: { heartbeatIntervalMs: 0 }, // 0 is invalid, treated as non-periodic runtimeConfig: { heartbeatIntervalMs: 0 },
}); });
const status = getAgentHealthStatus(agent); expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
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);
}); });
}); });
// ── 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", () => { describe("staleness floor", () => {
it("returns 'Healthy' for non-periodic agent regardless of elapsed time (no heartbeatIntervalMs)", () => { it("holds Healthy below the 60s floor even for sub-minute intervals", () => {
const agent = makeAgent({ const agent = makeAgent({
state: "active", 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(getAgentHealthStatus(agent).label).toBe("Healthy");
expect(status.label).toBe("Healthy");
expect(status.stateDerived).toBe(false);
}); });
it("returns 'Healthy' for agent with runtimeConfig but no heartbeatIntervalMs", () => { it("tips to Unresponsive past the floor", () => {
const agent = makeAgent({ const agent = makeAgent({
state: "active", state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 59_000).toISOString(), lastHeartbeatAt: new Date(FIXED_NOW - 61_000).toISOString(),
runtimeConfig: { maxConcurrentRuns: 2 }, // has other config, but no heartbeatIntervalMs runtimeConfig: { heartbeatIntervalMs: 10_000 },
}); });
const status = getAgentHealthStatus(agent); expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
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);
}); });
}); });
@@ -503,12 +391,6 @@ describe("getAgentHealthStatus", () => {
expectedLabel: "Starting...", expectedLabel: "Starting...",
expectedStateDerived: false, expectedStateDerived: false,
}, },
{
name: "disabled",
agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }),
expectedLabel: "Disabled",
expectedStateDerived: false,
},
])("sets stateDerived correctly for $name", ({ agent, expectedLabel, expectedStateDerived }) => { ])("sets stateDerived correctly for $name", ({ agent, expectedLabel, expectedStateDerived }) => {
const status = getAgentHealthStatus(agent); const status = getAgentHealthStatus(agent);
expect(status.label).toBe(expectedLabel); expect(status.label).toBe(expectedLabel);
@@ -541,26 +423,25 @@ describe("getAgentHealthStatus", () => {
expect(status.stateDerived).toBe(false); 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({ const agent = makeAgent({
state: "active", state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(), // stale lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(),
runtimeConfig: { heartbeatTimeoutMs: 120_000 }, // no heartbeatIntervalMs, so non-periodic runtimeConfig: { heartbeatTimeoutMs: 120_000 }, // no heartbeatIntervalMs
}); });
const status = getAgentHealthStatus(agent); expect(getAgentHealthStatus(agent).label).toBe("Healthy");
expect(status.label).toBe("Healthy"); // non-periodic agents are always Healthy when they have heartbeat
expect(status.stateDerived).toBe(false);
}); });
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({ const agent = makeAgent({
state: "active", 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 }, runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 120_000 },
}); });
const status = getAgentHealthStatus(agent); expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
expect(status.label).toBe("Healthy"); // within 120s timeout
expect(status.stateDerived).toBe(false);
}); });
it("returns consistent icons for all states", () => { it("returns consistent icons for all states", () => {
@@ -570,6 +451,7 @@ describe("getAgentHealthStatus", () => {
{ agent: makeAgent({ state: "paused" }), expectedIconType: "Pause" }, { agent: makeAgent({ state: "paused" }), expectedIconType: "Pause" },
{ agent: makeAgent({ state: "running" }), expectedIconType: "Activity" }, { agent: makeAgent({ state: "running" }), expectedIconType: "Activity" },
{ agent: makeAgent({ state: "idle" }), expectedIconType: "Bot" }, { agent: makeAgent({ state: "idle" }), expectedIconType: "Bot" },
// state=active + no lastHeartbeatAt → "Starting..." → Bot icon
{ agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }), expectedIconType: "Bot" }, { agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }), expectedIconType: "Bot" },
{ {
agent: makeAgent({ agent: makeAgent({

View File

@@ -1,9 +1,27 @@
import type { JSX } from "react"; import type { JSX } from "react";
import { Bot, Heart, Activity, Pause, Square } from "lucide-react"; import { Bot, Heart, Activity, Pause, Square } from "lucide-react";
import type { Agent } from "../api"; import type { Agent } from "../api";
import { resolveHeartbeatIntervalMs } from "./heartbeatIntervals";
/** Default heartbeat timeout when not configured per-agent */ // Heartbeat scheduling is driven by `agent.state` on the server — active and
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 60_000; // 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 */ /** Shape of the health status returned by getAgentHealthStatus */
export interface AgentHealthStatus { export interface AgentHealthStatus {
@@ -28,37 +46,18 @@ type AgentHealthInput = Pick<
>; >;
/** /**
* Extract the heartbeat timeout from agent runtimeConfig. * Compute the staleness threshold for an agent. Elapsed time beyond this is
* Returns undefined if not set or if monitoring is disabled. * 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 { function getStalenessThresholdMs(runtimeConfig?: Record<string, unknown>): number {
if (!runtimeConfig) return undefined; const intervalMs = resolveHeartbeatIntervalMs(runtimeConfig?.heartbeatIntervalMs);
if (runtimeConfig.enabled === false) return undefined; return Math.max(intervalMs * HEARTBEAT_GRACE_MULTIPLIER, MIN_HEARTBEAT_STALENESS_MS);
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 isTaskWorkerAgent(agent: AgentHealthInput): boolean { function isTaskWorkerAgent(agent: AgentHealthInput): boolean {
@@ -85,11 +84,10 @@ function isTaskWorkerAgent(agent: AgentHealthInput): boolean {
* - "Error" — agent.state === "error" (uses lastError if available) * - "Error" — agent.state === "error" (uses lastError if available)
* - "Paused" — agent.state === "paused" (uses pauseReason if available) * - "Paused" — agent.state === "paused" (uses pauseReason if available)
* - "Running" — agent.state === "running", or a detected task worker in "active" * - "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 * - "Starting..." — state === "active" && no lastHeartbeatAt
* - "Idle" — state !== "active" && no lastHeartbeatAt * - "Idle" — state !== "active" && no lastHeartbeatAt
* - "Healthy" — heartbeat is fresh within the configured timeout * - "Healthy" — heartbeat is fresh within 2× the configured interval
* - "Unresponsive" — heartbeat exceeded the configured timeout * - "Unresponsive" — heartbeat exceeded 2× the configured interval
* *
* @param agent - The agent object (partial Agent shape is accepted) * @param agent - The agent object (partial Agent shape is accepted)
* @returns A health status object with label, icon, color, and stateDerived metadata * @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 // No heartbeat data yet
if (!lastHeartbeatAt) { if (!lastHeartbeatAt) {
return { return {
@@ -156,25 +144,15 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
}; };
} }
// For agents without periodic heartbeat configuration (event-driven agents), // Every non-task-worker agent has an effective interval — either explicitly
// return "Healthy" if they have a lastHeartbeatAt. These agents don't have // configured, or the scheduler's 1h default. Compare elapsed time to that
// timer-based triggers, so absence of recent heartbeats is not a signal of // interval (with grace) rather than to `heartbeatTimeoutMs`, which is the
// unresponsiveness. // per-run work budget and has nothing to do with between-tick freshness.
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
const lastHeartbeat = new Date(lastHeartbeatAt).getTime(); const lastHeartbeat = new Date(lastHeartbeatAt).getTime();
const elapsed = Date.now() - lastHeartbeat; const elapsed = Date.now() - lastHeartbeat;
const timeoutMs = getHeartbeatTimeoutMs(runtimeConfig) ?? DEFAULT_HEARTBEAT_TIMEOUT_MS; const stalenessThresholdMs = getStalenessThresholdMs(runtimeConfig);
if (elapsed > timeoutMs) { if (elapsed > stalenessThresholdMs) {
return { return {
label: "Unresponsive", label: "Unresponsive",
icon: <Activity size={14} />, icon: <Activity size={14} />,