fix(FN-834): fix branch prefix drift, add merger branch guard, and fix test OOM
- Fix resolveBaseBranch to use stored branch name and consistent fusion/ prefix for both explicit deps and blockedBy paths (was using kb/ for blockedBy) - Add main branch checkout verification in merger before squash merge to prevent feature code from landing on wrong branch lineage - Align all branch prefix references from stale kb/ to fusion/ across executor, merger, store, and routes - Fix executor test OOM by mocking merger fully, adding fake timers to retry tests, and switching vitest pool to vmThreads - Update all test assertions to use fusion/ branch prefix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import type {
|
||||
AgentHeartbeatEvent,
|
||||
AgentHeartbeatRun,
|
||||
AgentDetail,
|
||||
AgentTaskSession,
|
||||
} from "./types.js";
|
||||
import { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
|
||||
@@ -66,6 +67,15 @@ interface AgentData {
|
||||
updatedAt: string;
|
||||
lastHeartbeatAt?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
reportsTo?: string;
|
||||
runtimeConfig?: Record<string, unknown>;
|
||||
pauseReason?: string;
|
||||
permissions?: Record<string, boolean>;
|
||||
totalInputTokens?: number;
|
||||
totalOutputTokens?: number;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
/** Per-agent write lock for serialization */
|
||||
@@ -121,6 +131,11 @@ export class AgentStore extends EventEmitter {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: input.metadata ?? {},
|
||||
...(input.title && { title: input.title }),
|
||||
...(input.icon && { icon: input.icon }),
|
||||
...(input.reportsTo && { reportsTo: input.reportsTo }),
|
||||
...(input.runtimeConfig && { runtimeConfig: input.runtimeConfig }),
|
||||
...(input.permissions && { permissions: input.permissions }),
|
||||
};
|
||||
|
||||
await this.writeAgent(agent);
|
||||
@@ -190,6 +205,15 @@ export class AgentStore extends EventEmitter {
|
||||
role: updates.role ?? agent.role,
|
||||
metadata: updates.metadata !== undefined ? updates.metadata : agent.metadata,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...(updates.title !== undefined && { title: updates.title }),
|
||||
...(updates.icon !== undefined && { icon: updates.icon }),
|
||||
...(updates.reportsTo !== undefined && { reportsTo: updates.reportsTo }),
|
||||
...(updates.runtimeConfig !== undefined && { runtimeConfig: updates.runtimeConfig }),
|
||||
...(updates.pauseReason !== undefined && { pauseReason: updates.pauseReason }),
|
||||
...(updates.permissions !== undefined && { permissions: updates.permissions }),
|
||||
...(updates.lastError !== undefined && { lastError: updates.lastError }),
|
||||
...(updates.totalInputTokens !== undefined && { totalInputTokens: updates.totalInputTokens }),
|
||||
...(updates.totalOutputTokens !== undefined && { totalOutputTokens: updates.totalOutputTokens }),
|
||||
};
|
||||
|
||||
await this.writeAgent(updated);
|
||||
@@ -290,7 +314,7 @@ export class AgentStore extends EventEmitter {
|
||||
*/
|
||||
async listAgents(filter?: { state?: AgentState; role?: AgentCapability }): Promise<Agent[]> {
|
||||
const files = await readdir(this.agentsDir).catch(() => [] as string[]);
|
||||
const agentFiles = files.filter((f) => f.endsWith(".json") && !f.includes("-heartbeats"));
|
||||
const agentFiles = files.filter((f) => f.endsWith(".json") && !f.includes("-heartbeats") && !f.includes("-sessions") && !f.includes("-runs"));
|
||||
|
||||
const agents: Agent[] = [];
|
||||
for (const file of agentFiles) {
|
||||
@@ -332,6 +356,13 @@ export class AgentStore extends EventEmitter {
|
||||
await unlink(agentPath).catch(() => {});
|
||||
await unlink(heartbeatPath).catch(() => {});
|
||||
|
||||
// Clean up sessions and runs directories
|
||||
const { rm } = await import("node:fs/promises");
|
||||
const sessionsDir = join(this.agentsDir, `${agentId}-sessions`);
|
||||
const runsDir = join(this.agentsDir, `${agentId}-runs`);
|
||||
await rm(sessionsDir, { recursive: true, force: true }).catch(() => {});
|
||||
await rm(runsDir, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
this.emit("agent:deleted", agentId);
|
||||
});
|
||||
}
|
||||
@@ -534,6 +565,146 @@ export class AgentStore extends EventEmitter {
|
||||
return Array.from(runs.values()).filter((r) => r.status !== "active");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Task Session Management
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a task session for an agent.
|
||||
* @param agentId - The agent ID
|
||||
* @param taskId - The task ID
|
||||
* @returns The session, or null if not found
|
||||
*/
|
||||
async getTaskSession(agentId: string, taskId: string): Promise<AgentTaskSession | null> {
|
||||
const sessionsDir = join(this.agentsDir, `${agentId}-sessions`);
|
||||
const sessionPath = join(sessionsDir, `${taskId}.json`);
|
||||
|
||||
try {
|
||||
const content = await readFile(sessionPath, "utf-8");
|
||||
return JSON.parse(content) as AgentTaskSession;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a task session for an agent.
|
||||
* @param session - The session data
|
||||
* @returns The saved session
|
||||
*/
|
||||
async upsertTaskSession(session: AgentTaskSession): Promise<AgentTaskSession> {
|
||||
const sessionsDir = join(this.agentsDir, `${session.agentId}-sessions`);
|
||||
await mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const existing = await this.getTaskSession(session.agentId, session.taskId);
|
||||
|
||||
const saved: AgentTaskSession = {
|
||||
...session,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const sessionPath = join(sessionsDir, `${session.taskId}.json`);
|
||||
await writeFile(sessionPath, JSON.stringify(saved, null, 2));
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a task session.
|
||||
* @param agentId - The agent ID
|
||||
* @param taskId - The task ID
|
||||
*/
|
||||
async deleteTaskSession(agentId: string, taskId: string): Promise<void> {
|
||||
const sessionPath = join(this.agentsDir, `${agentId}-sessions`, `${taskId}.json`);
|
||||
await unlink(sessionPath).catch(() => {});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Org Hierarchy
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get agents that report to a specific agent.
|
||||
* @param agentId - The parent agent ID
|
||||
* @returns Array of agents that report to this agent
|
||||
*/
|
||||
async getAgentsByReportsTo(agentId: string): Promise<Agent[]> {
|
||||
const all = await this.listAgents();
|
||||
return all.filter((a) => a.reportsTo === agentId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rich Run Storage
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Save a rich heartbeat run record (structured JSON, not JSONL events).
|
||||
* @param run - The heartbeat run data
|
||||
*/
|
||||
async saveRun(run: AgentHeartbeatRun): Promise<void> {
|
||||
const runsDir = join(this.agentsDir, `${run.agentId}-runs`);
|
||||
await mkdir(runsDir, { recursive: true });
|
||||
const runPath = join(runsDir, `${run.id}.json`);
|
||||
await writeFile(runPath, JSON.stringify(run, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific run by ID.
|
||||
* @param agentId - The agent ID
|
||||
* @param runId - The run ID
|
||||
* @returns The run detail, or null if not found
|
||||
*/
|
||||
async getRunDetail(agentId: string, runId: string): Promise<AgentHeartbeatRun | null> {
|
||||
const runPath = join(this.agentsDir, `${agentId}-runs`, `${runId}.json`);
|
||||
try {
|
||||
const content = await readFile(runPath, "utf-8");
|
||||
return JSON.parse(content) as AgentHeartbeatRun;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent runs for an agent from structured run storage.
|
||||
* @param agentId - The agent ID
|
||||
* @param limit - Max number of runs to return (default: 20)
|
||||
* @returns Array of runs (newest first)
|
||||
*/
|
||||
async getRecentRuns(agentId: string, limit = 20): Promise<AgentHeartbeatRun[]> {
|
||||
const runsDir = join(this.agentsDir, `${agentId}-runs`);
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(runsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const runFiles = files.filter((f) => f.endsWith(".json"));
|
||||
const runs: AgentHeartbeatRun[] = [];
|
||||
|
||||
for (const file of runFiles) {
|
||||
try {
|
||||
const content = await readFile(join(runsDir, file), "utf-8");
|
||||
runs.push(JSON.parse(content) as AgentHeartbeatRun);
|
||||
} catch {
|
||||
// Skip corrupted files
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by startedAt desc and limit
|
||||
return runs
|
||||
.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -555,6 +726,15 @@ export class AgentStore extends EventEmitter {
|
||||
updatedAt: data.updatedAt,
|
||||
lastHeartbeatAt: data.lastHeartbeatAt,
|
||||
metadata: data.metadata ?? {},
|
||||
title: data.title,
|
||||
icon: data.icon,
|
||||
reportsTo: data.reportsTo,
|
||||
runtimeConfig: data.runtimeConfig,
|
||||
pauseReason: data.pauseReason,
|
||||
permissions: data.permissions,
|
||||
totalInputTokens: data.totalInputTokens,
|
||||
totalOutputTokens: data.totalOutputTokens,
|
||||
lastError: data.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -570,6 +750,15 @@ export class AgentStore extends EventEmitter {
|
||||
updatedAt: agent.updatedAt,
|
||||
lastHeartbeatAt: agent.lastHeartbeatAt,
|
||||
metadata: agent.metadata,
|
||||
title: agent.title,
|
||||
icon: agent.icon,
|
||||
reportsTo: agent.reportsTo,
|
||||
runtimeConfig: agent.runtimeConfig,
|
||||
pauseReason: agent.pauseReason,
|
||||
permissions: agent.permissions,
|
||||
totalInputTokens: agent.totalInputTokens,
|
||||
totalOutputTokens: agent.totalOutputTokens,
|
||||
lastError: agent.lastError,
|
||||
};
|
||||
|
||||
// Write atomically using temp file
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent, AgentHeartbeatRun, NtfyNotificationEvent, SteeringComment } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment } from "./types.js";
|
||||
export { AgentStore } from "./agent-store.js";
|
||||
export type { AgentStoreEvents } from "./agent-store.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
|
||||
@@ -4301,7 +4301,7 @@ Task with acceptance criteria
|
||||
|
||||
const { execSync } = await import("node:child_process");
|
||||
try {
|
||||
execSync(`git checkout -b kb/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
|
||||
execSync(`git checkout -b fusion/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
|
||||
execSync('git commit --allow-empty -m "test commit"', { cwd: rootDir, stdio: "pipe" });
|
||||
execSync("git checkout main || git checkout master", { cwd: rootDir, stdio: "pipe" });
|
||||
} catch {
|
||||
@@ -4333,7 +4333,7 @@ Task with acceptance criteria
|
||||
// Create branch for merge
|
||||
const { execSync } = await import("node:child_process");
|
||||
try {
|
||||
execSync(`git checkout -b kb/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
|
||||
execSync(`git checkout -b fusion/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
|
||||
execSync('git commit --allow-empty -m "test commit"', { cwd: rootDir, stdio: "pipe" });
|
||||
execSync("git checkout main || git checkout master", { cwd: rootDir, stdio: "pipe" });
|
||||
} catch {
|
||||
|
||||
@@ -1016,7 +1016,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
async updateTask(
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; branch?: string; baseCommitSha?: string; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
||||
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; branch?: string; baseCommitSha?: string; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
// Validate that task doesn't depend on itself
|
||||
@@ -1115,6 +1115,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
} else if (updates.summary !== undefined) {
|
||||
task.summary = updates.summary;
|
||||
}
|
||||
if (updates.sessionFile === null) {
|
||||
task.sessionFile = undefined;
|
||||
} else if (updates.sessionFile !== undefined) {
|
||||
task.sessionFile = updates.sessionFile;
|
||||
}
|
||||
if (updates.workflowStepResults === null) {
|
||||
task.workflowStepResults = undefined;
|
||||
} else if (updates.workflowStepResults !== undefined) {
|
||||
@@ -1475,7 +1480,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
throw new Error(`Cannot merge ${id}: ${mergeBlocker}`);
|
||||
}
|
||||
|
||||
const branch = `kb/${id.toLowerCase()}`;
|
||||
const branch = `fusion/${id.toLowerCase()}`;
|
||||
const worktreePath = task.worktree;
|
||||
const result: MergeResult = {
|
||||
task,
|
||||
|
||||
@@ -524,6 +524,10 @@ export interface Task {
|
||||
nextRecoveryAt?: string;
|
||||
/** Thinking level for AI agent sessions — controls reasoning effort (off/minimal/low/medium/high) */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Path to the persisted agent session file, enabling pause/resume without
|
||||
* losing conversation context. Set when execution starts; cleared on
|
||||
* completion or terminal failure. */
|
||||
sessionFile?: string;
|
||||
/** Error message from the last failure, if the task failed during execution */
|
||||
error?: string;
|
||||
/** Optional summary of what was changed/fixed when task is completed */
|
||||
@@ -1243,14 +1247,16 @@ export interface PlanningSession {
|
||||
// ── Agent Types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Agent lifecycle states */
|
||||
export const AGENT_STATES = ["idle", "active", "paused", "terminated"] as const;
|
||||
export const AGENT_STATES = ["idle", "active", "running", "paused", "error", "terminated"] as const;
|
||||
export type AgentState = (typeof AGENT_STATES)[number];
|
||||
|
||||
/** Valid state transitions for agents */
|
||||
export const AGENT_VALID_TRANSITIONS: Record<AgentState, AgentState[]> = {
|
||||
idle: ["active"],
|
||||
active: ["paused", "terminated"],
|
||||
active: ["running", "paused", "terminated"],
|
||||
running: ["active", "paused", "error", "terminated"],
|
||||
paused: ["active", "terminated"],
|
||||
error: ["active", "terminated"],
|
||||
terminated: [], // Terminal state - no exits
|
||||
};
|
||||
|
||||
@@ -1264,6 +1270,9 @@ export interface AgentHeartbeatEvent {
|
||||
runId: string;
|
||||
}
|
||||
|
||||
/** What triggered a heartbeat run */
|
||||
export type HeartbeatInvocationSource = "on_demand" | "timer" | "assignment" | "automation";
|
||||
|
||||
/** A continuous heartbeat session/run for an agent */
|
||||
export interface AgentHeartbeatRun {
|
||||
/** Unique identifier for this run */
|
||||
@@ -1275,11 +1284,33 @@ export interface AgentHeartbeatRun {
|
||||
/** ISO-8601 timestamp when the run ended (null if active) */
|
||||
endedAt: string | null;
|
||||
/** Status of the run */
|
||||
status: "active" | "completed" | "terminated";
|
||||
status: "active" | "completed" | "terminated" | "failed";
|
||||
/** What triggered this run */
|
||||
invocationSource?: HeartbeatInvocationSource;
|
||||
/** Trigger detail (manual, ping, scheduler, system) */
|
||||
triggerDetail?: string;
|
||||
/** PID of the agent process */
|
||||
processPid?: number;
|
||||
/** Exit code of the agent process */
|
||||
exitCode?: number;
|
||||
/** Session ID before execution (for continuity tracking) */
|
||||
sessionIdBefore?: string;
|
||||
/** Session ID after execution */
|
||||
sessionIdAfter?: string;
|
||||
/** Token usage for this run */
|
||||
usageJson?: { inputTokens: number; outputTokens: number; cachedTokens: number };
|
||||
/** Structured result from the run */
|
||||
resultJson?: Record<string, unknown>;
|
||||
/** Snapshot of context at run start (taskId, projectId, etc.) */
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
/** Excerpt of stdout output */
|
||||
stdoutExcerpt?: string;
|
||||
/** Excerpt of stderr output */
|
||||
stderrExcerpt?: string;
|
||||
}
|
||||
|
||||
/** Capabilities/roles an agent can have */
|
||||
export type AgentCapability = "triage" | "executor" | "reviewer" | "merger" | "scheduler" | "custom";
|
||||
export type AgentCapability = "triage" | "executor" | "reviewer" | "merger" | "scheduler" | "engineer" | "custom";
|
||||
|
||||
/** Agent record stored in the system */
|
||||
export interface Agent {
|
||||
@@ -1301,6 +1332,24 @@ export interface Agent {
|
||||
lastHeartbeatAt?: string;
|
||||
/** Optional metadata */
|
||||
metadata: Record<string, unknown>;
|
||||
/** Job title / description for the agent */
|
||||
title?: string;
|
||||
/** Custom icon identifier */
|
||||
icon?: string;
|
||||
/** Agent ID this agent reports to (org hierarchy) */
|
||||
reportsTo?: string;
|
||||
/** Runtime configuration (maxTurns, thinkingLevel, etc.) */
|
||||
runtimeConfig?: Record<string, unknown>;
|
||||
/** Why the agent was paused (error, manual, etc.) */
|
||||
pauseReason?: string;
|
||||
/** Capability permission flags */
|
||||
permissions?: Record<string, boolean>;
|
||||
/** Cumulative input tokens across all runs */
|
||||
totalInputTokens?: number;
|
||||
/** Cumulative output tokens across all runs */
|
||||
totalOutputTokens?: number;
|
||||
/** Last error message */
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
/** Extended agent information including heartbeat history */
|
||||
@@ -1318,6 +1367,11 @@ export interface AgentCreateInput {
|
||||
name: string;
|
||||
role: AgentCapability;
|
||||
metadata?: Record<string, unknown>;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
reportsTo?: string;
|
||||
runtimeConfig?: Record<string, unknown>;
|
||||
permissions?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/** Input for updating an existing agent */
|
||||
@@ -1325,6 +1379,45 @@ export interface AgentUpdateInput {
|
||||
name?: string;
|
||||
role?: AgentCapability;
|
||||
metadata?: Record<string, unknown>;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
reportsTo?: string;
|
||||
runtimeConfig?: Record<string, unknown>;
|
||||
pauseReason?: string;
|
||||
permissions?: Record<string, boolean>;
|
||||
lastError?: string;
|
||||
totalInputTokens?: number;
|
||||
totalOutputTokens?: number;
|
||||
}
|
||||
|
||||
/** Per-task session persistence for an agent */
|
||||
export interface AgentTaskSession {
|
||||
/** Agent ID */
|
||||
agentId: string;
|
||||
/** Task ID */
|
||||
taskId: string;
|
||||
/** Session state for resuming context across runs */
|
||||
sessionParams: Record<string, unknown>;
|
||||
/** Human-readable session identifier */
|
||||
sessionDisplayId?: string;
|
||||
/** ISO-8601 timestamp when session was created */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Aggregate statistics for agents */
|
||||
export interface AgentStats {
|
||||
/** Number of agents in active/running state */
|
||||
activeCount: number;
|
||||
/** Number of tasks assigned to agents */
|
||||
assignedTaskCount: number;
|
||||
/** Total completed runs */
|
||||
completedRuns: number;
|
||||
/** Total failed runs */
|
||||
failedRuns: number;
|
||||
/** Success rate (0-1) */
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
// ── Multi-Project First-Run & Migration Types ───────────────────────────────
|
||||
|
||||
@@ -1628,8 +1628,8 @@ export function cancelSubtaskBreakdown(sessionId: string, projectId?: string): P
|
||||
|
||||
// ── Agent API ────────────────────────────────────────────────────────────
|
||||
|
||||
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput } from "@fusion/core";
|
||||
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput };
|
||||
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats } from "@fusion/core";
|
||||
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats };
|
||||
|
||||
function withProjectId(path: string, projectId?: string): string {
|
||||
if (!projectId) return path;
|
||||
@@ -1707,6 +1707,25 @@ export function fetchAgentHeartbeats(agentId: string, limit?: number, projectId?
|
||||
return api<AgentHeartbeatEvent[]>(`/agents/${encodeURIComponent(agentId)}/heartbeats${query}`);
|
||||
}
|
||||
|
||||
/** Fetch heartbeat runs for an agent */
|
||||
export function fetchAgentRuns(agentId: string, limit?: number, projectId?: string): Promise<AgentHeartbeatRun[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit !== undefined) params.set("limit", String(limit));
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<AgentHeartbeatRun[]>(`/agents/${encodeURIComponent(agentId)}/runs${query}`);
|
||||
}
|
||||
|
||||
/** Fetch a single heartbeat run detail */
|
||||
export function fetchAgentRunDetail(agentId: string, runId: string, projectId?: string): Promise<AgentHeartbeatRun> {
|
||||
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`, projectId));
|
||||
}
|
||||
|
||||
/** Fetch aggregate agent stats */
|
||||
export function fetchAgentStats(projectId?: string): Promise<AgentStats> {
|
||||
return api<AgentStats>(withProjectId("/agents/stats", projectId));
|
||||
}
|
||||
|
||||
// --- Backup API ---
|
||||
|
||||
/** Backup metadata from the API */
|
||||
|
||||
73
packages/dashboard/app/components/ActiveAgentsPanel.tsx
Normal file
73
packages/dashboard/app/components/ActiveAgentsPanel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Activity } from "lucide-react";
|
||||
import type { Agent } from "../api";
|
||||
import { useLiveTranscript } from "../hooks/useLiveTranscript";
|
||||
|
||||
interface LiveAgentCardProps {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
function LiveAgentCard({ agent }: LiveAgentCardProps) {
|
||||
const { entries, isConnected } = useLiveTranscript(agent.taskId);
|
||||
const elapsed = agent.lastHeartbeatAt
|
||||
? Math.floor((Date.now() - new Date(agent.lastHeartbeatAt).getTime()) / 1000)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="live-agent-card">
|
||||
<div className="live-agent-card-header">
|
||||
<div className="live-agent-card-name">
|
||||
<span className="live-agent-pulse" />
|
||||
<span>{agent.name}</span>
|
||||
</div>
|
||||
{agent.taskId && (
|
||||
<span className="live-agent-task badge">{agent.taskId}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="live-agent-card-transcript">
|
||||
{entries.length === 0 ? (
|
||||
<div className="live-agent-card-empty">
|
||||
{isConnected ? "Waiting for output..." : "Connecting..."}
|
||||
</div>
|
||||
) : (
|
||||
entries.slice(0, 20).map((entry, i) => (
|
||||
<div key={i} className="live-agent-card-line">
|
||||
{entry.content}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="live-agent-card-footer">
|
||||
<span className="text-secondary">{formatElapsed(elapsed)}</span>
|
||||
{isConnected && <Activity size={12} className="live-agent-streaming-dot" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatElapsed(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
interface ActiveAgentsPanelProps {
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
export function ActiveAgentsPanel({ agents }: ActiveAgentsPanelProps) {
|
||||
if (agents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="active-agents-panel">
|
||||
<div className="active-agents-panel-header">
|
||||
<Activity size={16} />
|
||||
<span>Active Agents ({agents.length})</span>
|
||||
</div>
|
||||
<div className="active-agents-grid">
|
||||
{agents.map(agent => (
|
||||
<LiveAgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,9 @@ const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
|
||||
const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: string }> = {
|
||||
idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" },
|
||||
active: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
|
||||
running: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
|
||||
paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
|
||||
error: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
};
|
||||
|
||||
@@ -183,8 +185,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
|
||||
if (agent.state === "terminated") {
|
||||
return { label: "Terminated", color: "var(--state-error-text, #f85149)" };
|
||||
}
|
||||
if (agent.state === "error") {
|
||||
return { label: agent.lastError ?? "Error", color: "var(--state-error-text, #f85149)" };
|
||||
}
|
||||
if (agent.state === "paused") {
|
||||
return { label: "Paused", color: "var(--state-paused-text, #e3b541)" };
|
||||
return { label: agent.pauseReason ? `Paused: ${agent.pauseReason}` : "Paused", color: "var(--state-paused-text, #e3b541)" };
|
||||
}
|
||||
if (agent.state === "running") {
|
||||
return { label: "Running", color: "var(--state-active-text, #3fb950)" };
|
||||
}
|
||||
if (!agent.lastHeartbeatAt) {
|
||||
return { label: agent.state === "active" ? "Starting..." : "Idle", color: "var(--state-idle-text, #8b949e)" };
|
||||
@@ -292,13 +300,37 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "running" && (
|
||||
<>
|
||||
<button className="btn" onClick={() => void handleStateChange("paused")}>
|
||||
<Pause size={16} />
|
||||
Pause
|
||||
</button>
|
||||
<button className="btn btn--danger" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={16} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "error" && (
|
||||
<>
|
||||
<button className="btn btn--primary" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={16} />
|
||||
Retry
|
||||
</button>
|
||||
<button className="btn btn--danger" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={16} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<button className="btn btn--danger" onClick={handleDelete}>
|
||||
<Trash2 size={16} />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
<button className="btn-icon" onClick={() => void loadAgent()} title="Refresh">
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
|
||||
@@ -17,14 +17,17 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
{ value: "reviewer", label: "Reviewer", icon: "👁" },
|
||||
{ value: "merger", label: "Merger", icon: "🔀" },
|
||||
{ value: "scheduler", label: "Scheduler", icon: "⏰" },
|
||||
{ value: "engineer", label: "Engineer", icon: "🛠" },
|
||||
{ value: "custom", label: "Custom", icon: "🔧" },
|
||||
];
|
||||
|
||||
const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: string }> = {
|
||||
idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" },
|
||||
active: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
|
||||
running: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
|
||||
paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
|
||||
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
error: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
};
|
||||
|
||||
export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentListModalProps) {
|
||||
@@ -134,8 +137,14 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
if (agent.state === "terminated") {
|
||||
return { label: "Terminated", icon: <Square size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
if (agent.state === "error") {
|
||||
return { label: agent.lastError ?? "Error", icon: <Activity size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
if (agent.state === "running") {
|
||||
return { label: "Running", icon: <Activity size={14} />, color: "var(--state-active-text)" };
|
||||
}
|
||||
if (agent.state === "paused") {
|
||||
return { label: "Paused", icon: <Pause size={14} />, color: "var(--state-paused-text)" };
|
||||
return { label: agent.pauseReason ?? "Paused", icon: <Pause size={14} />, color: "var(--state-paused-text)" };
|
||||
}
|
||||
if (!agent.lastHeartbeatAt) {
|
||||
return { label: agent.state === "active" ? "Starting..." : "Idle", icon: <Bot size={14} />, color: "var(--text-secondary)" };
|
||||
@@ -208,7 +217,9 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
<option value="all">All States</option>
|
||||
<option value="idle">Idle</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -341,6 +352,42 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "running" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
title="Pause"
|
||||
>
|
||||
<Pause size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "error" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Retry"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
@@ -490,6 +537,42 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "running" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
title="Pause"
|
||||
>
|
||||
<Pause size={14} /> Pause
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "error" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Retry"
|
||||
>
|
||||
<Play size={14} /> Retry
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
|
||||
30
packages/dashboard/app/components/AgentMetricsBar.tsx
Normal file
30
packages/dashboard/app/components/AgentMetricsBar.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Activity, CheckCircle, ListTodo } from "lucide-react";
|
||||
import type { AgentStats } from "../api";
|
||||
|
||||
interface AgentMetricsBarProps {
|
||||
stats: AgentStats | null;
|
||||
}
|
||||
|
||||
export function AgentMetricsBar({ stats }: AgentMetricsBarProps) {
|
||||
if (!stats) return null;
|
||||
|
||||
const cards = [
|
||||
{ icon: Activity, label: "Active Agents", value: stats.activeCount, color: "var(--state-active-text)" },
|
||||
{ icon: ListTodo, label: "Assigned Tasks", value: stats.assignedTaskCount, color: "var(--in-progress)" },
|
||||
{ icon: CheckCircle, label: "Success Rate", value: `${Math.round(stats.successRate * 100)}%`, color: "var(--color-success, #3fb950)" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="agent-metrics-bar">
|
||||
{cards.map(card => (
|
||||
<div key={card.label} className="agent-metric-card">
|
||||
<card.icon size={18} style={{ color: card.color }} />
|
||||
<div className="agent-metric-info">
|
||||
<span className="agent-metric-value">{card.value}</span>
|
||||
<span className="agent-metric-label">{card.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
packages/dashboard/app/components/AgentRunHistory.tsx
Normal file
73
packages/dashboard/app/components/AgentRunHistory.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { CheckCircle, XCircle, Loader2, Square, Clock } from "lucide-react";
|
||||
import type { AgentHeartbeatRun } from "../api";
|
||||
import { fetchAgentRuns } from "../api";
|
||||
|
||||
interface AgentRunHistoryProps {
|
||||
agentId: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
|
||||
completed: { icon: CheckCircle, color: "var(--color-success, #3fb950)" },
|
||||
failed: { icon: XCircle, color: "var(--color-error, #f85149)" },
|
||||
active: { icon: Loader2, color: "var(--in-progress, #bc8cff)" },
|
||||
terminated: { icon: Square, color: "var(--text-muted, #8b949e)" },
|
||||
};
|
||||
|
||||
export function AgentRunHistory({ agentId, projectId }: AgentRunHistoryProps) {
|
||||
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
fetchAgentRuns(agentId, 50, projectId)
|
||||
.then(setRuns)
|
||||
.catch(() => setRuns([]))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [agentId, projectId]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="agent-run-loading"><Loader2 className="animate-spin" size={20} /> Loading runs...</div>;
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
return <div className="agent-run-empty">No runs yet</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="agent-run-history">
|
||||
{runs.map(run => {
|
||||
const statusInfo = STATUS_ICONS[run.status] ?? STATUS_ICONS.terminated;
|
||||
const StatusIcon = statusInfo.icon;
|
||||
const duration = run.endedAt
|
||||
? Math.round((new Date(run.endedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)
|
||||
: null;
|
||||
const usage = run.usageJson;
|
||||
|
||||
return (
|
||||
<div key={run.id} className="agent-run-row">
|
||||
<StatusIcon size={16} style={{ color: statusInfo.color }} className={run.status === "active" ? "animate-spin" : ""} />
|
||||
<div className="agent-run-info">
|
||||
<span className="agent-run-id">{run.id}</span>
|
||||
<span className="text-secondary">{new Date(run.startedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="agent-run-meta">
|
||||
{duration !== null && (
|
||||
<span className="badge"><Clock size={12} /> {duration}s</span>
|
||||
)}
|
||||
{usage && (
|
||||
<span className="badge text-secondary">
|
||||
{((usage.inputTokens + usage.outputTokens) / 1000).toFixed(1)}k tokens
|
||||
</span>
|
||||
)}
|
||||
{run.triggerDetail && (
|
||||
<span className="badge text-secondary">{run.triggerDetail}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,12 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, ChevronRight, Filter } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState } from "../api";
|
||||
import { fetchAgents, createAgent, updateAgent, updateAgentState, deleteAgent } from "../api";
|
||||
import { fetchAgents, updateAgent, updateAgentState, deleteAgent } from "../api";
|
||||
import { AgentDetailView } from "./AgentDetailView";
|
||||
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
|
||||
import { AgentMetricsBar } from "./AgentMetricsBar";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { NewAgentDialog } from "./NewAgentDialog";
|
||||
|
||||
export interface AgentsViewProps {
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
@@ -16,22 +20,24 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
{ value: "reviewer", label: "Reviewer", icon: "👁" },
|
||||
{ value: "merger", label: "Merger", icon: "🔀" },
|
||||
{ value: "scheduler", label: "Scheduler", icon: "⏰" },
|
||||
{ value: "engineer", label: "Engineer", icon: "🛠" },
|
||||
{ value: "custom", label: "Custom", icon: "🔧" },
|
||||
];
|
||||
|
||||
const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: string }> = {
|
||||
idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" },
|
||||
active: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
|
||||
running: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
|
||||
paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
|
||||
error: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
};
|
||||
|
||||
export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const { activeAgents, stats } = useAgents(projectId);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newAgentName, setNewAgentName] = useState("");
|
||||
const [newAgentRole, setNewAgentRole] = useState<AgentCapability>("custom");
|
||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [agentView, setAgentView] = useState<"board" | "list">(() => {
|
||||
@@ -65,19 +71,6 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
void loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newAgentName.trim()) return;
|
||||
try {
|
||||
await createAgent({ name: newAgentName.trim(), role: newAgentRole }, projectId);
|
||||
addToast(`Agent "${newAgentName}" created`, "success");
|
||||
setNewAgentName("");
|
||||
setIsCreating(false);
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to create agent: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||
try {
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
@@ -132,8 +125,14 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
if (agent.state === "terminated") {
|
||||
return { label: "Terminated", icon: <Square size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
if (agent.state === "error") {
|
||||
return { label: agent.lastError ?? "Error", icon: <Activity size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
if (agent.state === "paused") {
|
||||
return { label: "Paused", icon: <Pause size={14} />, color: "var(--state-paused-text)" };
|
||||
return { label: agent.pauseReason ? `Paused: ${agent.pauseReason}` : "Paused", icon: <Pause size={14} />, color: "var(--state-paused-text)" };
|
||||
}
|
||||
if (agent.state === "running") {
|
||||
return { label: "Running", icon: <Activity size={14} />, color: "var(--state-active-text)" };
|
||||
}
|
||||
if (!agent.lastHeartbeatAt) {
|
||||
return { label: agent.state === "active" ? "Starting..." : "Idle", icon: <Bot size={14} />, color: "var(--text-secondary)" };
|
||||
@@ -200,48 +199,34 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
<option value="all">All States</option>
|
||||
<option value="idle">Idle</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
onClick={() => setIsCreating(!isCreating)}
|
||||
onClick={() => setIsCreating(true)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{isCreating ? "Cancel" : "New Agent"}
|
||||
New Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{isCreating && (
|
||||
<div className="agent-create-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Agent name..."
|
||||
value={newAgentName}
|
||||
onChange={(e) => setNewAgentName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void handleCreate()}
|
||||
className="input"
|
||||
autoFocus
|
||||
/>
|
||||
<select
|
||||
className="select"
|
||||
value={newAgentRole}
|
||||
onChange={(e) => setNewAgentRole(e.target.value as AgentCapability)}
|
||||
>
|
||||
{AGENT_ROLES.map(role => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.icon} {role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn--primary" onClick={() => void handleCreate()}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<NewAgentDialog
|
||||
isOpen={isCreating}
|
||||
onClose={() => setIsCreating(false)}
|
||||
onCreated={() => { setIsCreating(false); void loadAgents(); }}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
{/* Metrics Bar */}
|
||||
<AgentMetricsBar stats={stats} />
|
||||
|
||||
{/* Active Agents Panel - Live streaming cards */}
|
||||
<ActiveAgentsPanel agents={activeAgents} />
|
||||
|
||||
{/* Agent List */}
|
||||
<div className={agentView === "board" ? "agent-board" : "agent-list"}>
|
||||
@@ -341,6 +326,42 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "running" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
title="Pause"
|
||||
>
|
||||
<Pause size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "error" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Retry"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
@@ -501,6 +522,42 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "running" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
title="Pause"
|
||||
>
|
||||
<Pause size={14} /> Pause
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "error" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Retry"
|
||||
>
|
||||
<Play size={14} /> Retry
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
|
||||
273
packages/dashboard/app/components/NewAgentDialog.tsx
Normal file
273
packages/dashboard/app/components/NewAgentDialog.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useState } from "react";
|
||||
import type { AgentCapability } from "../api";
|
||||
import { createAgent } from "../api";
|
||||
|
||||
export interface NewAgentDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
{ value: "triage", label: "Triage", icon: "🔍" },
|
||||
{ value: "executor", label: "Executor", icon: "⚡" },
|
||||
{ value: "reviewer", label: "Reviewer", icon: "👁" },
|
||||
{ value: "merger", label: "Merger", icon: "🔀" },
|
||||
{ value: "scheduler", label: "Scheduler", icon: "⏰" },
|
||||
{ value: "engineer", label: "Engineer", icon: "🛠" },
|
||||
{ value: "custom", label: "Custom", icon: "🔧" },
|
||||
];
|
||||
|
||||
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
|
||||
|
||||
interface RuntimeConfig {
|
||||
model: string;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
maxTurns: number;
|
||||
}
|
||||
|
||||
export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAgentDialogProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [name, setName] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [role, setRole] = useState<AgentCapability>("custom");
|
||||
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig>({
|
||||
model: "",
|
||||
thinkingLevel: "off",
|
||||
maxTurns: 10,
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
setStep(0);
|
||||
setName("");
|
||||
setTitle("");
|
||||
setRole("custom");
|
||||
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 10 });
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const runtimeCfg: Record<string, unknown> = {};
|
||||
if (runtimeConfig.model.trim()) runtimeCfg.model = runtimeConfig.model.trim();
|
||||
if (runtimeConfig.thinkingLevel !== "off") runtimeCfg.thinkingLevel = runtimeConfig.thinkingLevel;
|
||||
if (runtimeConfig.maxTurns !== 10) runtimeCfg.maxTurns = runtimeConfig.maxTurns;
|
||||
await createAgent({
|
||||
name: name.trim(),
|
||||
role,
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
|
||||
}, projectId);
|
||||
handleClose();
|
||||
onCreated();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create agent");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedRole = AGENT_ROLES.find(r => r.value === role);
|
||||
|
||||
return (
|
||||
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
|
||||
<div className="agent-dialog" role="dialog" aria-modal="true" aria-label="Create new agent">
|
||||
{/* Header */}
|
||||
<div className="agent-dialog-header">
|
||||
<span style={{ fontWeight: 600, fontSize: 15 }}>New Agent</span>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)", fontSize: 18, lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="agent-dialog-steps">
|
||||
{[0, 1, 2].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className={`agent-dialog-step${i === step ? " active" : i < step ? " completed" : ""}`}
|
||||
aria-label={`Step ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="agent-dialog-body">
|
||||
{step === 0 && (
|
||||
<div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-name">Name <span style={{ color: "var(--state-error-text, #f85149)" }}>*</span></label>
|
||||
<input
|
||||
id="agent-name"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. Frontend Reviewer"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
autoFocus
|
||||
style={{ width: "100%", boxSizing: "border-box" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-title">Title <span style={{ color: "var(--text-muted)", fontWeight: 400 }}>(optional)</span></label>
|
||||
<input
|
||||
id="agent-title"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. Senior Code Reviewer"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
style={{ width: "100%", boxSizing: "border-box" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label>Role</label>
|
||||
<div className="agent-role-grid">
|
||||
{AGENT_ROLES.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
className={`agent-role-option${role === r.value ? " selected" : ""}`}
|
||||
onClick={() => setRole(r.value)}
|
||||
>
|
||||
<span className="agent-role-option-icon">{r.icon}</span>
|
||||
<span style={{ fontSize: 12, marginTop: 4 }}>{r.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-model">Model ID</label>
|
||||
<input
|
||||
id="agent-model"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. claude-sonnet-4-5"
|
||||
value={runtimeConfig.model}
|
||||
onChange={e => setRuntimeConfig(c => ({ ...c, model: e.target.value }))}
|
||||
style={{ width: "100%", boxSizing: "border-box" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-thinking">Thinking Level</label>
|
||||
<select
|
||||
id="agent-thinking"
|
||||
className="select"
|
||||
value={runtimeConfig.thinkingLevel}
|
||||
onChange={e => setRuntimeConfig(c => ({ ...c, thinkingLevel: e.target.value as ThinkingLevel }))}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-max-turns">Max Turns</label>
|
||||
<input
|
||||
id="agent-max-turns"
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={500}
|
||||
value={runtimeConfig.maxTurns}
|
||||
onChange={e => setRuntimeConfig(c => ({ ...c, maxTurns: Math.max(1, parseInt(e.target.value, 10) || 1) }))}
|
||||
style={{ width: "100%", boxSizing: "border-box" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 13, marginTop: 0, marginBottom: 12 }}>
|
||||
Review your agent configuration before creating.
|
||||
</p>
|
||||
<div className="agent-dialog-summary">
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Name</span>
|
||||
<span style={{ fontWeight: 600 }}>{name}</span>
|
||||
</div>
|
||||
{title && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Title</span>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Role</span>
|
||||
<span>{selectedRole?.icon} {selectedRole?.label}</span>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Model</span>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 13 }}>{runtimeConfig.model || <em style={{ color: "var(--text-muted)" }}>default</em>}</span>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Thinking</span>
|
||||
<span style={{ textTransform: "capitalize" }}>{runtimeConfig.thinkingLevel}</span>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Max Turns</span>
|
||||
<span>{runtimeConfig.maxTurns}</span>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p style={{ color: "var(--state-error-text, #f85149)", fontSize: 13, marginTop: 12 }}>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="agent-dialog-footer">
|
||||
{step > 0 && (
|
||||
<button className="btn" onClick={() => setStep(s => s - 1)} disabled={isSubmitting}>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" onClick={handleClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</button>
|
||||
{step < 2 ? (
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
onClick={() => setStep(s => s + 1)}
|
||||
disabled={step === 0 && !name.trim()}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={isSubmitting || !name.trim()}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
packages/dashboard/app/hooks/useAgents.ts
Normal file
60
packages/dashboard/app/hooks/useAgents.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Agent, AgentState, AgentCapability, AgentStats } from "../api";
|
||||
import { fetchAgents, fetchAgentStats } from "../api";
|
||||
|
||||
export function useAgents(projectId?: string) {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [stats, setStats] = useState<AgentStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const loadAgents = useCallback(async (filter?: { state?: AgentState; role?: AgentCapability }) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchAgents(filter, projectId);
|
||||
setAgents(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load agents:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchAgentStats(projectId);
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load agent stats:", err);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAgents();
|
||||
void loadStats();
|
||||
}, [loadAgents, loadStats]);
|
||||
|
||||
// SSE subscription for agent events
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
const query = `?projectId=${encodeURIComponent(projectId)}`;
|
||||
const es = new EventSource(`/api/events${query}`);
|
||||
|
||||
const refresh = () => {
|
||||
void loadAgents();
|
||||
void loadStats();
|
||||
};
|
||||
|
||||
es.addEventListener("agent:created", refresh);
|
||||
es.addEventListener("agent:updated", refresh);
|
||||
es.addEventListener("agent:deleted", refresh);
|
||||
es.addEventListener("agent:stateChanged", refresh);
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
}, [projectId, loadAgents, loadStats]);
|
||||
|
||||
const activeAgents = agents.filter(a => a.state === "active" || a.state === "running");
|
||||
|
||||
return { agents, activeAgents, stats, isLoading, loadAgents, loadStats };
|
||||
}
|
||||
43
packages/dashboard/app/hooks/useLiveTranscript.ts
Normal file
43
packages/dashboard/app/hooks/useLiveTranscript.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
/** Log entry from an agent's execution stream */
|
||||
export interface TranscriptEntry {
|
||||
type: string;
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export function useLiveTranscript(taskId: string | undefined) {
|
||||
const [entries, setEntries] = useState<TranscriptEntry[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) {
|
||||
setEntries([]);
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const es = new EventSource(`/api/tasks/${encodeURIComponent(taskId)}/logs/stream`);
|
||||
esRef.current = es;
|
||||
|
||||
es.addEventListener("agent:log", (event) => {
|
||||
try {
|
||||
const entry = JSON.parse(event.data) as TranscriptEntry;
|
||||
setEntries(prev => [entry, ...prev]);
|
||||
} catch { /* skip */ }
|
||||
});
|
||||
|
||||
es.addEventListener("open", () => setIsConnected(true));
|
||||
es.addEventListener("error", () => setIsConnected(false));
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
esRef.current = null;
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
return { entries, isConnected };
|
||||
}
|
||||
@@ -17028,7 +17028,311 @@ html .column.drag-over * {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Active Agents Panel ──────────────────────────────────────────────── */
|
||||
|
||||
.active-agents-panel {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.active-agents-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-md);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.active-agents-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.live-agent-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 280px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.live-agent-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-sm);
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.live-agent-card-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.live-agent-pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #22d3ee;
|
||||
animation: pulse-cyan 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-cyan {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.4); }
|
||||
50% { opacity: 0.7; box-shadow: 0 0 0 6px rgba(34, 211, 238, 0); }
|
||||
}
|
||||
|
||||
.live-agent-card-transcript {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.live-agent-card-empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: var(--space-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.live-agent-card-line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.live-agent-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.live-agent-streaming-dot {
|
||||
color: #22d3ee;
|
||||
animation: pulse-cyan 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Agent Metrics Bar ─────────────────────────────────────────────────── */
|
||||
|
||||
.agent-metrics-bar {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.agent-metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.agent-metric-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-metric-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.agent-metric-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Agent Run History ─────────────────────────────────────────────────── */
|
||||
|
||||
.agent-run-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.agent-run-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.agent-run-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-run-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.agent-run-meta {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-run-loading,
|
||||
.agent-run-empty {
|
||||
padding: var(--space-lg);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ── Light Theme ── */
|
||||
[data-theme="light"] .mission-manager {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ── NewAgentDialog ── */
|
||||
.agent-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.agent-dialog {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
max-width: 560px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.agent-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.agent-dialog-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.agent-dialog-step {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.agent-dialog-step.active {
|
||||
background: var(--cta-bg);
|
||||
}
|
||||
|
||||
.agent-dialog-step.completed {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.agent-dialog-body {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.agent-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.agent-role-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-role-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-role-option:hover {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-role-option.selected {
|
||||
border-color: var(--cta-bg);
|
||||
background: rgba(35, 134, 54, 0.1);
|
||||
}
|
||||
|
||||
.agent-role-option-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.agent-dialog-field {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.agent-dialog-field label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.agent-dialog-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-dialog-summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -3764,7 +3764,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
|
||||
// Determine branch name from task
|
||||
const branchName = `kb/${task.id.toLowerCase()}`;
|
||||
const branchName = `fusion/${task.id.toLowerCase()}`;
|
||||
|
||||
// Get owner/repo from git remote or GITHUB_REPOSITORY env
|
||||
let owner: string;
|
||||
@@ -6307,6 +6307,38 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/stats
|
||||
* Return aggregate stats across all agents.
|
||||
* Must be registered before /agents/:id to avoid "stats" matching :id.
|
||||
*/
|
||||
router.get("/agents/stats", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getRootDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agents = await agentStore.listAgents();
|
||||
const activeCount = agents.filter((a: any) => a.state === "active" || a.state === "running").length;
|
||||
const assignedTaskCount = agents.filter((a: any) => a.taskId).length;
|
||||
|
||||
let completedRuns = 0;
|
||||
let failedRuns = 0;
|
||||
for (const agent of agents) {
|
||||
const runs = await agentStore.getRecentRuns(agent.id, 100);
|
||||
completedRuns += runs.filter((r: any) => r.status === "completed").length;
|
||||
failedRuns += runs.filter((r: any) => r.status === "failed" || r.status === "terminated").length;
|
||||
}
|
||||
|
||||
const total = completedRuns + failedRuns;
|
||||
const successRate = total > 0 ? completedRuns / total : 0;
|
||||
res.json({ activeCount, assignedTaskCount, completedRuns, failedRuns, successRate });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id
|
||||
* Get agent by ID with heartbeat history.
|
||||
@@ -6451,6 +6483,56 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/runs
|
||||
* List recent runs for an agent.
|
||||
* Query: limit (default: 20)
|
||||
*/
|
||||
router.get("/agents/:id/runs", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getRootDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 20;
|
||||
const runs = await agentStore.getRecentRuns(req.params.id, limit);
|
||||
res.json(runs);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/runs/:runId
|
||||
* Get detail for a specific agent run.
|
||||
*/
|
||||
router.get("/agents/:id/runs/:runId", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getRootDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const run = await agentStore.getRunDetail(req.params.id, req.params.runId);
|
||||
if (!run) {
|
||||
res.status(404).json({ error: "Run not found" });
|
||||
return;
|
||||
}
|
||||
res.json(run);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mission Routes ─────────────────────────────────────────────────────────
|
||||
// Mount mission routes at /api/missions
|
||||
router.use("/missions", createMissionRouter(store));
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* - onTerminated: Called when an unresponsive agent is terminated
|
||||
*/
|
||||
|
||||
import type { AgentStore, Agent, AgentState } from "@fusion/core";
|
||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource } from "@fusion/core";
|
||||
|
||||
/** Options for HeartbeatMonitor constructor */
|
||||
export interface HeartbeatMonitorOptions {
|
||||
@@ -20,12 +20,28 @@ export interface HeartbeatMonitorOptions {
|
||||
pollIntervalMs?: number;
|
||||
/** Heartbeat timeout in milliseconds (default: 60000) */
|
||||
heartbeatTimeoutMs?: number;
|
||||
/** Max concurrent runs per agent (default: 1) */
|
||||
maxConcurrentRuns?: number;
|
||||
/** Callback when an agent misses its heartbeat */
|
||||
onMissed?: (agentId: string) => void;
|
||||
/** Callback when an agent recovers after a missed heartbeat */
|
||||
onRecovered?: (agentId: string) => void;
|
||||
/** Callback when an unresponsive agent is terminated */
|
||||
onTerminated?: (agentId: string) => void;
|
||||
/** Callback when a run starts */
|
||||
onRunStarted?: (agentId: string, run: AgentHeartbeatRun) => void;
|
||||
/** Callback when a run completes */
|
||||
onRunCompleted?: (agentId: string, run: AgentHeartbeatRun) => void;
|
||||
}
|
||||
|
||||
/** Options for waking up an agent */
|
||||
export interface WakeupOptions {
|
||||
/** What triggered the wakeup */
|
||||
source: HeartbeatInvocationSource;
|
||||
/** Detail about the trigger (manual, ping, scheduler, system) */
|
||||
triggerDetail?: string;
|
||||
/** Context snapshot for the run */
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Session interface for disposing agent resources */
|
||||
@@ -41,6 +57,8 @@ interface TrackedAgent {
|
||||
runId: string;
|
||||
lastSeen: number; // timestamp from Date.now()
|
||||
missedHeartbeatReported: boolean;
|
||||
/** Session ID before this execution started */
|
||||
sessionIdBefore?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,11 +69,15 @@ export class HeartbeatMonitor {
|
||||
private store: AgentStore;
|
||||
private pollIntervalMs: number;
|
||||
private heartbeatTimeoutMs: number;
|
||||
private maxConcurrentRuns: number;
|
||||
private onMissed?: (agentId: string) => void;
|
||||
private onRecovered?: (agentId: string) => void;
|
||||
private onTerminated?: (agentId: string) => void;
|
||||
private onRunStarted?: (agentId: string, run: AgentHeartbeatRun) => void;
|
||||
private onRunCompleted?: (agentId: string, run: AgentHeartbeatRun) => void;
|
||||
|
||||
private trackedAgents: Map<string, TrackedAgent> = new Map();
|
||||
private agentStartLocks: Map<string, Promise<unknown>> = new Map();
|
||||
private pollInterval: NodeJS.Timeout | null = null;
|
||||
private isRunning = false;
|
||||
|
||||
@@ -63,9 +85,12 @@ export class HeartbeatMonitor {
|
||||
this.store = options.store;
|
||||
this.pollIntervalMs = options.pollIntervalMs ?? 30000;
|
||||
this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? 60000;
|
||||
this.maxConcurrentRuns = options.maxConcurrentRuns ?? 1;
|
||||
this.onMissed = options.onMissed;
|
||||
this.onRecovered = options.onRecovered;
|
||||
this.onTerminated = options.onTerminated;
|
||||
this.onRunStarted = options.onRunStarted;
|
||||
this.onRunCompleted = options.onRunCompleted;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,18 +128,20 @@ export class HeartbeatMonitor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an agent for monitoring.
|
||||
* Register an agent for monitoring with optional session context.
|
||||
* @param agentId - The agent ID
|
||||
* @param session - Session with dispose() for cleanup
|
||||
* @param runId - The heartbeat run ID
|
||||
* @param sessionIdBefore - Optional session ID from before execution
|
||||
*/
|
||||
trackAgent(agentId: string, session: AgentSession, runId: string): void {
|
||||
trackAgent(agentId: string, session: AgentSession, runId: string, sessionIdBefore?: string): void {
|
||||
const tracked: TrackedAgent = {
|
||||
agentId,
|
||||
session,
|
||||
runId,
|
||||
lastSeen: Date.now(),
|
||||
missedHeartbeatReported: false,
|
||||
sessionIdBefore,
|
||||
};
|
||||
|
||||
this.trackedAgents.set(agentId, tracked);
|
||||
@@ -123,6 +150,126 @@ export class HeartbeatMonitor {
|
||||
void this.store.recordHeartbeat(agentId, "ok", runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize run starts per agent to prevent concurrent execution.
|
||||
* @param agentId - The agent ID
|
||||
* @param fn - Function to execute with the lock
|
||||
*/
|
||||
async withAgentStartLock<T>(agentId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const existing = this.agentStartLocks.get(agentId) ?? Promise.resolve();
|
||||
const operation = existing.then(fn, fn);
|
||||
this.agentStartLocks.set(agentId, operation);
|
||||
return operation as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a rich heartbeat run with full context capture.
|
||||
* Creates a structured run record and saves it to the run store.
|
||||
* @param agentId - The agent ID
|
||||
* @param options - Wakeup options with trigger context
|
||||
* @returns The created run
|
||||
*/
|
||||
async startRun(agentId: string, options?: WakeupOptions): Promise<AgentHeartbeatRun> {
|
||||
const run = await this.store.startHeartbeatRun(agentId);
|
||||
|
||||
// Enrich with execution context
|
||||
const enrichedRun: AgentHeartbeatRun = {
|
||||
...run,
|
||||
invocationSource: options?.source ?? "on_demand",
|
||||
triggerDetail: options?.triggerDetail ?? "manual",
|
||||
contextSnapshot: options?.contextSnapshot,
|
||||
processPid: process.pid,
|
||||
};
|
||||
|
||||
// Save rich run data
|
||||
await this.store.saveRun(enrichedRun);
|
||||
|
||||
// Transition agent to running state
|
||||
try {
|
||||
await this.store.updateAgentState(agentId, "running");
|
||||
} catch {
|
||||
// May fail if already in running state - that's ok
|
||||
}
|
||||
|
||||
this.onRunStarted?.(agentId, enrichedRun);
|
||||
return enrichedRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a heartbeat run with results.
|
||||
* @param agentId - The agent ID
|
||||
* @param runId - The run ID to complete
|
||||
* @param result - Execution results
|
||||
*/
|
||||
async completeRun(
|
||||
agentId: string,
|
||||
runId: string,
|
||||
result: {
|
||||
status: "completed" | "failed" | "terminated";
|
||||
exitCode?: number;
|
||||
sessionIdAfter?: string;
|
||||
usageJson?: { inputTokens: number; outputTokens: number; cachedTokens: number };
|
||||
resultJson?: Record<string, unknown>;
|
||||
stdoutExcerpt?: string;
|
||||
stderrExcerpt?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
// Load and update the run
|
||||
const run = await this.store.getRunDetail(agentId, runId);
|
||||
if (!run) return;
|
||||
|
||||
const tracked = this.trackedAgents.get(agentId);
|
||||
const completedRun: AgentHeartbeatRun = {
|
||||
...run,
|
||||
endedAt: new Date().toISOString(),
|
||||
status: result.status,
|
||||
exitCode: result.exitCode,
|
||||
sessionIdBefore: tracked?.sessionIdBefore,
|
||||
sessionIdAfter: result.sessionIdAfter,
|
||||
usageJson: result.usageJson,
|
||||
resultJson: result.resultJson,
|
||||
stdoutExcerpt: result.stdoutExcerpt,
|
||||
stderrExcerpt: result.stderrExcerpt,
|
||||
};
|
||||
|
||||
await this.store.saveRun(completedRun);
|
||||
|
||||
// Update cumulative usage on agent
|
||||
if (result.usageJson) {
|
||||
try {
|
||||
const agent = await this.store.getAgent(agentId);
|
||||
if (agent) {
|
||||
await this.store.updateAgent(agentId, {
|
||||
totalInputTokens: (agent.totalInputTokens ?? 0) + result.usageJson.inputTokens,
|
||||
totalOutputTokens: (agent.totalOutputTokens ?? 0) + result.usageJson.outputTokens,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, skip
|
||||
}
|
||||
}
|
||||
|
||||
// Transition agent state based on result
|
||||
try {
|
||||
if (result.status === "failed") {
|
||||
await this.store.updateAgentState(agentId, "error");
|
||||
await this.store.updateAgent(agentId, { lastError: result.stderrExcerpt ?? "Run failed" });
|
||||
} else if (result.status === "terminated") {
|
||||
await this.store.updateAgentState(agentId, "terminated");
|
||||
} else {
|
||||
// Completed successfully - back to active
|
||||
await this.store.updateAgentState(agentId, "active");
|
||||
}
|
||||
} catch {
|
||||
// State transition may fail if already in target state
|
||||
}
|
||||
|
||||
// End the heartbeat run tracking
|
||||
await this.store.endHeartbeatRun(runId, result.status === "completed" ? "completed" : "terminated");
|
||||
|
||||
this.onRunCompleted?.(agentId, completedRun);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an agent from monitoring.
|
||||
* Does NOT end the heartbeat run - caller's responsibility.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { AgentSemaphore } from "./concurrency.js";
|
||||
|
||||
// Mock external dependencies
|
||||
@@ -37,13 +37,10 @@ vi.mock("./logger.js", () => {
|
||||
hybridExecutorLog: createMockLogger(),
|
||||
};
|
||||
});
|
||||
vi.mock("./merger.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./merger.js")>();
|
||||
return {
|
||||
...actual,
|
||||
findWorktreeUser: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
});
|
||||
vi.mock("./merger.js", () => ({
|
||||
aiMergeTask: vi.fn(),
|
||||
findWorktreeUser: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock("./worktree-names.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./worktree-names.js")>("./worktree-names.js");
|
||||
return {
|
||||
@@ -62,6 +59,16 @@ vi.mock("node:fs", () => ({
|
||||
vi.mock("./rate-limit-retry.js", () => ({
|
||||
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-coding-agent", () => {
|
||||
const mockSessionManager = {};
|
||||
return {
|
||||
SessionManager: {
|
||||
create: vi.fn().mockReturnValue(mockSessionManager),
|
||||
open: vi.fn().mockReturnValue(mockSessionManager),
|
||||
inMemory: vi.fn().mockReturnValue(mockSessionManager),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { TaskExecutor, buildExecutionPrompt } from "./executor.js";
|
||||
import { createKbAgent } from "./pi.js";
|
||||
@@ -71,8 +78,10 @@ import { findWorktreeUser, aiMergeTask } from "./merger.js";
|
||||
import { WorktreePool } from "./worktree-pool.js";
|
||||
import { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
import type { Column, Task, TaskDetail } from "@fusion/core";
|
||||
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const mockedCreateHaiAgent = vi.mocked(createKbAgent);
|
||||
const mockedSessionManager = vi.mocked(SessionManager);
|
||||
const mockedGenerateWorktreeName = vi.mocked(generateWorktreeName);
|
||||
const mockedFindWorktreeUser = vi.mocked(findWorktreeUser);
|
||||
|
||||
@@ -438,7 +447,7 @@ describe("TaskExecutor worktree naming", () => {
|
||||
// The worktree path stored should use the generated name, not the task ID
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-030", {
|
||||
worktree: "/tmp/test/.worktrees/swift-falcon",
|
||||
branch: "kb/fn-030",
|
||||
branch: "fusion/fn-030",
|
||||
});
|
||||
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
|
||||
});
|
||||
@@ -490,7 +499,7 @@ describe("TaskExecutor worktree naming", () => {
|
||||
// Should use task ID (lowercase) as worktree name
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-042", {
|
||||
worktree: "/tmp/test/.worktrees/fn-042",
|
||||
branch: "kb/fn-042",
|
||||
branch: "fusion/fn-042",
|
||||
});
|
||||
// Should NOT call generateWorktreeName when using task-id
|
||||
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
||||
@@ -517,7 +526,7 @@ describe("TaskExecutor worktree naming", () => {
|
||||
const expectedSlug = slugify("Fix login bug with OAuth");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-043", {
|
||||
worktree: `/tmp/test/.worktrees/${expectedSlug}`,
|
||||
branch: "kb/fn-043",
|
||||
branch: "fusion/fn-043",
|
||||
});
|
||||
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -545,7 +554,7 @@ describe("TaskExecutor worktree naming", () => {
|
||||
const expectedSlug = slugify(taskDescription.slice(0, 60));
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-044", {
|
||||
worktree: `/tmp/test/.worktrees/${expectedSlug}`,
|
||||
branch: "kb/fn-044",
|
||||
branch: "fusion/fn-044",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -566,7 +575,7 @@ describe("TaskExecutor worktree naming", () => {
|
||||
// Should use generateWorktreeName for random mode
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-045", {
|
||||
worktree: "/tmp/test/.worktrees/swift-falcon",
|
||||
branch: "kb/fn-045",
|
||||
branch: "fusion/fn-045",
|
||||
});
|
||||
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
|
||||
});
|
||||
@@ -588,7 +597,7 @@ describe("TaskExecutor worktree naming", () => {
|
||||
// Should default to random naming
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-046", {
|
||||
worktree: "/tmp/test/.worktrees/swift-falcon",
|
||||
branch: "kb/fn-046",
|
||||
branch: "fusion/fn-046",
|
||||
});
|
||||
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
|
||||
});
|
||||
@@ -618,7 +627,7 @@ describe("TaskExecutor worktree naming", () => {
|
||||
// Should acquire from pool, ignoring the task-id naming preference
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-047", {
|
||||
worktree: "/tmp/test/.worktrees/pooled-warm-wt",
|
||||
branch: "kb/fn-047",
|
||||
branch: "fusion/fn-047",
|
||||
});
|
||||
// Should NOT call generateWorktreeName when using pooled worktree
|
||||
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
||||
@@ -646,6 +655,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
mockedGenerateWorktreeName.mockReturnValue("swift-falcon");
|
||||
@@ -657,6 +667,10 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("creates worktree successfully on first attempt", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
@@ -735,7 +749,10 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
const onError = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||||
|
||||
await executor.execute(makeTask());
|
||||
const executePromise = executor.execute(makeTask());
|
||||
// Advance past all retry delays (100 + 500 + 1000ms)
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await executePromise;
|
||||
|
||||
// Should log final failure
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
@@ -764,19 +781,19 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
if (command.includes("-b")) {
|
||||
// First attempt: createWithBranch fails with branch already exists
|
||||
const error: any = new Error(
|
||||
"fatal: A branch named 'kb/fn-050' already exists.",
|
||||
"fatal: A branch named 'fusion/fn-050' already exists.",
|
||||
);
|
||||
error.stderr = Buffer.from(
|
||||
"fatal: A branch named 'kb/fn-050' already exists.",
|
||||
"fatal: A branch named 'fusion/fn-050' already exists.",
|
||||
);
|
||||
throw error;
|
||||
} else {
|
||||
// Fallback createFromExistingBranch fails with already used
|
||||
const error: any = new Error(
|
||||
"fatal: 'kb/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
||||
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
||||
);
|
||||
error.stderr = Buffer.from(
|
||||
"fatal: 'kb/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
||||
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
@@ -803,18 +820,18 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
}
|
||||
if (command.includes("-b")) {
|
||||
const error: any = new Error(
|
||||
"fatal: A branch named 'kb/fn-050' already exists.",
|
||||
"fatal: A branch named 'fusion/fn-050' already exists.",
|
||||
);
|
||||
error.stderr = Buffer.from(
|
||||
"fatal: A branch named 'kb/fn-050' already exists.",
|
||||
"fatal: A branch named 'fusion/fn-050' already exists.",
|
||||
);
|
||||
throw error;
|
||||
} else {
|
||||
const error: any = new Error(
|
||||
"fatal: 'kb/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
||||
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
||||
);
|
||||
error.stderr = Buffer.from(
|
||||
"fatal: 'kb/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
||||
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
@@ -961,7 +978,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("Pruned stale worktree metadata"),
|
||||
"kb/fn-050",
|
||||
"fusion/fn-050",
|
||||
);
|
||||
// Should also call branch -D after prune
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
@@ -1063,7 +1080,9 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
|
||||
const onError = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||||
await executor.execute(makeTask());
|
||||
const executePromise = executor.execute(makeTask());
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await executePromise;
|
||||
|
||||
// Should have logged terminal failure for the stale reference
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
@@ -1278,8 +1297,10 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
mockedFindWorktreeUser.mockResolvedValue(null);
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -1294,7 +1315,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
await executor.execute(makeTask({
|
||||
id: "FN-060",
|
||||
baseBranch: "kb/fn-059",
|
||||
baseBranch: "fusion/fn-059",
|
||||
}));
|
||||
|
||||
// The git worktree add command should include the startPoint
|
||||
@@ -1302,7 +1323,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
||||
);
|
||||
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
||||
expect(worktreeAddCalls[0][0]).toContain("kb/fn-059");
|
||||
expect(worktreeAddCalls[0][0]).toContain("fusion/fn-059");
|
||||
});
|
||||
|
||||
it("creates worktree from HEAD when baseBranch is not set", async () => {
|
||||
@@ -1332,12 +1353,12 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
await executor.execute(makeTask({
|
||||
id: "FN-062",
|
||||
baseBranch: "kb/fn-061",
|
||||
baseBranch: "fusion/fn-061",
|
||||
}));
|
||||
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-062",
|
||||
expect.stringContaining("based on kb/fn-061"),
|
||||
expect.stringContaining("based on fusion/fn-061"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1367,10 +1388,10 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
if (typeof cmd === "string" && cmd.includes("git worktree add") && cmd.includes("-b") && firstAttempt) {
|
||||
firstAttempt = false;
|
||||
const err: any = new Error(
|
||||
`fatal: 'kb/fn-064' is already used by worktree at '${conflictingPath}'`,
|
||||
`fatal: 'fusion/fn-064' is already used by worktree at '${conflictingPath}'`,
|
||||
);
|
||||
err.stderr = Buffer.from(
|
||||
`fatal: 'kb/fn-064' is already used by worktree at '${conflictingPath}'`,
|
||||
`fatal: 'fusion/fn-064' is already used by worktree at '${conflictingPath}'`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
@@ -1384,7 +1405,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
expect.objectContaining({ cwd: "/tmp/test", stdio: "pipe" }),
|
||||
);
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
'git branch -D "kb/fn-064"',
|
||||
'git branch -D "fusion/fn-064"',
|
||||
expect.objectContaining({ cwd: "/tmp/test", stdio: "pipe" }),
|
||||
);
|
||||
|
||||
@@ -1399,6 +1420,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
});
|
||||
|
||||
it("throws original error if cleanup also fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
const conflictingPath = "/tmp/test/.worktrees/sharp-stone";
|
||||
@@ -1406,10 +1428,10 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (typeof cmd === "string" && cmd.includes("git worktree add") && cmd.includes("-b")) {
|
||||
const err: any = new Error(
|
||||
`fatal: 'kb/fn-065' is already used by worktree at '${conflictingPath}'`,
|
||||
`fatal: 'fusion/fn-065' is already used by worktree at '${conflictingPath}'`,
|
||||
);
|
||||
err.stderr = Buffer.from(
|
||||
`fatal: 'kb/fn-065' is already used by worktree at '${conflictingPath}'`,
|
||||
`fatal: 'fusion/fn-065' is already used by worktree at '${conflictingPath}'`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
@@ -1419,7 +1441,10 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await executor.execute(makeTask({ id: "FN-065" }));
|
||||
const executePromise = executor.execute(makeTask({ id: "FN-065" }));
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await executePromise;
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-065", {
|
||||
status: "failed",
|
||||
@@ -1434,7 +1459,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
||||
);
|
||||
|
||||
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockReturnValue("kb/fn-064");
|
||||
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockReturnValue("fusion/fn-064");
|
||||
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
@@ -1450,13 +1475,13 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
await executor.execute(makeTask({
|
||||
id: "FN-064",
|
||||
baseBranch: "kb/fn-063",
|
||||
baseBranch: "fusion/fn-063",
|
||||
}));
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledWith(
|
||||
"/tmp/test/.worktrees/idle-wt",
|
||||
"kb/fn-064",
|
||||
"kb/fn-063",
|
||||
"fusion/fn-064",
|
||||
"fusion/fn-063",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1467,7 +1492,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
||||
);
|
||||
|
||||
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockReturnValue("kb/fn-065");
|
||||
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockReturnValue("fusion/fn-065");
|
||||
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
@@ -1487,7 +1512,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledWith(
|
||||
"/tmp/test/.worktrees/idle-wt",
|
||||
"kb/fn-065",
|
||||
"fusion/fn-065",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -1500,7 +1525,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
);
|
||||
|
||||
// Pool returns a suffixed branch name due to conflict
|
||||
vi.spyOn(pool, "prepareForTask").mockReturnValue("kb/fn-066-2");
|
||||
vi.spyOn(pool, "prepareForTask").mockReturnValue("fusion/fn-066-2");
|
||||
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
@@ -1521,7 +1546,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
// Should store the suffixed branch name
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-066", {
|
||||
worktree: "/tmp/test/.worktrees/idle-wt",
|
||||
branch: "kb/fn-066-2",
|
||||
branch: "fusion/fn-066-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1770,117 +1795,29 @@ describe("Merger worktree pool integration", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function createMergerMockStore(overrides: Record<string, any> = {}) {
|
||||
const listeners = new Map<string, Function[]>();
|
||||
return {
|
||||
on: vi.fn((event: string, fn: Function) => {
|
||||
const existing = listeners.get(event) || [];
|
||||
existing.push(fn);
|
||||
listeners.set(event, existing);
|
||||
}),
|
||||
emit: vi.fn(),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "FN-050",
|
||||
title: "Test merge",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/test/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
moveTask: vi.fn().mockResolvedValue({
|
||||
id: "FN-050",
|
||||
column: "done",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
logEntry: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
recycleWorktrees: false,
|
||||
...overrides,
|
||||
}),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function mockMergerExecSync(cmd: any, opts?: any): any {
|
||||
const s = typeof cmd === "string" ? cmd : "";
|
||||
const isString = opts?.encoding === "utf-8";
|
||||
if (s.includes("rev-parse --verify")) return isString ? "abc123" : Buffer.from("abc123");
|
||||
if (s.includes("git log")) return isString ? "- test commit" : Buffer.from("- test commit");
|
||||
if (s.includes("git diff") && s.includes("--stat")) return isString ? "file.ts | 5 +++++" : Buffer.from("file.ts | 5 +++++");
|
||||
if (s.includes("diff --cached --quiet")) return isString ? "0" : Buffer.from("0");
|
||||
if (s.includes("diff --name-only --diff-filter=U")) return isString ? "" : Buffer.from("");
|
||||
return isString ? "" : Buffer.from("");
|
||||
}
|
||||
|
||||
it("releases worktree to pool instead of removing when recycleWorktrees is true", async () => {
|
||||
it("passes pool option through to aiMergeTask", async () => {
|
||||
const pool = new WorktreePool();
|
||||
const store = createMergerMockStore({ recycleWorktrees: true });
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
const mockedAiMergeTask = vi.mocked(aiMergeTask);
|
||||
mockedAiMergeTask.mockResolvedValue({
|
||||
task: { id: "FN-050" } as any,
|
||||
branch: "fusion/fn-050",
|
||||
merged: true,
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: true,
|
||||
});
|
||||
|
||||
mockedExecSync.mockImplementation(mockMergerExecSync);
|
||||
await aiMergeTask({} as any, "/tmp/test", "FN-050", { pool });
|
||||
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/test", "FN-050", { pool });
|
||||
|
||||
// Worktree should be in the pool, NOT removed
|
||||
expect(pool.has("/tmp/test/.worktrees/KB-050")).toBe(true);
|
||||
expect(result.worktreeRemoved).toBe(false);
|
||||
|
||||
// git worktree remove should NOT have been called
|
||||
const removeCalls = mockedExecSync.mock.calls.filter(
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
|
||||
expect(mockedAiMergeTask).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"/tmp/test",
|
||||
"FN-050",
|
||||
expect.objectContaining({ pool }),
|
||||
);
|
||||
expect(removeCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes worktree normally when recycleWorktrees is false", async () => {
|
||||
const pool = new WorktreePool();
|
||||
const store = createMergerMockStore({ recycleWorktrees: false });
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
mockedExecSync.mockImplementation(mockMergerExecSync);
|
||||
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/test", "FN-050", { pool });
|
||||
|
||||
// Worktree should NOT be in the pool
|
||||
expect(pool.size).toBe(0);
|
||||
expect(result.worktreeRemoved).toBe(true);
|
||||
|
||||
// git worktree remove should have been called
|
||||
const removeCalls = mockedExecSync.mock.calls.filter(
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
|
||||
);
|
||||
expect(removeCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
// Full merger worktree pool integration tests are in merger.test.ts
|
||||
// which tests aiMergeTask with real implementation
|
||||
});
|
||||
|
||||
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
@@ -2569,6 +2506,163 @@ describe("TaskExecutor pause behavior", () => {
|
||||
// Only one agent session created — the unpause during active session was a no-op
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses SessionManager.create for fresh execution and persists sessionFile", async () => {
|
||||
const store = createMockStore();
|
||||
const sessionFilePath = "/tmp/sessions/session_123.jsonl";
|
||||
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
sessionFile: sessionFilePath,
|
||||
} as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Fresh task",
|
||||
description: "Test fresh session",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Should use SessionManager.create for fresh execution
|
||||
expect(mockedSessionManager.create).toHaveBeenCalledWith(
|
||||
expect.stringContaining(".worktrees"),
|
||||
);
|
||||
expect(mockedSessionManager.open).not.toHaveBeenCalled();
|
||||
|
||||
// Should persist the session file path on the task
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { sessionFile: sessionFilePath });
|
||||
});
|
||||
|
||||
it("uses SessionManager.open to resume session when task has sessionFile", async () => {
|
||||
const store = createMockStore();
|
||||
const sessionFilePath = "/tmp/sessions/session_123.jsonl";
|
||||
const resumePromptFn = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// existsSync must return true for the session file
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: resumePromptFn,
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
sessionFile: sessionFilePath,
|
||||
} as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Resumed task",
|
||||
description: "Test session resume",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
sessionFile: sessionFilePath,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Should use SessionManager.open for the initial resumed execution
|
||||
expect(mockedSessionManager.open).toHaveBeenCalledWith(sessionFilePath);
|
||||
|
||||
// The first createKbAgent call should use the opened session manager
|
||||
const firstCall = mockedCreateHaiAgent.mock.calls[0][0] as any;
|
||||
expect(firstCall.sessionManager).toBeDefined();
|
||||
|
||||
// The log should indicate resume
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.stringContaining("Resumed agent session after unpause"),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves sessionFile when task is paused (graceful exit)", async () => {
|
||||
const store = createMockStore();
|
||||
const sessionFilePath = "/tmp/sessions/session_456.jsonl";
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Simulate pause — session ends gracefully
|
||||
store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" });
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
sessionFile: sessionFilePath,
|
||||
}) as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Pauseable task",
|
||||
description: "Test session file preserved on pause",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Session file should NOT be cleared when paused
|
||||
const clearCalls = store.updateTask.mock.calls.filter(
|
||||
(call: any[]) => call[0] === "FN-001" && call[1]?.sessionFile === null,
|
||||
);
|
||||
expect(clearCalls.length).toBe(0);
|
||||
|
||||
// Task should be moved to todo (ready for resume)
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
});
|
||||
|
||||
it("falls back to fresh session when sessionFile no longer exists on disk", async () => {
|
||||
const store = createMockStore();
|
||||
const staleSessionFile = "/tmp/sessions/deleted_session.jsonl";
|
||||
|
||||
// Session file does NOT exist on disk
|
||||
mockedExistsSync.mockImplementation(
|
||||
(p) => p !== staleSessionFile,
|
||||
);
|
||||
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
sessionFile: "/tmp/sessions/new_session.jsonl",
|
||||
} as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Stale session",
|
||||
description: "Test stale session file fallback",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
sessionFile: staleSessionFile,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Should fall back to SessionManager.create (not open)
|
||||
expect(mockedSessionManager.create).toHaveBeenCalled();
|
||||
expect(mockedSessionManager.open).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskExecutor global pause behavior", () => {
|
||||
@@ -4206,7 +4300,7 @@ describe("task_add_dep tool", () => {
|
||||
|
||||
// Branch deletion should have been attempted
|
||||
const branchDeleteCalls = mockedExecSync.mock.calls.filter(
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("branch -D") && (c[0] as string).includes("kb/fn-dep"),
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("branch -D") && (c[0] as string).includes("fusion/fn-dep"),
|
||||
);
|
||||
expect(branchDeleteCalls.length).toBeGreaterThan(0);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { createKbAgent, describeModel, promptWithFallback } from "./pi.js";
|
||||
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
|
||||
import type { ToolDefinition, AgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
|
||||
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
|
||||
import type { WorktreePool } from "./worktree-pool.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
@@ -431,7 +431,7 @@ export class TaskExecutor {
|
||||
}
|
||||
|
||||
// Create or reuse worktree — try pool first when recycling is enabled
|
||||
const branchName = `kb/${task.id.toLowerCase()}`;
|
||||
const branchName = `fusion/${task.id.toLowerCase()}`;
|
||||
// Use generateWorktreeName for human-friendly directory names (adjective-noun pattern)
|
||||
// instead of task.id, so worktrees are named like ".worktrees/swift-falcon"
|
||||
let isResume = existsSync(worktreePath);
|
||||
@@ -563,6 +563,7 @@ export class TaskExecutor {
|
||||
const codeReviewVerdicts = new Map<number, ReviewVerdict>();
|
||||
|
||||
let taskDone = false;
|
||||
let wasPaused = false;
|
||||
// Mutable ref — populated after createKbAgent, tools access lazily via closure
|
||||
const sessionRef: { current: AgentSession | null } = { current: null };
|
||||
const stepCheckpoints = new Map<number, string>();
|
||||
@@ -604,7 +605,15 @@ export class TaskExecutor {
|
||||
const executorFallbackProvider = settings.fallbackProvider;
|
||||
const executorFallbackModelId = settings.fallbackModelId;
|
||||
|
||||
let { session } = await createKbAgent({
|
||||
// Determine whether we're resuming a previous session (pause/resume)
|
||||
// or starting fresh. Use file-based sessions so conversation state
|
||||
// persists across pause/unpause cycles.
|
||||
const isResuming = !!task.sessionFile && existsSync(task.sessionFile);
|
||||
const sessionManager = isResuming
|
||||
? SessionManager.open(task.sessionFile!)
|
||||
: SessionManager.create(worktreePath);
|
||||
|
||||
let { session, sessionFile } = await createKbAgent({
|
||||
cwd: worktreePath,
|
||||
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
|
||||
tools: "coding",
|
||||
@@ -618,10 +627,20 @@ export class TaskExecutor {
|
||||
fallbackProvider: executorFallbackProvider,
|
||||
fallbackModelId: executorFallbackModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
sessionManager,
|
||||
});
|
||||
|
||||
executorLog.log(`${task.id}: using model ${describeModel(session)}`);
|
||||
await this.store.logEntry(task.id, `Executor using model: ${describeModel(session)}`);
|
||||
if (isResuming) {
|
||||
executorLog.log(`${task.id}: resumed session from ${task.sessionFile}`);
|
||||
await this.store.logEntry(task.id, `Resumed agent session after unpause (model: ${describeModel(session)})`);
|
||||
} else {
|
||||
executorLog.log(`${task.id}: using model ${describeModel(session)}`);
|
||||
await this.store.logEntry(task.id, `Executor using model: ${describeModel(session)}`);
|
||||
// Persist session file path so pause/resume can reopen it
|
||||
if (sessionFile) {
|
||||
await this.store.updateTask(task.id, { sessionFile });
|
||||
}
|
||||
}
|
||||
|
||||
// Make session available to custom tools (task_update checkpoint capture, review_step rewind)
|
||||
sessionRef.current = session;
|
||||
@@ -640,10 +659,21 @@ export class TaskExecutor {
|
||||
stuckDetector?.trackTask(task.id, session);
|
||||
|
||||
try {
|
||||
const agentPrompt = buildExecutionPrompt(detail, this.rootDir, settings);
|
||||
// Record activity on prompt start (heartbeat for stuck detection)
|
||||
stuckDetector?.recordActivity(task.id);
|
||||
await promptWithFallback(session, agentPrompt);
|
||||
|
||||
if (isResuming) {
|
||||
// Session already has full conversation history — just tell the
|
||||
// agent it was paused and should pick up where it left off.
|
||||
await promptWithFallback(session, [
|
||||
"Your session was paused and has now been resumed.",
|
||||
"Continue working on the task from where you left off.",
|
||||
"Review the current state of your worktree and proceed with the next pending step.",
|
||||
].join("\n"));
|
||||
} else {
|
||||
const agentPrompt = buildExecutionPrompt(detail, this.rootDir, settings);
|
||||
await promptWithFallback(session, agentPrompt);
|
||||
}
|
||||
|
||||
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
|
||||
// session.prompt() resolves normally even when retries are exhausted —
|
||||
@@ -662,8 +692,9 @@ export class TaskExecutor {
|
||||
// prompt to resolve gracefully instead of throwing.
|
||||
if (this.pausedAborted.has(task.id)) {
|
||||
this.pausedAborted.delete(task.id);
|
||||
wasPaused = true;
|
||||
executorLog.log(`${task.id} paused (graceful session exit) — moving to todo`);
|
||||
await this.store.logEntry(task.id, "Execution paused — agent terminated, moved to todo");
|
||||
await this.store.logEntry(task.id, "Execution paused — session preserved for resume, moved to todo");
|
||||
await this.store.moveTask(task.id, "todo");
|
||||
return;
|
||||
}
|
||||
@@ -708,7 +739,7 @@ export class TaskExecutor {
|
||||
this.activeSessions.delete(task.id);
|
||||
session.dispose();
|
||||
|
||||
const { session: retrySession } = await createKbAgent({
|
||||
const { session: retrySession, sessionFile: retrySessionFile } = await createKbAgent({
|
||||
cwd: worktreePath,
|
||||
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
|
||||
tools: "coding",
|
||||
@@ -722,7 +753,12 @@ export class TaskExecutor {
|
||||
fallbackProvider: executorFallbackProvider,
|
||||
fallbackModelId: executorFallbackModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
sessionManager: SessionManager.create(worktreePath),
|
||||
});
|
||||
// Update session file for the retry session (so pause/resume works)
|
||||
if (retrySessionFile) {
|
||||
this.store.updateTask(task.id, { sessionFile: retrySessionFile }).catch(() => {});
|
||||
}
|
||||
|
||||
// Reassign so finally{} disposes the correct session
|
||||
session = retrySession;
|
||||
@@ -778,6 +814,13 @@ export class TaskExecutor {
|
||||
stuckDetector?.untrackTask(task.id);
|
||||
await agentLogger.flush();
|
||||
session.dispose();
|
||||
// Clear session file when task completes or fails (not when paused —
|
||||
// the file is preserved so unpause can resume the conversation).
|
||||
// Check both the local flag (graceful exit) and the instance set
|
||||
// (error path where dispose caused prompt to throw).
|
||||
if (!wasPaused && !this.pausedAborted.has(task.id)) {
|
||||
this.store.updateTask(task.id, { sessionFile: null }).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1282,7 +1325,7 @@ export class TaskExecutor {
|
||||
|
||||
// Delete the branch — use stored branch name if available, fall back to convention
|
||||
const task = await this.store.getTask(taskId);
|
||||
const branch = task.branch || `kb/${taskId.toLowerCase()}`;
|
||||
const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
|
||||
try {
|
||||
execSync(`git branch -D "${branch}"`, { cwd: this.rootDir, stdio: "pipe" });
|
||||
} catch {
|
||||
|
||||
@@ -237,23 +237,23 @@ describe("aiMergeTask — task.branch field", () => {
|
||||
|
||||
it("uses task.branch when set instead of deriving from task ID", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", branch: "kb/fn-050-2", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
{ id: "FN-050", branch: "fusion/fn-050-2", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
// Should use kb/fn-050-2, not kb/fn-050
|
||||
expect(result.branch).toBe("kb/fn-050-2");
|
||||
expect(result.branch).toBe("fusion/fn-050-2");
|
||||
|
||||
// Verify the suffixed branch was verified and deleted
|
||||
const revParseCall = mockedExecSync.mock.calls.find(
|
||||
(call) => String(call[0]).includes("rev-parse --verify") && String(call[0]).includes("kb/fn-050-2"),
|
||||
(call) => String(call[0]).includes("rev-parse --verify") && String(call[0]).includes("fusion/fn-050-2"),
|
||||
);
|
||||
expect(revParseCall).toBeDefined();
|
||||
|
||||
const branchDeleteCall = mockedExecSync.mock.calls.find(
|
||||
(call) => String(call[0]).includes("branch -d") && String(call[0]).includes("kb/fn-050-2"),
|
||||
(call) => String(call[0]).includes("branch -d") && String(call[0]).includes("fusion/fn-050-2"),
|
||||
);
|
||||
expect(branchDeleteCall).toBeDefined();
|
||||
});
|
||||
@@ -266,7 +266,7 @@ describe("aiMergeTask — task.branch field", () => {
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(result.branch).toBe("kb/fn-050");
|
||||
expect(result.branch).toBe("fusion/fn-050");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -590,7 +590,7 @@ export async function aiMergeTask(
|
||||
throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`);
|
||||
}
|
||||
|
||||
const branch = task.branch || `kb/${taskId.toLowerCase()}`;
|
||||
const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
|
||||
const worktreePath = task.worktree;
|
||||
const result: MergeResult = {
|
||||
task,
|
||||
@@ -622,6 +622,36 @@ export async function aiMergeTask(
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3b. Ensure rootDir is on the main branch before merging.
|
||||
// Without this, a merge could land on whatever branch was last checked out,
|
||||
// causing feature code to be committed to the wrong lineage.
|
||||
try {
|
||||
const currentBranch = execSync("git symbolic-ref --short HEAD", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
}).trim();
|
||||
const mainBranch = execSync("git rev-parse --abbrev-ref origin/HEAD", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
}).trim().replace(/^origin\//, "");
|
||||
if (currentBranch !== mainBranch) {
|
||||
mergerLog.log(`${taskId}: rootDir on '${currentBranch}', checking out '${mainBranch}' before merge`);
|
||||
execSync(`git checkout "${mainBranch}"`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Fallback: try checking out main directly
|
||||
try {
|
||||
execSync("git checkout main", { cwd: rootDir, stdio: "pipe" });
|
||||
} catch {
|
||||
mergerLog.warn(`${taskId}: unable to verify/checkout main branch — proceeding on current HEAD`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Gather context for the agent (used in all attempts)
|
||||
let commitLog = "";
|
||||
let diffStat = "";
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
|
||||
export interface AgentResult {
|
||||
session: AgentSession;
|
||||
/** Path to the persisted session file (undefined for in-memory sessions). */
|
||||
sessionFile?: string;
|
||||
}
|
||||
|
||||
export interface PromptableSession extends AgentSession {
|
||||
@@ -77,6 +79,10 @@ export interface AgentOptions {
|
||||
fallbackModelId?: string;
|
||||
/** Default thinking effort level (e.g. "medium", "high"). When provided, sets the session's thinking level after creation. */
|
||||
defaultThinkingLevel?: string;
|
||||
/** Optional pre-configured SessionManager. When provided, the agent session
|
||||
* uses this instead of creating an in-memory session. Pass a file-based
|
||||
* SessionManager to enable session persistence and pause/resume. */
|
||||
sessionManager?: SessionManager;
|
||||
}
|
||||
|
||||
function resolveConfiguredModel(
|
||||
@@ -228,6 +234,8 @@ export async function createKbAgent(options: AgentOptions): Promise<AgentResult>
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const sessionManager = options.sessionManager ?? SessionManager.inMemory();
|
||||
|
||||
const createSessionWithModel = async (modelOverride?: typeof selectedModel) => {
|
||||
return createAgentSession({
|
||||
cwd: options.cwd,
|
||||
@@ -236,7 +244,7 @@ export async function createKbAgent(options: AgentOptions): Promise<AgentResult>
|
||||
resourceLoader,
|
||||
tools,
|
||||
customTools: options.customTools,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
...(modelOverride ? { model: modelOverride } : {}),
|
||||
});
|
||||
@@ -336,5 +344,5 @@ export async function createKbAgent(options: AgentOptions): Promise<AgentResult>
|
||||
}
|
||||
});
|
||||
|
||||
return { session: promptableSession };
|
||||
return { session: promptableSession, sessionFile: promptableSession.sessionFile };
|
||||
}
|
||||
|
||||
@@ -38,6 +38,16 @@ vi.mock("node:fs", () => ({
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
readFile: vi.fn().mockResolvedValue("# Task prompt content"),
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-coding-agent", () => {
|
||||
const mockSessionManager = {};
|
||||
return {
|
||||
SessionManager: {
|
||||
create: vi.fn().mockReturnValue(mockSessionManager),
|
||||
open: vi.fn().mockReturnValue(mockSessionManager),
|
||||
inMemory: vi.fn().mockReturnValue(mockSessionManager),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { TaskExecutor } from "./executor.js";
|
||||
import { TriageProcessor } from "./triage.js";
|
||||
|
||||
@@ -326,7 +326,7 @@ export class Scheduler {
|
||||
for (const depId of task.dependencies) {
|
||||
const dep = allTasks.find((t) => t.id === depId);
|
||||
if (dep && dep.column === "in-review" && dep.worktree) {
|
||||
return `fusion/${dep.id.toLowerCase()}`;
|
||||
return dep.branch || `fusion/${dep.id.toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ export class Scheduler {
|
||||
if (task.blockedBy) {
|
||||
const blocker = allTasks.find((t) => t.id === task.blockedBy);
|
||||
if (blocker && blocker.column === "in-review" && blocker.worktree) {
|
||||
return `kb/${blocker.id.toLowerCase()}`;
|
||||
return blocker.branch || `fusion/${blocker.id.toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
include: ["src/**/*.test.ts"],
|
||||
maxWorkers,
|
||||
fileParallelism: true,
|
||||
pool: "vmThreads",
|
||||
coverage: {
|
||||
enabled: false,
|
||||
reporter: ["text", "html", "json"],
|
||||
|
||||
Reference in New Issue
Block a user