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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-23 14:32:58 -07:00
parent 3d0ce2ed3a
commit 2978ec4973
16 changed files with 484 additions and 50 deletions

View File

@@ -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.

View File

@@ -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. - 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. - 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 - 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**, 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. - 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`) - 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`)

View File

@@ -31,6 +31,18 @@ import { dedupe } from "./dedupe.js";
// ── Agent API ──────────────────────────────────────────────────────────── // ── 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<Agent, "runtimeConfig">): boolean {
return agent.runtimeConfig?.enabled !== false;
}
export function withAgentHeartbeatEnabled<T extends Pick<Agent, "runtimeConfig">>(agent: T, enabled: boolean): AgentUpdateInput["runtimeConfig"] {
return { ...(agent.runtimeConfig ?? {}), enabled };
}
export interface AgentPromptSizePoint { export interface AgentPromptSizePoint {
runId: string; runId: string;
createdAt: string; createdAt: string;

View File

@@ -801,6 +801,7 @@ export {
deleteAgent, deleteAgent,
deleteAgentAvatar, deleteAgentAvatar,
fetchAgent, fetchAgent,
isAgentHeartbeatEnabled,
fetchAgentHeartbeats, fetchAgentHeartbeats,
fetchAgentMemory, fetchAgentMemory,
fetchAgentMemoryFile, fetchAgentMemoryFile,
@@ -817,6 +818,7 @@ export {
startAgentRun, startAgentRun,
stopAgentRun, stopAgentRun,
updateAgent, updateAgent,
withAgentHeartbeatEnabled,
updateAgentInstructions, updateAgentInstructions,
updateAgentMemory, updateAgentMemory,
updateAgentSoul, updateAgentSoul,

View File

@@ -13,7 +13,7 @@ import {
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api"; 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 { Agent } from "../api";
import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel } from "@fusion/core"; import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel } from "@fusion/core";
import { AGENT_PERMISSIONS, getErrorMessage, isEphemeralAgent } from "@fusion/core"; import { AGENT_PERMISSIONS, getErrorMessage, isEphemeralAgent } from "@fusion/core";
@@ -3348,10 +3348,6 @@ function deriveHeartbeatValues(runtimeConfig: AgentDetail["runtimeConfig"] | und
return nextValues; return nextValues;
} }
function deriveHeartbeatEnabled(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
return runtimeConfig?.enabled !== false;
}
function deriveAutoClaimRelevantTasksEnabled(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean { function deriveAutoClaimRelevantTasksEnabled(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
return runtimeConfig?.autoClaimRelevantTasks !== false; return runtimeConfig?.autoClaimRelevantTasks !== false;
} }
@@ -3749,7 +3745,7 @@ function ConfigTab({
() => deriveHeartbeatValues(agent.runtimeConfig), () => deriveHeartbeatValues(agent.runtimeConfig),
); );
const [heartbeatEnabled, setHeartbeatEnabled] = useState<boolean>( const [heartbeatEnabled, setHeartbeatEnabled] = useState<boolean>(
() => deriveHeartbeatEnabled(agent.runtimeConfig), () => isAgentHeartbeatEnabled(agent),
); );
const [autoClaimRelevantTasksEnabled, setAutoClaimRelevantTasksEnabled] = useState<boolean>( const [autoClaimRelevantTasksEnabled, setAutoClaimRelevantTasksEnabled] = useState<boolean>(
() => deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig), () => deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig),
@@ -4073,7 +4069,7 @@ function ConfigTab({
} }
// Check heartbeat values // Check heartbeat values
const rc = agent.runtimeConfig ?? {}; 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 (autoClaimRelevantTasksEnabled !== deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig)) return true;
if (engineerBacklogAutoClaimEnabled !== deriveEngineerBacklogAutoClaim(agent.runtimeConfig)) return true; if (engineerBacklogAutoClaimEnabled !== deriveEngineerBacklogAutoClaim(agent.runtimeConfig)) return true;
if (assignmentPolicy !== deriveAssignmentPolicy(agent.runtimeConfig)) return true; if (assignmentPolicy !== deriveAssignmentPolicy(agent.runtimeConfig)) return true;
@@ -4155,7 +4151,7 @@ function ConfigTab({
previousAgentRuntimeSyncRef.current = nextSnapshot; previousAgentRuntimeSyncRef.current = nextSnapshot;
setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig)); setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig));
setHeartbeatEnabled(deriveHeartbeatEnabled(agent.runtimeConfig)); setHeartbeatEnabled(isAgentHeartbeatEnabled(agent));
setAutoClaimRelevantTasksEnabled(deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig)); setAutoClaimRelevantTasksEnabled(deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig));
setEngineerBacklogAutoClaimEnabled(deriveEngineerBacklogAutoClaim(agent.runtimeConfig)); setEngineerBacklogAutoClaimEnabled(deriveEngineerBacklogAutoClaim(agent.runtimeConfig));
setRunMissedHeartbeatOnStartup(deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig)); setRunMissedHeartbeatOnStartup(deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig));
@@ -4318,7 +4314,7 @@ function ConfigTab({
// Build the runtimeConfig payload — only include non-empty values // Build the runtimeConfig payload — only include non-empty values
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig }; const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
newRuntimeConfig.enabled = heartbeatEnabled; Object.assign(newRuntimeConfig, withAgentHeartbeatEnabled(agent, heartbeatEnabled));
newRuntimeConfig.autoClaimRelevantTasks = autoClaimRelevantTasksEnabled; newRuntimeConfig.autoClaimRelevantTasks = autoClaimRelevantTasksEnabled;
newRuntimeConfig.engineerBacklogAutoClaim = engineerBacklogAutoClaimEnabled; newRuntimeConfig.engineerBacklogAutoClaim = engineerBacklogAutoClaimEnabled;
if (assignmentPolicy === "auto") { if (assignmentPolicy === "auto") {

View File

@@ -1235,6 +1235,17 @@ AgentsView uses the measured SVG overlay as the single connector system so paren
font-size: var(--space-md); 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 { .org-chart-children {
position: relative; position: relative;
display: flex; display: flex;

View File

@@ -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 { 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 { 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 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 }))); const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView })));
import { AgentTokenStatsPanel } from "./AgentTokenStatsPanel"; import { AgentTokenStatsPanel } from "./AgentTokenStatsPanel";
@@ -178,16 +178,47 @@ function getHealthSummary(agent: Agent, health: AgentHealthStatus, t: TFunction<
type OrgChartLink = { parentId: string; childId: string }; type OrgChartLink = { parentId: string; childId: string };
type OrgChartTransform = { scale: number; x: number; y: number }; 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 (
<button
type="button"
className="btn btn-sm agent-heartbeat-toggle"
disabled={pending}
aria-pressed={enabled}
aria-label={t("agents.heartbeatActionForAgent", "{{action}} for {{name}}", { action: label, name: agent.name })}
onClick={(event) => {
event.stopPropagation();
onToggle(agent);
}}
>
{pending ? t("agents.heartbeatUpdating", "Updating heartbeat…") : label}
</button>
);
}
type OrgChartNodeProps = { type OrgChartNodeProps = {
node: OrgTreeNode; node: OrgTreeNode;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onToggleHeartbeat: (agent: Agent) => void;
isHeartbeatPending: (agentId: string) => boolean;
getHealthStatus: (agent: Agent) => AgentHealthStatus; getHealthStatus: (agent: Agent) => AgentHealthStatus;
selectedAgentId: string | null; selectedAgentId: string | null;
registerNodeElement: (id: string, element: HTMLDivElement | null) => void; registerNodeElement: (id: string, element: HTMLDivElement | null) => void;
linksRef: MutableRefObject<OrgChartLink[]>; linksRef: MutableRefObject<OrgChartLink[]>;
}; };
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 { t } = useTranslation("app");
const { agent, children } = node; const { agent, children } = node;
const health = getHealthStatus(agent); const health = getHealthStatus(agent);
@@ -227,6 +258,7 @@ function OrgChartNode({ node, onSelect, getHealthStatus, selectedAgentId, regist
</span> </span>
</div> </div>
</div> </div>
<div className="org-chart-node__actions"><HeartbeatToggle agent={agent} pending={isHeartbeatPending(agent.id)} onToggle={onToggleHeartbeat} /></div>
{children.length > 0 && ( {children.length > 0 && (
<div className="org-chart-children" role="group" aria-label={t("agents.orgChartEmployees", "{{name}} employees", { name: agent.name })}> <div className="org-chart-children" role="group" aria-label={t("agents.orgChartEmployees", "{{name}} employees", { name: agent.name })}>
{children.map((child) => { {children.map((child) => {
@@ -236,6 +268,8 @@ function OrgChartNode({ node, onSelect, getHealthStatus, selectedAgentId, regist
key={child.agent.id} key={child.agent.id}
node={child} node={child}
onSelect={onSelect} onSelect={onSelect}
onToggleHeartbeat={onToggleHeartbeat}
isHeartbeatPending={isHeartbeatPending}
getHealthStatus={getHealthStatus} getHealthStatus={getHealthStatus}
selectedAgentId={selectedAgentId} selectedAgentId={selectedAgentId}
registerNodeElement={registerNodeElement} registerNodeElement={registerNodeElement}
@@ -462,6 +496,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const [isBulkEligibilityLoading, setIsBulkEligibilityLoading] = useState(false); const [isBulkEligibilityLoading, setIsBulkEligibilityLoading] = useState(false);
const [bulkPauseEligibleCount, setBulkPauseEligibleCount] = useState(0); const [bulkPauseEligibleCount, setBulkPauseEligibleCount] = useState(0);
const [bulkResumeEligibleCount, setBulkResumeEligibleCount] = useState(0); const [bulkResumeEligibleCount, setBulkResumeEligibleCount] = useState(0);
const [bulkEnableHeartbeatEligibleCount, setBulkEnableHeartbeatEligibleCount] = useState(0);
const [bulkDisableHeartbeatEligibleCount, setBulkDisableHeartbeatEligibleCount] = useState(0);
const [orgChartTransform, setOrgChartTransform] = useState<OrgChartTransform>({ scale: 1, x: 0, y: 0 }); const [orgChartTransform, setOrgChartTransform] = useState<OrgChartTransform>({ scale: 1, x: 0, y: 0 });
const [isOrgChartPanning, setIsOrgChartPanning] = useState(false); const [isOrgChartPanning, setIsOrgChartPanning] = useState(false);
const controlsPanelRef = useRef<HTMLDivElement>(null); const controlsPanelRef = useRef<HTMLDivElement>(null);
@@ -560,6 +596,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null); const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null);
const roleSelectRef = useRef<HTMLSelectElement>(null); const roleSelectRef = useRef<HTMLSelectElement>(null);
const [updatingHeartbeatAgentId, setUpdatingHeartbeatAgentId] = useState<string | null>(null); const [updatingHeartbeatAgentId, setUpdatingHeartbeatAgentId] = useState<string | null>(null);
const [heartbeatMutationAgentIds, setHeartbeatMutationAgentIds] = useState<Set<string>>(new Set());
const [isBulkHeartbeatMutationRunning, setIsBulkHeartbeatMutationRunning] = useState(false);
/** Agent ID currently showing custom heartbeat input */ /** Agent ID currently showing custom heartbeat input */
const [customHeartbeatAgentId, setCustomHeartbeatAgentId] = useState<string | null>(null); const [customHeartbeatAgentId, setCustomHeartbeatAgentId] = useState<string | null>(null);
/** Custom minutes input value for each agent */ /** 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, nonEphemeralAgents.filter((projectAgent) => projectAgent.state === "active" || projectAgent.state === "running").length,
); );
setBulkResumeEligibleCount(nonEphemeralAgents.filter((projectAgent) => projectAgent.state === "paused").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) => { .catch((err) => {
if (cancelled) return; if (cancelled) return;
setBulkPauseEligibleCount(0); setBulkPauseEligibleCount(0);
setBulkResumeEligibleCount(0); setBulkResumeEligibleCount(0);
setBulkEnableHeartbeatEligibleCount(0);
setBulkDisableHeartbeatEligibleCount(0);
addToast(t("agents.bulkActionsLoadFailed", "Failed to load bulk agent actions: {{error}}", { error: getErrorMessage(err) }), "error"); addToast(t("agents.bulkActionsLoadFailed", "Failed to load bulk agent actions: {{error}}", { error: getErrorMessage(err) }), "error");
}) })
.finally(() => { .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) => { const handleStateChange = async (agentId: string, newState: AgentState) => {
if (transitioningAgentIds.has(agentId)) return; if (transitioningAgentIds.has(agentId)) return;
@@ -919,6 +1002,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
}; };
const handleHeartbeatIntervalChange = async (agent: Agent, newIntervalMs: number) => { const handleHeartbeatIntervalChange = async (agent: Agent, newIntervalMs: number) => {
if (isBulkHeartbeatMutationRunning || heartbeatMutationAgentIds.has(agent.id)) return;
// Clear custom input state when selecting a preset // Clear custom input state when selecting a preset
if (customHeartbeatAgentId === agent.id) { if (customHeartbeatAgentId === agent.id) {
setCustomHeartbeatAgentId(null); setCustomHeartbeatAgentId(null);
@@ -934,11 +1018,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
await updateAgent( await updateAgent(
agent.id, agent.id,
{ {
runtimeConfig: { runtimeConfig: { ...withAgentHeartbeatEnabled(agent, true), heartbeatIntervalMs: newIntervalMs },
...(agent.runtimeConfig ?? {}),
enabled: true,
heartbeatIntervalMs: newIntervalMs,
},
}, },
projectId, projectId,
); );
@@ -951,28 +1031,31 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
} }
}; };
const handleHeartbeatDisabled = async (agent: Agent) => { const handleHeartbeatEnabledChange = async (agent: Agent, enabled: boolean) => {
setUpdatingHeartbeatAgentId(agent.id); if (isEphemeralAgent(agent) || isBulkHeartbeatMutationRunning || heartbeatMutationAgentIds.has(agent.id)) return;
setHeartbeatMutationAgentIds((previous) => new Set(previous).add(agent.id));
try { try {
await updateAgent( await updateAgent(agent.id, { runtimeConfig: withAgentHeartbeatEnabled(agent, enabled) }, projectId);
agent.id, addToast(
{ enabled
runtimeConfig: { ? t("agents.heartbeatEnabledForAgent", "Heartbeat enabled for {{name}}", { name: agent.name })
...(agent.runtimeConfig ?? {}), : t("agents.heartbeatDisabled", "Heartbeat disabled for {{name}}", { name: agent.name }),
enabled: false, "success",
},
},
projectId,
); );
addToast(t("agents.heartbeatDisabled", "Heartbeat disabled for {{name}}", { name: agent.name }), "success"); await loadAgents();
void loadAgents();
} catch (err) { } 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 { } 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. * Handle saving custom heartbeat interval from typed minutes input.
* Validation behavior: * Validation behavior:
@@ -983,6 +1066,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
* - Value >= 5: save exact minute value converted to ms * - Value >= 5: save exact minute value converted to ms
*/ */
const handleCustomHeartbeatSave = async (agent: Agent) => { const handleCustomHeartbeatSave = async (agent: Agent) => {
if (isBulkHeartbeatMutationRunning || heartbeatMutationAgentIds.has(agent.id)) return;
const inputValue = customHeartbeatMinutes[agent.id] ?? ""; const inputValue = customHeartbeatMinutes[agent.id] ?? "";
// Validate: empty value // Validate: empty value
@@ -1011,11 +1095,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
await updateAgent( await updateAgent(
agent.id, agent.id,
{ {
runtimeConfig: { runtimeConfig: { ...withAgentHeartbeatEnabled(agent, true), heartbeatIntervalMs: MIN_HEARTBEAT_INTERVAL_MS },
...(agent.runtimeConfig ?? {}),
enabled: true,
heartbeatIntervalMs: MIN_HEARTBEAT_INTERVAL_MS,
},
}, },
projectId, projectId,
); );
@@ -1042,11 +1122,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
await updateAgent( await updateAgent(
agent.id, agent.id,
{ {
runtimeConfig: { runtimeConfig: { ...withAgentHeartbeatEnabled(agent, true), heartbeatIntervalMs: intervalMs },
...(agent.runtimeConfig ?? {}),
enabled: true,
heartbeatIntervalMs: intervalMs,
},
}, },
projectId, projectId,
); );
@@ -1067,6 +1143,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
/** Handle selecting custom option from dropdown */ /** Handle selecting custom option from dropdown */
const handleSelectCustomHeartbeat = (agent: Agent) => { const handleSelectCustomHeartbeat = (agent: Agent) => {
if (isBulkHeartbeatMutationRunning || heartbeatMutationAgentIds.has(agent.id)) return;
const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs); const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs);
// Convert ms to minutes for the input field // Convert ms to minutes for the input field
const currentMinutes = Math.round(configuredIntervalMs / 60_000); 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 isPauseAllDisabled = isBulkEligibilityLoading || isBulkActionRunning || bulkPauseEligibleCount === 0;
const isResumeAllDisabled = isBulkEligibilityLoading || isBulkActionRunning || bulkResumeEligibleCount === 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 showInitialAgentsLoading = isLoading && agents.length === 0;
const handleOpenNewAgent = useCallback(() => { 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" })} : t("agents.pauseCountHint", { count: bulkPauseEligibleCount, defaultValue_one: "Pause {{count}} active/running agent", defaultValue_other: "Pause {{count}} active/running agents" })}
</span> </span>
</button> </button>
<button
type="button"
className="agent-detail-bulk-menu-item"
role="menuitem"
disabled={isEnableAllHeartbeatsDisabled}
onClick={() => {
setIsControlsPanelOpen(false);
void handleBulkHeartbeatChange(true);
}}
>
<span className="agent-controls-bulk-actions__label"><Play /><span>{t("agents.enableAllHeartbeats", "Enable all heartbeats")}</span></span>
<span className="agent-detail-bulk-menu-item-hint">{t("agents.enableHeartbeatsCountHint", "Enable {{count}} disabled project heartbeats", { count: bulkEnableHeartbeatEligibleCount })}</span>
</button>
<button
type="button"
className="agent-detail-bulk-menu-item"
role="menuitem"
disabled={isDisableAllHeartbeatsDisabled}
onClick={() => {
setIsControlsPanelOpen(false);
void handleBulkHeartbeatChange(false);
}}
>
<span className="agent-controls-bulk-actions__label"><Pause /><span>{t("agents.disableAllHeartbeats", "Disable all heartbeats")}</span></span>
<span className="agent-detail-bulk-menu-item-hint">{t("agents.disableHeartbeatsCountHint", "Disable {{count}} enabled project heartbeats", { count: bulkDisableHeartbeatEligibleCount })}</span>
</button>
<button <button
type="button" type="button"
className="agent-detail-bulk-menu-item" className="agent-detail-bulk-menu-item"
@@ -1769,6 +1874,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
key={node.agent.id} key={node.agent.id}
node={node} node={node}
onSelect={handleOrgChartNodeSelect} onSelect={handleOrgChartNodeSelect}
onToggleHeartbeat={(agent) => void handleHeartbeatEnabledChange(agent, !isAgentHeartbeatEnabled(agent))}
isHeartbeatPending={(agentId) => isBulkHeartbeatMutationRunning || heartbeatMutationAgentIds.has(agentId)}
getHealthStatus={getHealthStatus} getHealthStatus={getHealthStatus}
selectedAgentId={selectedOrgChartAgentId} selectedAgentId={selectedOrgChartAgentId}
registerNodeElement={registerOrgChartNodeElement} registerNodeElement={registerOrgChartNodeElement}
@@ -1852,8 +1959,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""} {health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
</div> </div>
</div> </div>
{(agent.state === "idle" || agent.state === "paused" || agent.state === "error") && ( <div className="agent-board-actions">
<div className="agent-board-actions"> <HeartbeatToggle agent={agent} pending={isBulkHeartbeatMutationRunning || heartbeatMutationAgentIds.has(agent.id)} onToggle={(target) => void handleHeartbeatEnabledChange(target, !isAgentHeartbeatEnabled(target))} />
{(agent.state === "idle" || agent.state === "paused" || agent.state === "error") && (
<button <button
className="btn btn-sm btn-danger" className="btn btn-sm btn-danger"
onClick={() => void handleDelete(agent.id, agent.name)} onClick={() => void handleDelete(agent.id, agent.name)}
@@ -1861,8 +1969,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
</div> )}
)} </div>
</div> </div>
); );
}) })
@@ -1887,9 +1995,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
* while absent enabled remains backwards-compatible as enabled. Interval changes always persist * while absent enabled remains backwards-compatible as enabled. Interval changes always persist
* enabled: true; disabling retains the complete runtime configuration and saved cadence. * 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 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); const modelLabel = getAgentModelLabel(agent);
return ( return (
<div <div
@@ -2030,6 +2138,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={isAgentCardInViewport(`list:${agent.id}`)} projectId={projectId} /> <RuntimeFallbackBadge taskId={agent.taskId} isInViewport={isAgentCardInViewport(`list:${agent.id}`)} projectId={projectId} />
</div> </div>
)} )}
{!isEphemeralAgent(agent) && (
<div className="agent-heartbeat-control"> <div className="agent-heartbeat-control">
<span className="text-secondary">{t("agents.heartbeat", "Heartbeat:")}</span> <span className="text-secondary">{t("agents.heartbeat", "Heartbeat:")}</span>
{customHeartbeatAgentId === agent.id ? ( {customHeartbeatAgentId === agent.id ? (
@@ -2136,6 +2245,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
); );
})()} })()}
</div> </div>
)}
</div> </div>
<div className="agent-card-actions"> <div className="agent-card-actions">

View File

@@ -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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
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 () => { it("defaults run-missed-heartbeat-on-startup toggle to disabled when runtimeConfig flag is missing", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: { runtimeConfig: {

View File

@@ -53,6 +53,8 @@ vi.mock("../../api", () => ({
fetchAgent: (...args: Parameters<ApiModule["fetchAgent"]>) => mockFetchAgent(...args), fetchAgent: (...args: Parameters<ApiModule["fetchAgent"]>) => mockFetchAgent(...args),
fetchAgents: (...args: Parameters<ApiModule["fetchAgents"]>) => mockFetchAgents(...args), fetchAgents: (...args: Parameters<ApiModule["fetchAgents"]>) => mockFetchAgents(...args),
updateAgent: (...args: Parameters<ApiModule["updateAgent"]>) => mockUpdateAgent(...args), updateAgent: (...args: Parameters<ApiModule["updateAgent"]>) => mockUpdateAgent(...args),
isAgentHeartbeatEnabled: (agent: { runtimeConfig?: { enabled?: boolean } }) => agent.runtimeConfig?.enabled !== false,
withAgentHeartbeatEnabled: (agent: { runtimeConfig?: Record<string, unknown> }, enabled: boolean) => ({ ...(agent.runtimeConfig ?? {}), enabled }),
updateAgentState: (...args: Parameters<ApiModule["updateAgentState"]>) => mockUpdateAgentState(...args), updateAgentState: (...args: Parameters<ApiModule["updateAgentState"]>) => mockUpdateAgentState(...args),
deleteAgent: (...args: Parameters<ApiModule["deleteAgent"]>) => mockDeleteAgent(...args), deleteAgent: (...args: Parameters<ApiModule["deleteAgent"]>) => mockDeleteAgent(...args),
fetchAgentLogs: vi.fn(), fetchAgentLogs: vi.fn(),

View File

@@ -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(<AgentsView addToast={mockAddToast} projectId={projectId} />);
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<Agent>((resolve) => { resolveUpdate = resolve; }));
renderView(<AgentsView addToast={mockAddToast} projectId={projectId} />);
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(<AgentsView addToast={mockAddToast} projectId={projectId} />);
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", () => { describe("global heartbeat multiplier", () => {
it("renders the global heartbeat speed control", async () => { it("renders the global heartbeat speed control", async () => {
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 }); mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });

View File

@@ -17,6 +17,8 @@ const appRoot = path.join(dashboardRoot, "app");
const clientDistRoot = path.join(dashboardRoot, "dist", "client"); const clientDistRoot = path.join(dashboardRoot, "dist", "client");
const requireBrowser = process.argv.includes("--require-browser") || process.env.FUSION_BROWSER_SMOKE_REQUIRE === "1"; const requireBrowser = process.argv.includes("--require-browser") || process.env.FUSION_BROWSER_SMOKE_REQUIRE === "1";
const screenshotPath = process.env.FUSION_BROWSER_SMOKE_SCREENSHOT; 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) { function log(message) {
console.log(`[dashboard-browser-smoke] ${message}`); console.log(`[dashboard-browser-smoke] ${message}`);
@@ -506,6 +508,20 @@ export function createSmokeHtml() {
FNXC:CommandCenterTesting 2026-06-19-02:04: 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. 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.
--> -->
<!-- FNXC:AgentHeartbeatControls 2026-07-23-14:20: Production-CSS smoke keeps durable per-agent and project controls visible at both viewports while task-worker cards leave no control shell. -->
<section class="agents-view" data-smoke="agent-heartbeat-controls" aria-label="Agent heartbeat controls">
<header class="view-header"><h2 class="view-header__title">Agents</h2><div class="agents-view-primary-actions"><button class="btn-icon agent-controls-trigger" type="button" aria-label="Controls">Controls</button></div></header>
<div class="agent-controls-bulk-actions" role="menu" aria-label="Bulk agent actions">
<button class="agent-detail-bulk-menu-item" type="button" role="menuitem" data-smoke="enable-all-heartbeats">Enable all heartbeats</button>
<button class="agent-detail-bulk-menu-item" type="button" role="menuitem" data-smoke="disable-all-heartbeats">Disable all heartbeats</button>
</div>
<div class="agent-board" data-smoke="agent-heartbeat-board">
<article class="agent-board-card"><div class="agent-board-name">Enabled durable agent</div><div class="agent-board-actions"><button class="btn btn-sm agent-heartbeat-toggle" type="button" aria-pressed="true" data-smoke="agent-heartbeat-toggle">Disable heartbeat</button></div></article>
<article class="agent-board-card"><div class="agent-board-name">Disabled durable agent</div><div class="agent-board-actions"><button class="btn btn-sm agent-heartbeat-toggle" type="button" aria-pressed="false" data-smoke="agent-heartbeat-toggle">Enable heartbeat</button></div></article>
<article class="agent-board-card" data-smoke="ephemeral-agent-card"><div class="agent-board-name">Task worker (excluded)</div><div class="agent-board-actions"></div></article>
</div>
</section>
<section data-smoke="command-center-charts" hidden> <section data-smoke="command-center-charts" hidden>
<div class="command-center" data-testid="command-center"> <div class="command-center" data-testid="command-center">
<header class="cc-header"> <header class="cc-header">
@@ -983,6 +999,31 @@ async function runSmokeChecks(page, pageUrl) {
await loaded; await loaded;
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true"); await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
const collectAgentHeartbeatControlLayout = () => evaluate(page, `(() => {
const fixture = document.querySelector('[data-smoke="agent-heartbeat-controls"]');
const viewportWidth = window.innerWidth;
const controls = [...fixture.querySelectorAll('[data-smoke="agent-heartbeat-toggle"], [data-smoke="enable-all-heartbeats"], [data-smoke="disable-all-heartbeats"]')].map((control) => {
const rect = control.getBoundingClientRect();
return { left: rect.left, right: rect.right, width: rect.width, height: rect.height };
});
const worker = fixture.querySelector('[data-smoke="ephemeral-agent-card"]');
return { viewportWidth, controls, workerToggleCount: worker.querySelectorAll('[data-smoke="agent-heartbeat-toggle"]').length, fixtureOverflow: fixture.scrollWidth - fixture.clientWidth, documentOverflow: document.documentElement.scrollWidth - viewportWidth };
})()`);
const mobileAgentHeartbeatLayout = await collectAgentHeartbeatControlLayout();
assertSmokeResult(
"agent heartbeat controls stay visible on mobile and omit ephemeral shells",
mobileAgentHeartbeatLayout.controls.length === 4 && mobileAgentHeartbeatLayout.controls.every((control) => control.width > 0 && control.height > 0 && control.left >= 0 && control.right <= mobileAgentHeartbeatLayout.viewportWidth + 1) && mobileAgentHeartbeatLayout.workerToggleCount === 0 && mobileAgentHeartbeatLayout.fixtureOverflow <= 1 && mobileAgentHeartbeatLayout.documentOverflow <= 1,
JSON.stringify(mobileAgentHeartbeatLayout),
);
if (agentHeartbeatMobileScreenshotPath) {
/* FNXC:AgentHeartbeatControls 2026-07-23-14:30: Optional proof captures document the durable-agent controls and omitted task-worker shell at the tested mobile viewport. */
await evaluate(page, "document.querySelector('[data-smoke=\"agent-heartbeat-controls\"]').scrollIntoView({ block: 'start' })");
const screenshot = await page.send("Page.captureScreenshot", { format: "png" });
await writeFile(agentHeartbeatMobileScreenshotPath, Buffer.from(screenshot.data, "base64"));
log(`saved agent heartbeat mobile screenshot to ${agentHeartbeatMobileScreenshotPath}`);
}
const initialLayout = await evaluate(page, `(() => { const initialLayout = await evaluate(page, `(() => {
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const nav = document.querySelector('.mobile-nav-bar').getBoundingClientRect(); const nav = document.querySelector('.mobile-nav-bar').getBoundingClientRect();
@@ -1425,6 +1466,19 @@ async function runSmokeChecks(page, pageUrl) {
mobile: false, mobile: false,
}); });
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true"); await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
const desktopAgentHeartbeatLayout = await collectAgentHeartbeatControlLayout();
assertSmokeResult(
"agent heartbeat controls stay visible on desktop and omit ephemeral shells",
desktopAgentHeartbeatLayout.controls.length === 4 && desktopAgentHeartbeatLayout.controls.every((control) => control.width > 0 && control.height > 0 && control.left >= 0 && control.right <= desktopAgentHeartbeatLayout.viewportWidth + 1) && desktopAgentHeartbeatLayout.workerToggleCount === 0 && desktopAgentHeartbeatLayout.fixtureOverflow <= 1 && desktopAgentHeartbeatLayout.documentOverflow <= 1,
JSON.stringify(desktopAgentHeartbeatLayout),
);
if (agentHeartbeatDesktopScreenshotPath) {
await evaluate(page, "document.querySelector('[data-smoke=\"agent-heartbeat-controls\"]').scrollIntoView({ block: 'start' })");
const screenshot = await page.send("Page.captureScreenshot", { format: "png" });
await writeFile(agentHeartbeatDesktopScreenshotPath, Buffer.from(screenshot.data, "base64"));
log(`saved agent heartbeat desktop screenshot to ${agentHeartbeatDesktopScreenshotPath}`);
}
const desktopQuickAddSaveLayout = await collectQuickAddSaveLayout(); const desktopQuickAddSaveLayout = await collectQuickAddSaveLayout();
const frenchDesktopWidth = desktopQuickAddSaveLayout.find((layout) => layout.fixture === "quick-add-save-board-minimum-fr")?.saveWidth; const frenchDesktopWidth = desktopQuickAddSaveLayout.find((layout) => layout.fixture === "quick-add-save-board-minimum-fr")?.saveWidth;
const widestDesktopWidth = Math.max(...desktopQuickAddSaveLayout const widestDesktopWidth = Math.max(...desktopQuickAddSaveLayout

View File

@@ -0,0 +1,69 @@
// @vitest-environment node
import express from "express";
import { describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { createApiRoutes } from "../../routes.js";
import { request } from "../../test-request.js";
const updateAgent = vi.fn();
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
class MockAgentStore {
async init() {}
updateAgent = updateAgent;
}
return { ...actual, AgentStore: MockAgentStore };
});
function createStore(): TaskStore {
return {
getRootDir: vi.fn().mockReturnValue("/fake/project"),
getFusionDir: vi.fn().mockReturnValue("/fake/project/.fusion"),
getAsyncLayer: vi.fn().mockReturnValue(undefined),
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({}),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn(),
getPluginStore: vi.fn().mockReturnValue({ init: vi.fn().mockResolvedValue(undefined), listPlugins: vi.fn().mockResolvedValue([]) }),
listTasks: vi.fn().mockResolvedValue([]),
searchTasks: vi.fn().mockResolvedValue([]),
getAgentLogs: vi.fn().mockResolvedValue([]),
getAgentLogCount: vi.fn().mockResolvedValue(0),
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
getTaskDocuments: vi.fn().mockResolvedValue([]),
getTaskDocument: vi.fn().mockResolvedValue(null),
getTaskDocumentRevisions: vi.fn().mockResolvedValue([]),
getAllDocuments: vi.fn().mockResolvedValue([]),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
getMissionStore: vi.fn(),
} as unknown as TaskStore;
}
describe("agent core PATCH route", () => {
it("passes a complete heartbeat runtime config to the project-scoped agent store without lifecycle transitions", async () => {
updateAgent.mockResolvedValue({ id: "agent-1" });
const store = createStore();
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const runtimeConfig = {
enabled: false,
heartbeatIntervalMs: 30_000,
heartbeatTimeoutMs: 10_000,
maxConcurrentRuns: 2,
assignmentMode: "shared",
skipHeartbeatWhenIdle: true,
model: "provider/model",
unknownFutureKey: "preserved",
};
const response = await request(app, "PATCH", "/api/agents/agent-1", JSON.stringify({ runtimeConfig }), { "Content-Type": "application/json" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(updateAgent).toHaveBeenCalledWith("agent-1", { runtimeConfig });
expect(store.getFusionDir).toHaveBeenCalled();
});
});

View File

@@ -232,6 +232,27 @@ describe("HeartbeatTriggerScheduler", () => {
await vi.advanceTimersByTimeAsync(1); await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledWith(agent.id, "timer", expect.objectContaining({ intervalMs: 2_000 })); expect(callback).toHaveBeenCalledWith(agent.id, "timer", expect.objectContaining({ intervalMs: 2_000 }));
/*
FNXC:AgentHeartbeatControls 2026-07-23-14:15:
Dashboard heartbeat controls update only runtimeConfig.enabled. Scheduler updates must disarm and re-arm timers without treating that configuration change as a lifecycle pause or resume.
*/
callback.mockClear();
const heartbeatDisabled = { ...eventStore.agents.get(agent.id)!, runtimeConfig: { enabled: false, heartbeatIntervalMs: 2_000 } } as Agent;
eventStore.agents.set(agent.id, heartbeatDisabled);
eventStore.emit("agent:updated", heartbeatDisabled);
expect(heartbeatDisabled.state).toBe("active");
expect(scheduler.getRegisteredAgents()).not.toContain(agent.id);
await vi.advanceTimersByTimeAsync(2_000);
expect(callback).not.toHaveBeenCalled();
const heartbeatEnabled = { ...heartbeatDisabled, runtimeConfig: { enabled: true, heartbeatIntervalMs: 2_000 } } as Agent;
eventStore.agents.set(agent.id, heartbeatEnabled);
eventStore.emit("agent:updated", heartbeatEnabled);
expect(heartbeatEnabled.state).toBe("active");
expect(scheduler.getRegisteredAgents()).toContain(agent.id);
await vi.advanceTimersByTimeAsync(2_000);
expect(callback).toHaveBeenCalledWith(agent.id, "timer", expect.objectContaining({ intervalMs: 2_000 }));
callback.mockClear(); callback.mockClear();
const paused = { ...eventStore.agents.get(agent.id)!, state: "paused" as const }; const paused = { ...eventStore.agents.get(agent.id)!, state: "paused" as const };
eventStore.agents.set(agent.id, paused); eventStore.agents.set(agent.id, paused);

View File

@@ -1138,7 +1138,24 @@
"notGranted": "Not granted", "notGranted": "Not granted",
"toggleCapabilityGrant": "Toggle explicit grant for {{permission}}", "toggleCapabilityGrant": "Toggle explicit grant for {{permission}}",
"capabilityPermissionsUpdated": "Capability grants updated", "capabilityPermissionsUpdated": "Capability grants updated",
"capabilityPermissionsFailed": "Failed to update capability grants: {{error}}" "capabilityPermissionsFailed": "Failed to update capability grants: {{error}}",
"disableHeartbeat": "Disable heartbeat",
"enableHeartbeat": "Enable heartbeat",
"heartbeatActionForAgent": "{{action}} for {{name}}",
"heartbeatUpdating": "Updating heartbeat…",
"heartbeatEnabledForAgent": "Heartbeat enabled for {{name}}",
"heartbeatUpdateFailed": "Failed to update heartbeat: {{error}}",
"enableAllHeartbeats": "Enable all heartbeats",
"disableAllHeartbeats": "Disable all heartbeats",
"enableAllHeartbeatsConfirm": "Enable heartbeats for {{count}} project agents?",
"disableAllHeartbeatsConfirm": "Disable heartbeats for {{count}} project agents?",
"noHeartbeatsToEnable": "No agent heartbeats need enabling",
"noHeartbeatsToDisable": "No agent heartbeats need disabling",
"enableHeartbeatsSummary": "Enabled {{count}} heartbeats; skipped {{skipped}}",
"disableHeartbeatsSummary": "Disabled {{count}} heartbeats; skipped {{skipped}}",
"enableHeartbeatsCountHint": "Enable {{count}} disabled project heartbeats",
"disableHeartbeatsCountHint": "Disable {{count}} enabled project heartbeats",
"bulkFailures": "failed {{count}}"
}, },
"app": { "app": {
"backendError": { "backendError": {

View File

@@ -1128,7 +1128,24 @@
"notGranted": "Not granted", "notGranted": "Not granted",
"toggleCapabilityGrant": "Toggle explicit grant for {{permission}}", "toggleCapabilityGrant": "Toggle explicit grant for {{permission}}",
"capabilityPermissionsUpdated": "Capability grants updated", "capabilityPermissionsUpdated": "Capability grants updated",
"capabilityPermissionsFailed": "Failed to update capability grants: {{error}}" "capabilityPermissionsFailed": "Failed to update capability grants: {{error}}",
"disableHeartbeat": "Desactivar latido",
"enableHeartbeat": "Activar latido",
"heartbeatActionForAgent": "{{action}} para {{name}}",
"heartbeatUpdating": "Actualizando latido…",
"heartbeatEnabledForAgent": "Latido activado para {{name}}",
"heartbeatUpdateFailed": "No se pudo actualizar el latido: {{error}}",
"enableAllHeartbeats": "Activar todos los latidos",
"disableAllHeartbeats": "Desactivar todos los latidos",
"enableAllHeartbeatsConfirm": "¿Activar los latidos de {{count}} agentes del proyecto?",
"disableAllHeartbeatsConfirm": "¿Desactivar los latidos de {{count}} agentes del proyecto?",
"noHeartbeatsToEnable": "No hay latidos que activar",
"noHeartbeatsToDisable": "No hay latidos que desactivar",
"enableHeartbeatsSummary": "Se activaron {{count}} latidos; se omitieron {{skipped}}",
"disableHeartbeatsSummary": "Se desactivaron {{count}} latidos; se omitieron {{skipped}}",
"enableHeartbeatsCountHint": "Activar {{count}} latidos desactivados",
"disableHeartbeatsCountHint": "Desactivar {{count}} latidos activados",
"bulkFailures": "fallaron {{count}}"
}, },
"app": { "app": {
"backendError": { "backendError": {

View File

@@ -1128,7 +1128,24 @@
"notGranted": "Not granted", "notGranted": "Not granted",
"toggleCapabilityGrant": "Toggle explicit grant for {{permission}}", "toggleCapabilityGrant": "Toggle explicit grant for {{permission}}",
"capabilityPermissionsUpdated": "Capability grants updated", "capabilityPermissionsUpdated": "Capability grants updated",
"capabilityPermissionsFailed": "Failed to update capability grants: {{error}}" "capabilityPermissionsFailed": "Failed to update capability grants: {{error}}",
"disableHeartbeat": "Désactiver le battement",
"enableHeartbeat": "Activer le battement",
"heartbeatActionForAgent": "{{action}} pour {{name}}",
"heartbeatUpdating": "Mise à jour du battement…",
"heartbeatEnabledForAgent": "Battement activé pour {{name}}",
"heartbeatUpdateFailed": "Échec de la mise à jour du battement : {{error}}",
"enableAllHeartbeats": "Activer tous les battements",
"disableAllHeartbeats": "Désactiver tous les battements",
"enableAllHeartbeatsConfirm": "Activer les battements de {{count}} agents du projet ?",
"disableAllHeartbeatsConfirm": "Désactiver les battements de {{count}} agents du projet ?",
"noHeartbeatsToEnable": "Aucun battement à activer",
"noHeartbeatsToDisable": "Aucun battement à désactiver",
"enableHeartbeatsSummary": "{{count}} battements activés ; {{skipped}} ignorés",
"disableHeartbeatsSummary": "{{count}} battements désactivés ; {{skipped}} ignorés",
"enableHeartbeatsCountHint": "Activer {{count}} battements désactivés",
"disableHeartbeatsCountHint": "Désactiver {{count}} battements activés",
"bulkFailures": "{{count}} échecs"
}, },
"app": { "app": {
"backendError": { "backendError": {