feat(FN-1661): merge fusion/fn-1661 (auto-resolved)
- feat(FN-1661): complete Step 4 — document task-worker health fix - feat(FN-1661): complete Step 3 — verify task-worker agent health fix
This commit is contained in:
@@ -1,13 +1,22 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { getAgentHealthStatus, getAgentHealthColorVar } from "./agentHealth";
|
||||
import type { Agent } from "../api";
|
||||
|
||||
// Mock Date.now to get deterministic elapsed time calculations
|
||||
const FIXED_NOW = new Date("2026-04-10T12:00:00.000Z").getTime();
|
||||
|
||||
function makeAgent(overrides: Partial<Pick<Agent, "state" | "lastHeartbeatAt" | "lastError" | "pauseReason" | "runtimeConfig">> = {}): Pick<Agent, "state" | "lastHeartbeatAt" | "lastError" | "pauseReason" | "runtimeConfig"> {
|
||||
type AgentHealthInput = Pick<
|
||||
Agent,
|
||||
"state" | "lastHeartbeatAt" | "lastError" | "pauseReason" | "runtimeConfig" | "metadata" | "name" | "role" | "taskId"
|
||||
>;
|
||||
|
||||
function makeAgent(overrides: Partial<AgentHealthInput> = {}): AgentHealthInput {
|
||||
return {
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
taskId: undefined,
|
||||
metadata: {},
|
||||
lastHeartbeatAt: undefined,
|
||||
lastError: undefined,
|
||||
pauseReason: undefined,
|
||||
@@ -145,6 +154,52 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("task worker health classification", () => {
|
||||
it('returns "Running" for metadata-marked task workers with disabled heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
name: "executor-FN-1661",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
taskId: "FN-1661",
|
||||
metadata: {
|
||||
agentKind: "task-worker",
|
||||
taskWorker: true,
|
||||
managedBy: "task-executor",
|
||||
},
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(),
|
||||
runtimeConfig: { enabled: false, heartbeatTimeoutMs: 60_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Running");
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
it('returns "Running" for legacy executor-* task workers with stale heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
name: "executor-FN-1661",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
taskId: "FN-1661",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(),
|
||||
runtimeConfig: { heartbeatTimeoutMs: 30_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Running");
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
it('keeps non-task-worker disabled agents as "Disabled"', () => {
|
||||
const agent = makeAgent({
|
||||
name: "Reviewer",
|
||||
role: "reviewer",
|
||||
state: "active",
|
||||
runtimeConfig: { enabled: false },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Disabled");
|
||||
});
|
||||
});
|
||||
|
||||
// ── No heartbeat data ──────────────────────────────────────────────────────
|
||||
|
||||
describe("no heartbeat data", () => {
|
||||
@@ -329,14 +384,31 @@ describe("getAgentHealthStatus", () => {
|
||||
{ agent: makeAgent({ state: "running" }), expectedIconType: "Activity" },
|
||||
{ agent: makeAgent({ state: "idle" }), expectedIconType: "Bot" },
|
||||
{ agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }), expectedIconType: "Bot" },
|
||||
{
|
||||
agent: makeAgent({
|
||||
name: "executor-FN-1661",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
taskId: "FN-1661",
|
||||
metadata: { agentKind: "task-worker" },
|
||||
runtimeConfig: { enabled: false },
|
||||
}),
|
||||
expectedIconType: "Activity",
|
||||
},
|
||||
// Active with recent heartbeat should show "Healthy" (Heart icon)
|
||||
{ agent: makeAgent({ state: "active", lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString() }), expectedIconType: "Heart" },
|
||||
];
|
||||
|
||||
testCases.forEach(({ agent, expectedIconType }) => {
|
||||
const status = getAgentHealthStatus(agent);
|
||||
// lucide icons have displayName property
|
||||
const iconType = (status.icon as any).type?.displayName ?? (status.icon as any).type?.name;
|
||||
// lucide icons expose their component on the JSX element's `type`
|
||||
const iconElement = status.icon as JSX.Element & {
|
||||
type?: {
|
||||
displayName?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
const iconType = iconElement.type?.displayName ?? iconElement.type?.name;
|
||||
expect(iconType).toBe(expectedIconType);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { JSX } from "react";
|
||||
import { Bot, Heart, Activity, Pause, Square } from "lucide-react";
|
||||
import type { Agent, AgentState } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
|
||||
/** Default heartbeat timeout when not configured per-agent */
|
||||
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 60_000;
|
||||
@@ -12,6 +12,19 @@ export interface AgentHealthStatus {
|
||||
color: string;
|
||||
}
|
||||
|
||||
type AgentHealthInput = Pick<
|
||||
Agent,
|
||||
| "state"
|
||||
| "lastHeartbeatAt"
|
||||
| "lastError"
|
||||
| "pauseReason"
|
||||
| "runtimeConfig"
|
||||
| "metadata"
|
||||
| "name"
|
||||
| "role"
|
||||
| "taskId"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Extract the heartbeat timeout from agent runtimeConfig.
|
||||
* Returns undefined if not set or if monitoring is disabled.
|
||||
@@ -33,6 +46,21 @@ function isHeartbeatEnabled(runtimeConfig?: Record<string, unknown>): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isTaskWorkerAgent(agent: AgentHealthInput): boolean {
|
||||
const metadata = agent.metadata as Record<string, unknown> | null | undefined;
|
||||
if (metadata) {
|
||||
if (metadata.agentKind === "task-worker") return true;
|
||||
if (metadata.taskWorker === true) return true;
|
||||
if (metadata.managedBy === "task-executor") return true;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
agent.role === "executor" &&
|
||||
agent.name?.startsWith("executor-") &&
|
||||
agent.taskId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a single canonical health status for an agent based on its
|
||||
* state, runtimeConfig, and last heartbeat timestamp.
|
||||
@@ -41,8 +69,8 @@ function isHeartbeatEnabled(runtimeConfig?: Record<string, unknown>): boolean {
|
||||
* - "Terminated" — agent.state === "terminated"
|
||||
* - "Error" — agent.state === "error" (uses lastError if available)
|
||||
* - "Paused" — agent.state === "paused" (uses pauseReason if available)
|
||||
* - "Running" — agent.state === "running"
|
||||
* - "Disabled" — runtimeConfig.enabled === false
|
||||
* - "Running" — agent.state === "running", or a detected task worker in "active"
|
||||
* - "Disabled" — runtimeConfig.enabled === false for non-task-worker agents
|
||||
* - "Starting..." — state === "active" && no lastHeartbeatAt
|
||||
* - "Idle" — state !== "active" && no lastHeartbeatAt
|
||||
* - "Healthy" — heartbeat is fresh within the configured timeout
|
||||
@@ -51,8 +79,9 @@ function isHeartbeatEnabled(runtimeConfig?: Record<string, unknown>): boolean {
|
||||
* @param agent - The agent object (partial Agent shape is accepted)
|
||||
* @returns A health status object with label, icon, and color
|
||||
*/
|
||||
export function getAgentHealthStatus(agent: Pick<Agent, "state" | "lastHeartbeatAt" | "lastError" | "pauseReason" | "runtimeConfig">): AgentHealthStatus {
|
||||
export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus {
|
||||
const { state, lastHeartbeatAt, lastError, pauseReason, runtimeConfig } = agent;
|
||||
const isTaskWorker = isTaskWorkerAgent(agent);
|
||||
|
||||
// Terminal states - these always take precedence
|
||||
if (state === "terminated") {
|
||||
@@ -80,7 +109,7 @@ export function getAgentHealthStatus(agent: Pick<Agent, "state" | "lastHeartbeat
|
||||
};
|
||||
}
|
||||
|
||||
if (state === "running") {
|
||||
if (state === "running" || (isTaskWorker && state === "active")) {
|
||||
return {
|
||||
label: "Running",
|
||||
icon: <Activity size={14} />,
|
||||
@@ -130,7 +159,7 @@ export function getAgentHealthStatus(agent: Pick<Agent, "state" | "lastHeartbeat
|
||||
* Returns a CSS variable name for the health color.
|
||||
* Useful when you need the raw CSS variable name for custom styling.
|
||||
*/
|
||||
export function getAgentHealthColorVar(agent: Pick<Agent, "state" | "lastHeartbeatAt" | "lastError" | "pauseReason" | "runtimeConfig">): string {
|
||||
export function getAgentHealthColorVar(agent: AgentHealthInput): string {
|
||||
const status = getAgentHealthStatus(agent);
|
||||
// Extract the CSS variable name from the color string
|
||||
// e.g., "var(--state-error-text)" -> "--state-error-text"
|
||||
|
||||
Reference in New Issue
Block a user