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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user