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:
5
.changeset/fix-task-worker-agent-health.md
Normal file
5
.changeset/fix-task-worker-agent-health.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix false "Unresponsive" health for runtime-created task worker agents by marking them as non-heartbeat workers and keeping the dashboard health label stable while task execution is active.
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
- `HeartbeatMonitor.executeHeartbeat()` uses the Paperclip wake→check→work→exit model. The lazy `import("./pi.js")` pattern keeps pi SDK out of the module graph when only monitoring (not execution) is needed.
|
- `HeartbeatMonitor.executeHeartbeat()` uses the Paperclip wake→check→work→exit model. The lazy `import("./pi.js")` pattern keeps pi SDK out of the module graph when only monitoring (not execution) is needed.
|
||||||
- Agent tool factories (`createTaskCreateTool`, `createTaskLogTool`) live in `agent-tools.ts` and are shared between `TaskExecutor` and `HeartbeatMonitor` to avoid duplication.
|
- Agent tool factories (`createTaskCreateTool`, `createTaskLogTool`) live in `agent-tools.ts` and are shared between `TaskExecutor` and `HeartbeatMonitor` to avoid duplication.
|
||||||
- **Heartbeat Control-Plane Lane (FN-1487)**: Heartbeat runs from the Agents panel run on a separate control-plane lane that is independent of task execution concurrency limits. `HeartbeatMonitor` and `HeartbeatTriggerScheduler` are created WITHOUT the task-lane semaphore in both `runDashboard()` and `runServe()`. The semaphore boundary is documented in comments with "UTILITY PATH: This component does NOT receive the task-lane semaphore." This ensures agent responsiveness is preserved even when task pipelines are saturated.
|
- **Heartbeat Control-Plane Lane (FN-1487)**: Heartbeat runs from the Agents panel run on a separate control-plane lane that is independent of task execution concurrency limits. `HeartbeatMonitor` and `HeartbeatTriggerScheduler` are created WITHOUT the task-lane semaphore in both `runDashboard()` and `runServe()`. The semaphore boundary is documented in comments with "UTILITY PATH: This component does NOT receive the task-lane semaphore." This ensures agent responsiveness is preserved even when task pipelines are saturated.
|
||||||
|
- **Task-worker agent contract (FN-1661)**: Runtime-created executor task workers (for example `executor-FN-1234`) must be explicitly marked with `metadata.agentKind = "task-worker"` and `runtimeConfig.enabled = false`, then transition `idle -> active -> running` after assignment wiring completes. `HeartbeatTriggerScheduler.watchAssignments()` must skip assignment wakeups when `runtimeConfig.enabled === false`; otherwise task workers inherit user-agent heartbeat semantics and show false "Unresponsive" health in the dashboard.
|
||||||
- Dashboard SSE clients (planning/subtask/mission interview) now use a shared keep-alive pattern: start a 25s `setInterval` in stream `onOpen` that `POST`s `/api/ai-sessions/:id/ping`, and always stop it on stream `close`, `complete`, and fatal errors.
|
- Dashboard SSE clients (planning/subtask/mission interview) now use a shared keep-alive pattern: start a 25s `setInterval` in stream `onOpen` that `POST`s `/api/ai-sessions/:id/ping`, and always stop it on stream `close`, `complete`, and fatal errors.
|
||||||
- **Subtask Session ProjectId Propagation (FN-1479)**: Subtask breakdown sessions must persist `projectId` throughout their lifecycle to enable project-scoped resume. Key patterns:
|
- **Subtask Session ProjectId Propagation (FN-1479)**: Subtask breakdown sessions must persist `projectId` throughout their lifecycle to enable project-scoped resume. Key patterns:
|
||||||
- `POST /api/subtasks/start-streaming` forwards `projectId` from the route handler to `createSubtaskSession()`
|
- `POST /api/subtasks/start-streaming` forwards `projectId` from the route handler to `createSubtaskSession()`
|
||||||
|
|||||||
@@ -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 { getAgentHealthStatus, getAgentHealthColorVar } from "./agentHealth";
|
||||||
import type { Agent } from "../api";
|
import type { Agent } from "../api";
|
||||||
|
|
||||||
// Mock Date.now to get deterministic elapsed time calculations
|
// Mock Date.now to get deterministic elapsed time calculations
|
||||||
const FIXED_NOW = new Date("2026-04-10T12:00:00.000Z").getTime();
|
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 {
|
return {
|
||||||
|
name: "Test Agent",
|
||||||
|
role: "executor",
|
||||||
state: "idle",
|
state: "idle",
|
||||||
|
taskId: undefined,
|
||||||
|
metadata: {},
|
||||||
lastHeartbeatAt: undefined,
|
lastHeartbeatAt: undefined,
|
||||||
lastError: undefined,
|
lastError: undefined,
|
||||||
pauseReason: 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 ──────────────────────────────────────────────────────
|
// ── No heartbeat data ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("no heartbeat data", () => {
|
describe("no heartbeat data", () => {
|
||||||
@@ -329,14 +384,31 @@ describe("getAgentHealthStatus", () => {
|
|||||||
{ agent: makeAgent({ state: "running" }), expectedIconType: "Activity" },
|
{ agent: makeAgent({ state: "running" }), expectedIconType: "Activity" },
|
||||||
{ agent: makeAgent({ state: "idle" }), expectedIconType: "Bot" },
|
{ agent: makeAgent({ state: "idle" }), expectedIconType: "Bot" },
|
||||||
{ agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }), 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)
|
// Active with recent heartbeat should show "Healthy" (Heart icon)
|
||||||
{ agent: makeAgent({ state: "active", lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString() }), expectedIconType: "Heart" },
|
{ agent: makeAgent({ state: "active", lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString() }), expectedIconType: "Heart" },
|
||||||
];
|
];
|
||||||
|
|
||||||
testCases.forEach(({ agent, expectedIconType }) => {
|
testCases.forEach(({ agent, expectedIconType }) => {
|
||||||
const status = getAgentHealthStatus(agent);
|
const status = getAgentHealthStatus(agent);
|
||||||
// lucide icons have displayName property
|
// lucide icons expose their component on the JSX element's `type`
|
||||||
const iconType = (status.icon as any).type?.displayName ?? (status.icon as any).type?.name;
|
const iconElement = status.icon as JSX.Element & {
|
||||||
|
type?: {
|
||||||
|
displayName?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const iconType = iconElement.type?.displayName ?? iconElement.type?.name;
|
||||||
expect(iconType).toBe(expectedIconType);
|
expect(iconType).toBe(expectedIconType);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import { Bot, Heart, Activity, Pause, Square } from "lucide-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 */
|
/** Default heartbeat timeout when not configured per-agent */
|
||||||
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 60_000;
|
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 60_000;
|
||||||
@@ -12,6 +12,19 @@ export interface AgentHealthStatus {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AgentHealthInput = Pick<
|
||||||
|
Agent,
|
||||||
|
| "state"
|
||||||
|
| "lastHeartbeatAt"
|
||||||
|
| "lastError"
|
||||||
|
| "pauseReason"
|
||||||
|
| "runtimeConfig"
|
||||||
|
| "metadata"
|
||||||
|
| "name"
|
||||||
|
| "role"
|
||||||
|
| "taskId"
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the heartbeat timeout from agent runtimeConfig.
|
* Extract the heartbeat timeout from agent runtimeConfig.
|
||||||
* Returns undefined if not set or if monitoring is disabled.
|
* Returns undefined if not set or if monitoring is disabled.
|
||||||
@@ -33,6 +46,21 @@ function isHeartbeatEnabled(runtimeConfig?: Record<string, unknown>): boolean {
|
|||||||
return true;
|
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
|
* Computes a single canonical health status for an agent based on its
|
||||||
* state, runtimeConfig, and last heartbeat timestamp.
|
* state, runtimeConfig, and last heartbeat timestamp.
|
||||||
@@ -41,8 +69,8 @@ function isHeartbeatEnabled(runtimeConfig?: Record<string, unknown>): boolean {
|
|||||||
* - "Terminated" — agent.state === "terminated"
|
* - "Terminated" — agent.state === "terminated"
|
||||||
* - "Error" — agent.state === "error" (uses lastError if available)
|
* - "Error" — agent.state === "error" (uses lastError if available)
|
||||||
* - "Paused" — agent.state === "paused" (uses pauseReason if available)
|
* - "Paused" — agent.state === "paused" (uses pauseReason if available)
|
||||||
* - "Running" — agent.state === "running"
|
* - "Running" — agent.state === "running", or a detected task worker in "active"
|
||||||
* - "Disabled" — runtimeConfig.enabled === false
|
* - "Disabled" — runtimeConfig.enabled === false for non-task-worker agents
|
||||||
* - "Starting..." — state === "active" && no lastHeartbeatAt
|
* - "Starting..." — state === "active" && no lastHeartbeatAt
|
||||||
* - "Idle" — state !== "active" && no lastHeartbeatAt
|
* - "Idle" — state !== "active" && no lastHeartbeatAt
|
||||||
* - "Healthy" — heartbeat is fresh within the configured timeout
|
* - "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)
|
* @param agent - The agent object (partial Agent shape is accepted)
|
||||||
* @returns A health status object with label, icon, and color
|
* @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 { state, lastHeartbeatAt, lastError, pauseReason, runtimeConfig } = agent;
|
||||||
|
const isTaskWorker = isTaskWorkerAgent(agent);
|
||||||
|
|
||||||
// Terminal states - these always take precedence
|
// Terminal states - these always take precedence
|
||||||
if (state === "terminated") {
|
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 {
|
return {
|
||||||
label: "Running",
|
label: "Running",
|
||||||
icon: <Activity size={14} />,
|
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.
|
* Returns a CSS variable name for the health color.
|
||||||
* Useful when you need the raw CSS variable name for custom styling.
|
* 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);
|
const status = getAgentHealthStatus(agent);
|
||||||
// Extract the CSS variable name from the color string
|
// Extract the CSS variable name from the color string
|
||||||
// e.g., "var(--state-error-text)" -> "--state-error-text"
|
// e.g., "var(--state-error-text)" -> "--state-error-text"
|
||||||
|
|||||||
@@ -2905,6 +2905,26 @@ describe("HeartbeatTriggerScheduler", () => {
|
|||||||
expect(callback).not.toHaveBeenCalled();
|
expect(callback).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips trigger when agent heartbeat is disabled", async () => {
|
||||||
|
const agent: import("@fusion/core").Agent = {
|
||||||
|
id: "agent-test",
|
||||||
|
name: "executor-FN-1661",
|
||||||
|
role: "executor",
|
||||||
|
state: "active",
|
||||||
|
taskId: "FN-1661",
|
||||||
|
metadata: {},
|
||||||
|
runtimeConfig: { enabled: false },
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
eventStore.emit("agent:assigned", agent, "FN-1661");
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
expect(eventStore.getActiveHeartbeatRun).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("skips trigger when agent has active run", async () => {
|
it("skips trigger when agent has active run", async () => {
|
||||||
(eventStore.getActiveHeartbeatRun as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(eventStore.getActiveHeartbeatRun as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
id: "run-active",
|
id: "run-active",
|
||||||
|
|||||||
@@ -20,15 +20,13 @@
|
|||||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot, RunMutationContext } from "@fusion/core";
|
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot, RunMutationContext } from "@fusion/core";
|
||||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||||
import { createTaskCreateTool, createTaskLogTool, createTaskLogToolWithContext, taskCreateParams } from "./agent-tools.js";
|
import { createTaskCreateTool, createTaskLogToolWithContext, taskCreateParams } from "./agent-tools.js";
|
||||||
import { AgentLogger } from "./agent-logger.js";
|
import { AgentLogger } from "./agent-logger.js";
|
||||||
import { heartbeatLog } from "./logger.js";
|
import { heartbeatLog } from "./logger.js";
|
||||||
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
|
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
|
||||||
|
|
||||||
// Lazy import for pi — avoids pulling the pi SDK into the module graph
|
// Lazy import for pi — avoids pulling the pi SDK into the module graph
|
||||||
// when heartbeat execution isn't needed.
|
// when heartbeat execution isn't needed.
|
||||||
type CreateKbAgentFn = (options: import("./pi.js").AgentOptions) => Promise<import("./pi.js").AgentResult>;
|
|
||||||
type PromptWithFallbackFn = (session: import("@mariozechner/pi-coding-agent").AgentSession, prompt: string) => Promise<void>;
|
|
||||||
|
|
||||||
/** Resolved per-agent heartbeat config after validation and fallback */
|
/** Resolved per-agent heartbeat config after validation and fallback */
|
||||||
interface ResolvedHeartbeatConfig {
|
interface ResolvedHeartbeatConfig {
|
||||||
@@ -1081,8 +1079,8 @@ export class HeartbeatMonitor {
|
|||||||
const baseCreateTool = createTaskCreateTool(taskStore);
|
const baseCreateTool = createTaskCreateTool(taskStore);
|
||||||
const trackedCreateTool: ToolDefinition = {
|
const trackedCreateTool: ToolDefinition = {
|
||||||
...baseCreateTool,
|
...baseCreateTool,
|
||||||
execute: async (id: string, params: Static<typeof taskCreateParams>, _signal?: unknown, _onUpdate?: unknown, _ctx?: unknown) => {
|
execute: async (id: string, params: Static<typeof taskCreateParams>, signal, onUpdate, ctx) => {
|
||||||
const result = await baseCreateTool.execute(id, params, undefined as any, undefined as any, undefined as any);
|
const result = await baseCreateTool.execute(id, params, signal, onUpdate, ctx);
|
||||||
|
|
||||||
// Extract created task ID from the response text ("Created FN-XXX: ...")
|
// Extract created task ID from the response text ("Created FN-XXX: ...")
|
||||||
const firstContent = result.content[0];
|
const firstContent = result.content[0];
|
||||||
@@ -1404,6 +1402,11 @@ export class HeartbeatTriggerScheduler {
|
|||||||
if (!this.running) return;
|
if (!this.running) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (agent.runtimeConfig?.enabled === false) {
|
||||||
|
heartbeatLog.log(`Assignment trigger skipped for ${agent.id} (heartbeat disabled)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Guard: skip if agent already has an active run
|
// Guard: skip if agent already has an active run
|
||||||
const activeRun = await this.store.getActiveHeartbeatRun(agent.id);
|
const activeRun = await this.store.getActiveHeartbeatRun(agent.id);
|
||||||
if (activeRun) {
|
if (activeRun) {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { EventEmitter } from "node:events";
|
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { Task, TaskStore, CentralCore } from "@fusion/core";
|
import type { Task, TaskStore, CentralCore, AgentStore, Agent } from "@fusion/core";
|
||||||
import { InProcessRuntime } from "./in-process-runtime.js";
|
import { InProcessRuntime } from "./in-process-runtime.js";
|
||||||
import type { ProjectRuntimeConfig } from "../project-runtime.js";
|
import type { ProjectRuntimeConfig } from "../project-runtime.js";
|
||||||
|
|
||||||
@@ -135,6 +134,21 @@ vi.mock("../executor.js", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type RuntimeInternals = {
|
||||||
|
agentStore?: AgentStore;
|
||||||
|
stuckTaskDetector?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRuntimeInternals(runtime: InProcessRuntime): RuntimeInternals {
|
||||||
|
return runtime as unknown as RuntimeInternals;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentStore(runtime: InProcessRuntime): AgentStore {
|
||||||
|
const store = getRuntimeInternals(runtime).agentStore;
|
||||||
|
expect(store).toBeDefined();
|
||||||
|
return store!;
|
||||||
|
}
|
||||||
|
|
||||||
describe("InProcessRuntime", () => {
|
describe("InProcessRuntime", () => {
|
||||||
let runtime: InProcessRuntime;
|
let runtime: InProcessRuntime;
|
||||||
let mockCentralCore: CentralCore;
|
let mockCentralCore: CentralCore;
|
||||||
@@ -222,7 +236,7 @@ describe("InProcessRuntime", () => {
|
|||||||
stuckTaskDetector: expect.any(Object),
|
stuckTaskDetector: expect.any(Object),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect((runtime as any).stuckTaskDetector).toBeDefined();
|
expect(getRuntimeInternals(runtime).stuckTaskDetector).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should transition to 'stopped' after stop", async () => {
|
it("should transition to 'stopped' after stop", async () => {
|
||||||
@@ -407,8 +421,7 @@ describe("InProcessRuntime", () => {
|
|||||||
await runtime.start();
|
await runtime.start();
|
||||||
|
|
||||||
// Create an agent with heartbeat config
|
// Create an agent with heartbeat config
|
||||||
const store = (runtime as any).agentStore;
|
const store = getAgentStore(runtime);
|
||||||
expect(store).toBeDefined();
|
|
||||||
|
|
||||||
const createdAgent = await store.createAgent({
|
const createdAgent = await store.createAgent({
|
||||||
name: "Configured Agent",
|
name: "Configured Agent",
|
||||||
@@ -434,12 +447,13 @@ describe("InProcessRuntime", () => {
|
|||||||
|
|
||||||
const monitor = runtime.getHeartbeatMonitor();
|
const monitor = runtime.getHeartbeatMonitor();
|
||||||
expect(monitor).toBeDefined();
|
expect(monitor).toBeDefined();
|
||||||
|
const heartbeatMonitor = monitor!;
|
||||||
|
const executeResult = { id: "run-test" } as Awaited<ReturnType<typeof heartbeatMonitor.executeHeartbeat>>;
|
||||||
const executeSpy = vi
|
const executeSpy = vi
|
||||||
.spyOn(monitor!, "executeHeartbeat")
|
.spyOn(heartbeatMonitor, "executeHeartbeat")
|
||||||
.mockResolvedValue({ id: "run-test" } as any);
|
.mockResolvedValue(executeResult);
|
||||||
|
|
||||||
const store = (runtime as any).agentStore;
|
const store = getAgentStore(runtime);
|
||||||
expect(store).toBeDefined();
|
|
||||||
|
|
||||||
const agent = await store.createAgent({
|
const agent = await store.createAgent({
|
||||||
name: "Assignable",
|
name: "Assignable",
|
||||||
@@ -462,6 +476,72 @@ describe("InProcessRuntime", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
it("creates runtime task-worker agents with disabled heartbeat metadata and running state", async () => {
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
const store = getAgentStore(runtime);
|
||||||
|
|
||||||
|
const assignTaskSpy = vi.spyOn(store, "assignTask");
|
||||||
|
const updateStateSpy = vi.spyOn(store, "updateAgentState");
|
||||||
|
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
|
||||||
|
onStart?: (task: Task, worktreePath: string) => void;
|
||||||
|
};
|
||||||
|
expect(executorOptions.onStart).toBeTypeOf("function");
|
||||||
|
|
||||||
|
executorOptions.onStart?.({ id: "FN-1661" } as Task, join(testDir, "worktree-FN-1661"));
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const agents = await store.listAgents();
|
||||||
|
expect(agents).toHaveLength(1);
|
||||||
|
expect(agents[0]).toMatchObject({
|
||||||
|
name: "executor-FN-1661",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-1661",
|
||||||
|
metadata: {
|
||||||
|
agentKind: "task-worker",
|
||||||
|
taskWorker: true,
|
||||||
|
managedBy: "task-executor",
|
||||||
|
},
|
||||||
|
runtimeConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assignTaskSpy).toHaveBeenCalledWith(expect.any(String), "FN-1661");
|
||||||
|
expect(updateStateSpy).toHaveBeenNthCalledWith(1, expect.any(String), "active");
|
||||||
|
expect(updateStateSpy).toHaveBeenNthCalledWith(2, expect.any(String), "running");
|
||||||
|
expect(assignTaskSpy.mock.invocationCallOrder[0]).toBeLessThan(updateStateSpy.mock.invocationCallOrder[0]);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("does not wake executeHeartbeat for runtime task-worker assignment events", async () => {
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
const monitor = runtime.getHeartbeatMonitor();
|
||||||
|
expect(monitor).toBeDefined();
|
||||||
|
const heartbeatMonitor = monitor!;
|
||||||
|
const executeResult = { id: "run-task-worker" } as Awaited<ReturnType<typeof heartbeatMonitor.executeHeartbeat>>;
|
||||||
|
const executeSpy = vi
|
||||||
|
.spyOn(heartbeatMonitor, "executeHeartbeat")
|
||||||
|
.mockResolvedValue(executeResult);
|
||||||
|
|
||||||
|
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
|
||||||
|
onStart?: (task: Task, worktreePath: string) => void;
|
||||||
|
};
|
||||||
|
executorOptions.onStart?.({ id: "FN-2001" } as Task, join(testDir, "worktree-FN-2001"));
|
||||||
|
|
||||||
|
const store = getAgentStore(runtime);
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const agents = await store.listAgents();
|
||||||
|
expect(agents.some((agent: Agent) => agent.name === "executor-FN-2001")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
expect(executeSpy).not.toHaveBeenCalled();
|
||||||
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("configuration", () => {
|
describe("configuration", () => {
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export class InProcessRuntime
|
|||||||
missionStore,
|
missionStore,
|
||||||
missionAutopilot: missionAutopilot
|
missionAutopilot: missionAutopilot
|
||||||
? {
|
? {
|
||||||
notifyValidationComplete: async (featureId: string, _status: "passed" | "failed" | "blocked" | "error") => {
|
notifyValidationComplete: async (featureId: string) => {
|
||||||
// Pass the feature's linked taskId to handleTaskCompletion, not the featureId
|
// Pass the feature's linked taskId to handleTaskCompletion, not the featureId
|
||||||
const feature = missionStore.getFeature(featureId);
|
const feature = missionStore.getFeature(featureId);
|
||||||
if (feature?.taskId) {
|
if (feature?.taskId) {
|
||||||
@@ -254,15 +254,26 @@ export class InProcessRuntime
|
|||||||
onStart: (task, worktreePath) => {
|
onStart: (task, worktreePath) => {
|
||||||
this.recordActivity();
|
this.recordActivity();
|
||||||
runtimeLog.log(`Started executing task ${task.id} in ${worktreePath}`);
|
runtimeLog.log(`Started executing task ${task.id} in ${worktreePath}`);
|
||||||
// Create agent in AgentStore for lifecycle tracking
|
// Create a runtime-managed task worker agent for lifecycle tracking.
|
||||||
|
// These workers are not heartbeat-managed dashboard agents, so mark them
|
||||||
|
// explicitly and disable heartbeat triggers/timers.
|
||||||
if (this.agentStore) {
|
if (this.agentStore) {
|
||||||
this.agentStore.createAgent({
|
this.agentStore.createAgent({
|
||||||
name: `executor-${task.id}`,
|
name: `executor-${task.id}`,
|
||||||
role: "executor",
|
role: "executor",
|
||||||
|
metadata: {
|
||||||
|
agentKind: "task-worker",
|
||||||
|
taskWorker: true,
|
||||||
|
managedBy: "task-executor",
|
||||||
|
},
|
||||||
|
runtimeConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
}).then(async (agent: { id: string }) => {
|
}).then(async (agent: { id: string }) => {
|
||||||
this.taskAgentMap.set(task.id, agent.id);
|
this.taskAgentMap.set(task.id, agent.id);
|
||||||
await this.agentStore!.assignTask(agent.id, task.id);
|
await this.agentStore!.assignTask(agent.id, task.id);
|
||||||
await this.agentStore!.updateAgentState(agent.id, "active");
|
await this.agentStore!.updateAgentState(agent.id, "active");
|
||||||
|
await this.agentStore!.updateAgentState(agent.id, "running");
|
||||||
}).catch((err: unknown) => {
|
}).catch((err: unknown) => {
|
||||||
runtimeLog.warn(`Failed to create agent for task ${task.id}:`, err);
|
runtimeLog.warn(`Failed to create agent for task ${task.id}:`, err);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user