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 55921c60e7
commit 780bb21fa3
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>