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 { 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>

View File

@@ -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({

View File

@@ -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} />,