From 2978ec4973bb097a4949b016488f7ac6ac43ad60 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 23 Jul 2026 14:32:58 -0700 Subject: [PATCH] FN-8551: add accessible agent heartbeat controls Add per-agent and project-wide heartbeat enablement controls. - Add preserved runtime configuration helpers and heartbeat controls across agent list, board, and org-chart views. - Add bulk enable/disable actions, accessible labels, localized copy, documentation, and release metadata. - Cover heartbeat mutations through dashboard, route, scheduler, and layout smoke tests. Files changed: .changeset/fn-8551-agent-heartbeat-controls.md | 7 + docs/dashboard-guide.md | 2 + packages/dashboard/app/api/agents.ts | 12 ++ packages/dashboard/app/api/legacy.ts | 2 + .../dashboard/app/components/AgentDetailView.tsx | 14 +- packages/dashboard/app/components/AgentsView.css | 11 ++ packages/dashboard/app/components/AgentsView.tsx | 186 ++++++++++++++++----- .../AgentDetailView.advanced-settings.test.tsx | 29 ++++ .../__tests__/AgentDetailView.test-helpers.ts | 2 + .../app/components/__tests__/AgentsView.test.tsx | 68 ++++++++ .../dashboard/scripts/browser-layout-smoke.mjs | 54 ++++++ .../src/routes/__tests__/agent-core-routes.test.ts | 69 ++++++++ .../src/__tests__/heartbeat-scheduler.test.ts | 21 +++ packages/i18n/locales/en/app.json | 19 ++- packages/i18n/locales/es/app.json | 19 ++- packages/i18n/locales/fr/app.json | 19 ++- 16 files changed, 484 insertions(+), 50 deletions(-) Fusion-Task-Id: FN-8551 Fusion-Task-Lineage: 2d4c8a15-a444-47b6-b57c-f41fc4163155 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8551-agent-heartbeat-controls.md | 7 + docs/dashboard-guide.md | 2 + packages/dashboard/app/api/agents.ts | 12 ++ packages/dashboard/app/api/legacy.ts | 2 + .../app/components/AgentDetailView.tsx | 14 +- .../dashboard/app/components/AgentsView.css | 11 ++ .../dashboard/app/components/AgentsView.tsx | 186 ++++++++++++++---- ...AgentDetailView.advanced-settings.test.tsx | 29 +++ .../__tests__/AgentDetailView.test-helpers.ts | 2 + .../components/__tests__/AgentsView.test.tsx | 68 +++++++ .../scripts/browser-layout-smoke.mjs | 54 +++++ .../__tests__/agent-core-routes.test.ts | 69 +++++++ .../src/__tests__/heartbeat-scheduler.test.ts | 21 ++ packages/i18n/locales/en/app.json | 19 +- packages/i18n/locales/es/app.json | 19 +- packages/i18n/locales/fr/app.json | 19 +- 16 files changed, 484 insertions(+), 50 deletions(-) create mode 100644 .changeset/fn-8551-agent-heartbeat-controls.md create mode 100644 packages/dashboard/src/routes/__tests__/agent-core-routes.test.ts diff --git a/.changeset/fn-8551-agent-heartbeat-controls.md b/.changeset/fn-8551-agent-heartbeat-controls.md new file mode 100644 index 0000000000..08a7082c1e --- /dev/null +++ b/.changeset/fn-8551-agent-heartbeat-controls.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add per-agent and project-wide heartbeat enable controls. +category: feature +dev: Preserves complete runtimeConfig replacements through the existing project-scoped agent PATCH route. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7d40bc5076..d6647b5a43 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1140,6 +1140,8 @@ Features: - Agent list, live-agent, and detail task badges show the linked task ID with its current column when the task is non-terminal (for example `FN-6902 · Planning` or `FN-6902 · In Progress`). Terminal linked tasks are omitted, and unresolved column lookups render an explicit `Unresolved task` suffix so missing or deleted task links are not mistaken for healthy parked work. - First-run setup asks whether to create an optional project agent after project registration. The default template is **CEO**; users can choose another preset, use the AI interview when `experimentalFeatures.agentOnboarding` is enabled, or skip it. Fusion can still build tasks without an agent by starting temporary agents to plan, code, review, and merge task work. - Start, pause, stop, and trigger agent runs from the view and from detail panels +- **Heartbeat controls** appear on every durable-agent List, Board, and Org chart card and in Agent detail settings. Heartbeats default to enabled unless `runtimeConfig.enabled` is explicitly `false`; changing this setting preserves the agent's other runtime configuration and does not pause, resume, or otherwise alter the agent lifecycle. +- The Agents controls menu also provides **Enable all heartbeats** and **Disable all heartbeats** for every eligible durable agent in the current project, regardless of the active filter or layout. Each action confirms its target count, excludes ephemeral/task-worker agents, refreshes after completion, and reports skipped or failed updates rather than treating the batch as atomic. - In **Agent detail**, use the kebab **Bulk agent actions** button in the header utility cluster (next to **Refresh** and **Close**) to run project-wide lifecycle transitions for non-ephemeral agents in the current project — **Pause All Agents** targets agents in the `active` or `running` state, while **Resume All Agents** targets agents in the `paused` state only - In **Agent detail → Settings → Configuration**, the built-in-model picker includes a concrete **Thinking Level** selector; changing it autosaves to the agent's `runtimeConfig.thinkingLevel` alongside the provider/model choice. - Bulk menu items stay disabled when nothing is eligible and show an inline hint (`Loading eligible agents...`, `No active agents eligible`, `No paused agents eligible`, or the current eligible count such as `Pause 2 active/running agents`) diff --git a/packages/dashboard/app/api/agents.ts b/packages/dashboard/app/api/agents.ts index 6b9c334a43..5910fe4670 100644 --- a/packages/dashboard/app/api/agents.ts +++ b/packages/dashboard/app/api/agents.ts @@ -31,6 +31,18 @@ import { dedupe } from "./dedupe.js"; // ── Agent API ──────────────────────────────────────────────────────────── +/* +FNXC:AgentHeartbeatControls 2026-07-23-13:00: +Heartbeat enablement defaults to enabled when no explicit value exists. The agent PATCH route replaces runtimeConfig rather than merging it, so every dashboard surface must share this immutable helper to preserve interval, model, concurrency, and future configuration keys without changing lifecycle state. +*/ +export function isAgentHeartbeatEnabled(agent: Pick): boolean { + return agent.runtimeConfig?.enabled !== false; +} + +export function withAgentHeartbeatEnabled>(agent: T, enabled: boolean): AgentUpdateInput["runtimeConfig"] { + return { ...(agent.runtimeConfig ?? {}), enabled }; +} + export interface AgentPromptSizePoint { runId: string; createdAt: string; diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index bc5dcb0c02..79ea2fc6fa 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -801,6 +801,7 @@ export { deleteAgent, deleteAgentAvatar, fetchAgent, + isAgentHeartbeatEnabled, fetchAgentHeartbeats, fetchAgentMemory, fetchAgentMemoryFile, @@ -817,6 +818,7 @@ export { startAgentRun, stopAgentRun, updateAgent, + withAgentHeartbeatEnabled, updateAgentInstructions, updateAgentMemory, updateAgentSoul, diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index c6c17135e1..bae4f63884 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -13,7 +13,7 @@ import { import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api"; -import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettings, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api"; +import { fetchAgent, updateAgent, updateAgentState, deleteAgent, isAgentHeartbeatEnabled, withAgentHeartbeatEnabled, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettings, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api"; import type { Agent } from "../api"; import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel } from "@fusion/core"; import { AGENT_PERMISSIONS, getErrorMessage, isEphemeralAgent } from "@fusion/core"; @@ -3348,10 +3348,6 @@ function deriveHeartbeatValues(runtimeConfig: AgentDetail["runtimeConfig"] | und return nextValues; } -function deriveHeartbeatEnabled(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean { - return runtimeConfig?.enabled !== false; -} - function deriveAutoClaimRelevantTasksEnabled(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean { return runtimeConfig?.autoClaimRelevantTasks !== false; } @@ -3749,7 +3745,7 @@ function ConfigTab({ () => deriveHeartbeatValues(agent.runtimeConfig), ); const [heartbeatEnabled, setHeartbeatEnabled] = useState( - () => deriveHeartbeatEnabled(agent.runtimeConfig), + () => isAgentHeartbeatEnabled(agent), ); const [autoClaimRelevantTasksEnabled, setAutoClaimRelevantTasksEnabled] = useState( () => deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig), @@ -4073,7 +4069,7 @@ function ConfigTab({ } // Check heartbeat values const rc = agent.runtimeConfig ?? {}; - if (heartbeatEnabled !== deriveHeartbeatEnabled(agent.runtimeConfig)) return true; + if (heartbeatEnabled !== isAgentHeartbeatEnabled(agent)) return true; if (autoClaimRelevantTasksEnabled !== deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig)) return true; if (engineerBacklogAutoClaimEnabled !== deriveEngineerBacklogAutoClaim(agent.runtimeConfig)) return true; if (assignmentPolicy !== deriveAssignmentPolicy(agent.runtimeConfig)) return true; @@ -4155,7 +4151,7 @@ function ConfigTab({ previousAgentRuntimeSyncRef.current = nextSnapshot; setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig)); - setHeartbeatEnabled(deriveHeartbeatEnabled(agent.runtimeConfig)); + setHeartbeatEnabled(isAgentHeartbeatEnabled(agent)); setAutoClaimRelevantTasksEnabled(deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig)); setEngineerBacklogAutoClaimEnabled(deriveEngineerBacklogAutoClaim(agent.runtimeConfig)); setRunMissedHeartbeatOnStartup(deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig)); @@ -4318,7 +4314,7 @@ function ConfigTab({ // Build the runtimeConfig payload — only include non-empty values const newRuntimeConfig: Record = { ...agent.runtimeConfig }; - newRuntimeConfig.enabled = heartbeatEnabled; + Object.assign(newRuntimeConfig, withAgentHeartbeatEnabled(agent, heartbeatEnabled)); newRuntimeConfig.autoClaimRelevantTasks = autoClaimRelevantTasksEnabled; newRuntimeConfig.engineerBacklogAutoClaim = engineerBacklogAutoClaimEnabled; if (assignmentPolicy === "auto") { diff --git a/packages/dashboard/app/components/AgentsView.css b/packages/dashboard/app/components/AgentsView.css index fd77e6d5d8..447d9d11d7 100644 --- a/packages/dashboard/app/components/AgentsView.css +++ b/packages/dashboard/app/components/AgentsView.css @@ -1235,6 +1235,17 @@ AgentsView uses the measured SVG overlay as the single connector system so paren font-size: var(--space-md); } +/* FNXC:AgentHeartbeatControls 2026-07-23-13:15: Keep the org action outside the card's button-like detail target so keyboard users never encounter nested interactive controls. */ +.org-chart-node__actions { + display: flex; + justify-content: center; + margin-top: var(--space-xs); +} + +.agent-heartbeat-toggle { + min-height: var(--touch-target-min-size); +} + .org-chart-children { position: relative; display: flex; diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx index 18bed18ce7..e59e0f094b 100644 --- a/packages/dashboard/app/components/AgentsView.tsx +++ b/packages/dashboard/app/components/AgentsView.tsx @@ -4,7 +4,7 @@ import type { TFunction } from "i18next"; import { useState, useEffect, useCallback, useRef, useMemo, useId, useLayoutEffect, lazy, Suspense, type CSSProperties, type ReactNode, type MutableRefObject, type RefObject, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal, ZoomIn, ZoomOut, Minimize2, Move, Info } from "lucide-react"; import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api"; -import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api"; +import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings, isAgentHeartbeatEnabled, withAgentHeartbeatEnabled } from "../api"; const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView }))); import { AgentTokenStatsPanel } from "./AgentTokenStatsPanel"; @@ -178,16 +178,47 @@ function getHealthSummary(agent: Agent, health: AgentHealthStatus, t: TFunction< type OrgChartLink = { parentId: string; childId: string }; type OrgChartTransform = { scale: number; x: number; y: number }; +/* +FNXC:AgentHeartbeatControls 2026-07-23-13:10: +List, board, and org-chart cards use one explicit heartbeat action. It changes only runtimeConfig.enabled through the preserved payload helper; lifecycle pause/resume remains a separate control. +*/ +function HeartbeatToggle({ agent, pending, onToggle }: { agent: Agent; pending: boolean; onToggle: (agent: Agent) => void }) { + const { t } = useTranslation("app"); + // FNXC:AgentHeartbeatControls 2026-07-23-13:30: Task-worker agents are ephemeral and must never receive durable heartbeat mutations, even when operators expose system agents. + if (isEphemeralAgent(agent)) return null; + const enabled = isAgentHeartbeatEnabled(agent); + const label = enabled + ? t("agents.disableHeartbeat", "Disable heartbeat") + : t("agents.enableHeartbeat", "Enable heartbeat"); + return ( + + ); +} + type OrgChartNodeProps = { node: OrgTreeNode; onSelect: (id: string) => void; + onToggleHeartbeat: (agent: Agent) => void; + isHeartbeatPending: (agentId: string) => boolean; getHealthStatus: (agent: Agent) => AgentHealthStatus; selectedAgentId: string | null; registerNodeElement: (id: string, element: HTMLDivElement | null) => void; linksRef: MutableRefObject; }; -function OrgChartNode({ node, onSelect, getHealthStatus, selectedAgentId, registerNodeElement, linksRef }: OrgChartNodeProps) { +function OrgChartNode({ node, onSelect, onToggleHeartbeat, isHeartbeatPending, getHealthStatus, selectedAgentId, registerNodeElement, linksRef }: OrgChartNodeProps) { const { t } = useTranslation("app"); const { agent, children } = node; const health = getHealthStatus(agent); @@ -227,6 +258,7 @@ function OrgChartNode({ node, onSelect, getHealthStatus, selectedAgentId, regist +
{children.length > 0 && (
{children.map((child) => { @@ -236,6 +268,8 @@ function OrgChartNode({ node, onSelect, getHealthStatus, selectedAgentId, regist key={child.agent.id} node={child} onSelect={onSelect} + onToggleHeartbeat={onToggleHeartbeat} + isHeartbeatPending={isHeartbeatPending} getHealthStatus={getHealthStatus} selectedAgentId={selectedAgentId} registerNodeElement={registerNodeElement} @@ -462,6 +496,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin const [isBulkEligibilityLoading, setIsBulkEligibilityLoading] = useState(false); const [bulkPauseEligibleCount, setBulkPauseEligibleCount] = useState(0); const [bulkResumeEligibleCount, setBulkResumeEligibleCount] = useState(0); + const [bulkEnableHeartbeatEligibleCount, setBulkEnableHeartbeatEligibleCount] = useState(0); + const [bulkDisableHeartbeatEligibleCount, setBulkDisableHeartbeatEligibleCount] = useState(0); const [orgChartTransform, setOrgChartTransform] = useState({ scale: 1, x: 0, y: 0 }); const [isOrgChartPanning, setIsOrgChartPanning] = useState(false); const controlsPanelRef = useRef(null); @@ -560,6 +596,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin const [editingRoleForAgent, setEditingRoleForAgent] = useState(null); const roleSelectRef = useRef(null); const [updatingHeartbeatAgentId, setUpdatingHeartbeatAgentId] = useState(null); + const [heartbeatMutationAgentIds, setHeartbeatMutationAgentIds] = useState>(new Set()); + const [isBulkHeartbeatMutationRunning, setIsBulkHeartbeatMutationRunning] = useState(false); /** Agent ID currently showing custom heartbeat input */ const [customHeartbeatAgentId, setCustomHeartbeatAgentId] = useState(null); /** Custom minutes input value for each agent */ @@ -734,11 +772,15 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin nonEphemeralAgents.filter((projectAgent) => projectAgent.state === "active" || projectAgent.state === "running").length, ); setBulkResumeEligibleCount(nonEphemeralAgents.filter((projectAgent) => projectAgent.state === "paused").length); + setBulkEnableHeartbeatEligibleCount(nonEphemeralAgents.filter((projectAgent) => !isAgentHeartbeatEnabled(projectAgent)).length); + setBulkDisableHeartbeatEligibleCount(nonEphemeralAgents.filter((projectAgent) => isAgentHeartbeatEnabled(projectAgent)).length); }) .catch((err) => { if (cancelled) return; setBulkPauseEligibleCount(0); setBulkResumeEligibleCount(0); + setBulkEnableHeartbeatEligibleCount(0); + setBulkDisableHeartbeatEligibleCount(0); addToast(t("agents.bulkActionsLoadFailed", "Failed to load bulk agent actions: {{error}}", { error: getErrorMessage(err) }), "error"); }) .finally(() => { @@ -841,6 +883,47 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin } }; + const handleBulkHeartbeatChange = async (enabled: boolean) => { + if (isBulkHeartbeatMutationRunning || isBulkActionRunning || heartbeatMutationAgentIds.size > 0) return; + setIsBulkHeartbeatMutationRunning(true); + try { + const projectAgents = await fetchAgents(undefined, projectId); + const durableAgents = projectAgents.filter((agent) => !isEphemeralAgent(agent)); + const eligibleAgents = durableAgents.filter((agent) => isAgentHeartbeatEnabled(agent) !== enabled); + const skippedCount = projectAgents.length - eligibleAgents.length; + if (eligibleAgents.length === 0) { + addToast(enabled ? t("agents.noHeartbeatsToEnable", "No agent heartbeats need enabling") : t("agents.noHeartbeatsToDisable", "No agent heartbeats need disabling"), "error"); + return; + } + const confirmed = await confirm({ + title: enabled ? t("agents.enableAllHeartbeats", "Enable all heartbeats") : t("agents.disableAllHeartbeats", "Disable all heartbeats"), + message: enabled + ? t("agents.enableAllHeartbeatsConfirm", "Enable heartbeats for {{count}} project agents?", { count: eligibleAgents.length }) + : t("agents.disableAllHeartbeatsConfirm", "Disable heartbeats for {{count}} project agents?", { count: eligibleAgents.length }), + danger: !enabled, + }); + if (!confirmed) return; + const results = await Promise.allSettled(eligibleAgents.map((agent) => updateAgent(agent.id, { runtimeConfig: withAgentHeartbeatEnabled(agent, enabled) }, projectId))); + const failedResults = results + .map((result, index) => ({ result, agent: eligibleAgents[index] })) + .filter((entry): entry is { result: PromiseRejectedResult; agent: Agent } => entry.result.status === "rejected"); + const successCount = results.length - failedResults.length; + const summary = enabled + ? t("agents.enableHeartbeatsSummary", "Enabled {{count}} heartbeats; skipped {{skipped}}", { count: successCount, skipped: skippedCount }) + : t("agents.disableHeartbeatsSummary", "Disabled {{count}} heartbeats; skipped {{skipped}}", { count: successCount, skipped: skippedCount }); + const failureSummary = failedResults + .slice(0, 3) + .map(({ agent, result }) => `${agent.name || agent.id}: ${getErrorMessage(result.reason)}`) + .join("; "); + addToast(failedResults.length ? `${summary}; ${t("agents.bulkFailures", "failed {{count}}", { count: failedResults.length })}${failureSummary ? ` (${failureSummary})` : ""}` : summary, failedResults.length ? "error" : "success"); + await loadAgents(); + } catch (err) { + addToast(t("agents.heartbeatUpdateFailed", "Failed to update heartbeat: {{error}}", { error: getErrorMessage(err) }), "error"); + } finally { + setIsBulkHeartbeatMutationRunning(false); + } + }; + const handleStateChange = async (agentId: string, newState: AgentState) => { if (transitioningAgentIds.has(agentId)) return; @@ -919,6 +1002,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin }; const handleHeartbeatIntervalChange = async (agent: Agent, newIntervalMs: number) => { + if (isBulkHeartbeatMutationRunning || heartbeatMutationAgentIds.has(agent.id)) return; // Clear custom input state when selecting a preset if (customHeartbeatAgentId === agent.id) { setCustomHeartbeatAgentId(null); @@ -934,11 +1018,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin await updateAgent( agent.id, { - runtimeConfig: { - ...(agent.runtimeConfig ?? {}), - enabled: true, - heartbeatIntervalMs: newIntervalMs, - }, + runtimeConfig: { ...withAgentHeartbeatEnabled(agent, true), heartbeatIntervalMs: newIntervalMs }, }, projectId, ); @@ -951,28 +1031,31 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin } }; - const handleHeartbeatDisabled = async (agent: Agent) => { - setUpdatingHeartbeatAgentId(agent.id); + const handleHeartbeatEnabledChange = async (agent: Agent, enabled: boolean) => { + if (isEphemeralAgent(agent) || isBulkHeartbeatMutationRunning || heartbeatMutationAgentIds.has(agent.id)) return; + setHeartbeatMutationAgentIds((previous) => new Set(previous).add(agent.id)); try { - await updateAgent( - agent.id, - { - runtimeConfig: { - ...(agent.runtimeConfig ?? {}), - enabled: false, - }, - }, - projectId, + await updateAgent(agent.id, { runtimeConfig: withAgentHeartbeatEnabled(agent, enabled) }, projectId); + addToast( + enabled + ? t("agents.heartbeatEnabledForAgent", "Heartbeat enabled for {{name}}", { name: agent.name }) + : t("agents.heartbeatDisabled", "Heartbeat disabled for {{name}}", { name: agent.name }), + "success", ); - addToast(t("agents.heartbeatDisabled", "Heartbeat disabled for {{name}}", { name: agent.name }), "success"); - void loadAgents(); + await loadAgents(); } catch (err) { - addToast(t("agents.heartbeatIntervalUpdateFailed", "Failed to update heartbeat interval: {{error}}", { error: getErrorMessage(err) }), "error"); + addToast(t("agents.heartbeatUpdateFailed", "Failed to update heartbeat: {{error}}", { error: getErrorMessage(err) }), "error"); } finally { - setUpdatingHeartbeatAgentId(null); + setHeartbeatMutationAgentIds((previous) => { + const next = new Set(previous); + next.delete(agent.id); + return next; + }); } }; + const handleHeartbeatDisabled = (agent: Agent) => handleHeartbeatEnabledChange(agent, false); + /** * Handle saving custom heartbeat interval from typed minutes input. * Validation behavior: @@ -983,6 +1066,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin * - Value >= 5: save exact minute value converted to ms */ const handleCustomHeartbeatSave = async (agent: Agent) => { + if (isBulkHeartbeatMutationRunning || heartbeatMutationAgentIds.has(agent.id)) return; const inputValue = customHeartbeatMinutes[agent.id] ?? ""; // Validate: empty value @@ -1011,11 +1095,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin await updateAgent( agent.id, { - runtimeConfig: { - ...(agent.runtimeConfig ?? {}), - enabled: true, - heartbeatIntervalMs: MIN_HEARTBEAT_INTERVAL_MS, - }, + runtimeConfig: { ...withAgentHeartbeatEnabled(agent, true), heartbeatIntervalMs: MIN_HEARTBEAT_INTERVAL_MS }, }, projectId, ); @@ -1042,11 +1122,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin await updateAgent( agent.id, { - runtimeConfig: { - ...(agent.runtimeConfig ?? {}), - enabled: true, - heartbeatIntervalMs: intervalMs, - }, + runtimeConfig: { ...withAgentHeartbeatEnabled(agent, true), heartbeatIntervalMs: intervalMs }, }, projectId, ); @@ -1067,6 +1143,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin /** Handle selecting custom option from dropdown */ const handleSelectCustomHeartbeat = (agent: Agent) => { + if (isBulkHeartbeatMutationRunning || heartbeatMutationAgentIds.has(agent.id)) return; const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs); // Convert ms to minutes for the input field const currentMinutes = Math.round(configuredIntervalMs / 60_000); @@ -1378,6 +1455,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin const isPauseAllDisabled = isBulkEligibilityLoading || isBulkActionRunning || bulkPauseEligibleCount === 0; const isResumeAllDisabled = isBulkEligibilityLoading || isBulkActionRunning || bulkResumeEligibleCount === 0; + const isEnableAllHeartbeatsDisabled = isBulkEligibilityLoading || isBulkActionRunning || isBulkHeartbeatMutationRunning || bulkEnableHeartbeatEligibleCount === 0; + const isDisableAllHeartbeatsDisabled = isBulkEligibilityLoading || isBulkActionRunning || isBulkHeartbeatMutationRunning || bulkDisableHeartbeatEligibleCount === 0; const showInitialAgentsLoading = isLoading && agents.length === 0; const handleOpenNewAgent = useCallback(() => { @@ -1568,6 +1647,32 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin : t("agents.pauseCountHint", { count: bulkPauseEligibleCount, defaultValue_one: "Pause {{count}} active/running agent", defaultValue_other: "Pause {{count}} active/running agents" })} + +
- {(agent.state === "idle" || agent.state === "paused" || agent.state === "error") && ( -
+
+ void handleHeartbeatEnabledChange(target, !isAgentHeartbeatEnabled(target))} /> + {(agent.state === "idle" || agent.state === "paused" || agent.state === "error") && ( -
- )} + )} +
); }) @@ -1887,9 +1995,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin * while absent enabled remains backwards-compatible as enabled. Interval changes always persist * enabled: true; disabling retains the complete runtime configuration and saved cadence. */ - const isHeartbeatDisabled = agent.runtimeConfig?.enabled === false; + const isHeartbeatDisabled = !isAgentHeartbeatEnabled(agent); const heartbeatSelectValue = isHeartbeatDisabled ? HEARTBEAT_DISABLED_OPTION_VALUE : String(configuredIntervalMs); - const isUpdatingHeartbeat = updatingHeartbeatAgentId === agent.id; + const isUpdatingHeartbeat = isBulkHeartbeatMutationRunning || updatingHeartbeatAgentId === agent.id || heartbeatMutationAgentIds.has(agent.id); const modelLabel = getAgentModelLabel(agent); return (
)} + {!isEphemeralAgent(agent) && (
{t("agents.heartbeat", "Heartbeat:")} {customHeartbeatAgentId === agent.id ? ( @@ -2136,6 +2245,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin ); })()}
+ )}
diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx index 6f3992c81f..2a909e07ff 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx @@ -989,6 +989,35 @@ describe("Advanced Settings", () => { }); }); + it("preserves the complete runtime config when shared heartbeat helper changes enabled state", async () => { + mockFetchAgent.mockResolvedValue(createMockAgent({ + runtimeConfig: { + heartbeatIntervalMs: 30000, + heartbeatTimeoutMs: 120000, + maxConcurrentRuns: 2, + messageResponseMode: "on-heartbeat", + unknownRuntimeKey: "preserved", + }, + })); + mockUpdateAgent.mockResolvedValue(createMockAgent() as any); + const user = userEvent.setup(); + render(); + await navigateToSettings(user); + expect((await screen.findByLabelText("Heartbeat Enabled") as HTMLInputElement).checked).toBe(true); + await user.click(screen.getByLabelText("Heartbeat Enabled")); + await user.click(screen.getByText("Save Settings")); + await waitFor(() => expect(mockUpdateAgent).toHaveBeenCalledWith("agent-001", expect.objectContaining({ + runtimeConfig: expect.objectContaining({ + enabled: false, + heartbeatIntervalMs: 30000, + heartbeatTimeoutMs: 120000, + maxConcurrentRuns: 2, + messageResponseMode: "on-heartbeat", + unknownRuntimeKey: "preserved", + }), + }), undefined)); + }); + it("defaults run-missed-heartbeat-on-startup toggle to disabled when runtimeConfig flag is missing", async () => { mockFetchAgent.mockResolvedValue(createMockAgent({ runtimeConfig: { diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.test-helpers.ts b/packages/dashboard/app/components/__tests__/AgentDetailView.test-helpers.ts index 0fa3aef1e8..bf454dd939 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.test-helpers.ts @@ -53,6 +53,8 @@ vi.mock("../../api", () => ({ fetchAgent: (...args: Parameters) => mockFetchAgent(...args), fetchAgents: (...args: Parameters) => mockFetchAgents(...args), updateAgent: (...args: Parameters) => mockUpdateAgent(...args), + isAgentHeartbeatEnabled: (agent: { runtimeConfig?: { enabled?: boolean } }) => agent.runtimeConfig?.enabled !== false, + withAgentHeartbeatEnabled: (agent: { runtimeConfig?: Record }, enabled: boolean) => ({ ...(agent.runtimeConfig ?? {}), enabled }), updateAgentState: (...args: Parameters) => mockUpdateAgentState(...args), deleteAgent: (...args: Parameters) => mockDeleteAgent(...args), fetchAgentLogs: vi.fn(), diff --git a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx index 12af9ba4c3..b5e7d91792 100644 --- a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx @@ -2937,6 +2937,74 @@ describe("AgentsView", () => { }); }); + describe("durable heartbeat enablement controls", () => { + it("uses the shared default-enabled contract and preserved config from list and board controls", async () => { + const durable = { + ...mockAgents[1], + runtimeConfig: { heartbeatIntervalMs: 900_000, heartbeatTimeoutMs: 120_000, maxConcurrentRuns: 3, unknownRuntimeKey: "preserved" }, + }; + const worker = { ...mockAgents[2], id: "agent-worker", name: "Task Worker", metadata: { agentKind: "task-worker" } }; + mockFetchAgents.mockResolvedValue([durable, worker]); + mockFetchAgentStats.mockResolvedValue({ total: 2, byState: {}, byRole: {} }); + + renderView(); + + fireEvent.click(await screen.findByTitle("Board view")); + const boardToggle = await screen.findByRole("button", { name: "Disable heartbeat for Test Agent 2" }); + fireEvent.click(boardToggle); + await waitFor(() => { + expect(mockUpdateAgent).toHaveBeenCalledWith("agent-002", { + runtimeConfig: { + enabled: false, + heartbeatIntervalMs: 900_000, + heartbeatTimeoutMs: 120_000, + maxConcurrentRuns: 3, + unknownRuntimeKey: "preserved", + }, + }, projectId); + }); + expect(screen.queryByRole("button", { name: /heartbeat for Task Worker/i })).toBeNull(); + expect(screen.queryByTestId("agent-detail-view")).toBeNull(); + + expect(screen.queryByRole("button", { name: /heartbeat for Task Worker/i })).toBeNull(); + expect(boardToggle).toHaveAttribute("aria-pressed", "true"); + }); + + it("renders recursive org controls and prevents a pending toggle from issuing duplicate PATCH requests", async () => { + let resolveUpdate: ((agent: Agent) => void) | undefined; + mockFetchOrgTree.mockResolvedValue([{ agent: { ...mockAgents[1], runtimeConfig: { enabled: false, heartbeatIntervalMs: 900_000 } }, children: [] }]); + mockUpdateAgent.mockImplementation(() => new Promise((resolve) => { resolveUpdate = resolve; })); + renderView(); + fireEvent.click(screen.getByRole("button", { name: "Org Chart view" })); + + const toggle = await screen.findByRole("button", { name: "Enable heartbeat for Test Agent 2" }); + fireEvent.click(toggle); + fireEvent.click(toggle); + expect(mockUpdateAgent).toHaveBeenCalledTimes(1); + expect(toggle).toBeDisabled(); + resolveUpdate?.(mockAgents[1]); + }); + + it("updates every eligible current-project durable agent through bulk controls despite filtered display", async () => { + const disabled = { ...mockAgents[1], id: "agent-disabled", name: "Disabled", runtimeConfig: { enabled: false, heartbeatIntervalMs: 900_000, unknownRuntimeKey: "keep" } }; + const enabled = { ...mockAgents[2], id: "agent-enabled", name: "Enabled", runtimeConfig: { enabled: true, heartbeatIntervalMs: 1_800_000 } }; + const worker = { ...mockAgents[3], id: "agent-worker", name: "Worker", metadata: { agentKind: "task-worker" }, runtimeConfig: { enabled: false } }; + mockFetchAgents.mockResolvedValue([disabled, enabled, worker]); + mockFetchAgentStats.mockResolvedValue({ total: 3, byState: {}, byRole: {} }); + renderView(); + + await openControlsPanel(); + fireEvent.click(screen.getByRole("menuitem", { name: /enable all heartbeats/i })); + await waitFor(() => expect(mockConfirm).toHaveBeenCalled()); + await waitFor(() => { + expect(mockUpdateAgent).toHaveBeenCalledTimes(1); + expect(mockUpdateAgent).toHaveBeenCalledWith("agent-disabled", { + runtimeConfig: { enabled: true, heartbeatIntervalMs: 900_000, unknownRuntimeKey: "keep" }, + }, projectId); + }); + }); + }); + describe("global heartbeat multiplier", () => { it("renders the global heartbeat speed control", async () => { mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 }); diff --git a/packages/dashboard/scripts/browser-layout-smoke.mjs b/packages/dashboard/scripts/browser-layout-smoke.mjs index 0d0e7bcd6e..0d4912f5c9 100644 --- a/packages/dashboard/scripts/browser-layout-smoke.mjs +++ b/packages/dashboard/scripts/browser-layout-smoke.mjs @@ -17,6 +17,8 @@ const appRoot = path.join(dashboardRoot, "app"); const clientDistRoot = path.join(dashboardRoot, "dist", "client"); const requireBrowser = process.argv.includes("--require-browser") || process.env.FUSION_BROWSER_SMOKE_REQUIRE === "1"; const screenshotPath = process.env.FUSION_BROWSER_SMOKE_SCREENSHOT; +const agentHeartbeatMobileScreenshotPath = process.env.FUSION_AGENT_HEARTBEAT_MOBILE_SCREENSHOT; +const agentHeartbeatDesktopScreenshotPath = process.env.FUSION_AGENT_HEARTBEAT_DESKTOP_SCREENSHOT; function log(message) { console.log(`[dashboard-browser-smoke] ${message}`); @@ -506,6 +508,20 @@ export function createSmokeHtml() { FNXC:CommandCenterTesting 2026-06-19-02:04: FN-6685 requires a real-Blink desktop and mobile gate for the FN-6683/FN-6684 recharts surfaces because jsdom cannot compute ResponsiveContainer parent height, min-content shrink, or overflow. This fixture mirrors Command Center tabpanel/card wrappers and includes populated pie/line plus empty states so emitted dashboard CSS owns the sizing chain under test. --> + +
+

Agents

+ +
+
Enabled durable agent
+
Disabled durable agent
+
Task worker (excluded)
+
+
+