From fd00719471e3a8dffaad527c32070dae67ddf460 Mon Sep 17 00:00:00 2001 From: FridayLiu Date: Fri, 17 Apr 2026 14:16:31 +0000 Subject: [PATCH 1/6] feat: add Agent Team orchestration (Light Brain & Heavy Brain) Implement cross-engine agent team orchestration with two modes: - Light Brain: deterministic DAG planning + parallel execution - Heavy Brain: LLM orchestrator loop with dynamic task dispatch Includes StructuredOutputSkill framework for format-safe LLM output, DAG executor with topological scheduling, gateway integration, minimal frontend with team trigger button and status bar. Co-Authored-By: Claude Opus 4.6 --- electron/main/gateway/ws-server.ts | 39 ++ electron/main/index.ts | 4 + .../main/services/agent-team/dag-executor.ts | 151 +++++++ .../main/services/agent-team/heavy-brain.ts | 172 ++++++++ electron/main/services/agent-team/index.ts | 209 +++++++++ .../main/services/agent-team/light-brain.ts | 135 ++++++ electron/main/services/agent-team/logger.ts | 2 + electron/main/services/agent-team/prompts.ts | 112 +++++ electron/main/services/agent-team/skills.ts | 406 ++++++++++++++++++ .../main/services/agent-team/task-executor.ts | 99 +++++ electron/main/services/logger.ts | 1 + src/components/PromptInput.tsx | 77 ++++ src/lib/gateway-api.ts | 32 ++ src/lib/gateway-client.ts | 24 ++ src/pages/Chat.tsx | 122 ++++++ src/stores/team.ts | 94 ++++ src/types/unified.ts | 89 ++++ .../services/agent-team/dag-executor.test.ts | 187 ++++++++ .../services/agent-team/skills.test.ts | 313 ++++++++++++++ 19 files changed, 2268 insertions(+) create mode 100644 electron/main/services/agent-team/dag-executor.ts create mode 100644 electron/main/services/agent-team/heavy-brain.ts create mode 100644 electron/main/services/agent-team/index.ts create mode 100644 electron/main/services/agent-team/light-brain.ts create mode 100644 electron/main/services/agent-team/logger.ts create mode 100644 electron/main/services/agent-team/prompts.ts create mode 100644 electron/main/services/agent-team/skills.ts create mode 100644 electron/main/services/agent-team/task-executor.ts create mode 100644 src/stores/team.ts create mode 100644 tests/unit/electron/services/agent-team/dag-executor.test.ts create mode 100644 tests/unit/electron/services/agent-team/skills.test.ts diff --git a/electron/main/gateway/ws-server.ts b/electron/main/gateway/ws-server.ts index a1789afd..87d79c9f 100644 --- a/electron/main/gateway/ws-server.ts +++ b/electron/main/gateway/ws-server.ts @@ -17,6 +17,7 @@ import { gatewayLog } from "../services/logger"; import log from "../services/logger"; import { conversationStore } from "../services/conversation-store"; import { scheduledTaskService } from "../services/scheduled-task-service"; +import { agentTeamService } from "../services/agent-team"; import { GatewayRequestType, GatewayNotificationType, @@ -40,6 +41,9 @@ import { type WorktreeRemoveRequest, type WorktreeMergeRequest, type WorktreeListBranchesRequest, + type TeamCreateRequest, + type TeamCancelRequest, + type TeamGetRequest, } from "../../../src/types/unified"; interface ClientConnection { @@ -499,6 +503,27 @@ export class GatewayServer { return worktreeManager.listBranches(req.directory); } + // --- Agent Team --- + + case GatewayRequestType.TEAM_CREATE: { + const req = p as TeamCreateRequest; + return agentTeamService.createRun(req); + } + + case GatewayRequestType.TEAM_CANCEL: { + const req = p as TeamCancelRequest; + return agentTeamService.cancelRun(req.runId); + } + + case GatewayRequestType.TEAM_LIST: { + return agentTeamService.listRuns(); + } + + case GatewayRequestType.TEAM_GET: { + const req = p as TeamGetRequest; + return agentTeamService.getRun(req.runId); + } + default: throw Object.assign( new Error(`Unknown request type: ${type}`), @@ -622,6 +647,20 @@ export class GatewayServer { payload: data, }); }); + + // Agent Team events + agentTeamService.on("team.run.updated", (data) => { + this.broadcast({ + type: GatewayNotificationType.TEAM_RUN_UPDATED, + payload: data, + }); + }); + agentTeamService.on("team.task.updated", (data) => { + this.broadcast({ + type: GatewayNotificationType.TEAM_TASK_UPDATED, + payload: data, + }); + }); } private broadcast(notification: GatewayNotification): void { diff --git a/electron/main/index.ts b/electron/main/index.ts index b856558a..bf6c9226 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -125,6 +125,10 @@ if (!gotTheLock) { // Initialize scheduled task service (persistent desktop-level scheduled tasks) scheduledTaskService.init(engineManager); + // Initialize agent team service (cross-engine orchestration) + const { agentTeamService } = await import("./services/agent-team"); + agentTeamService.init(engineManager); + // Register IPC handlers registerIpcHandlers(); diff --git a/electron/main/services/agent-team/dag-executor.ts b/electron/main/services/agent-team/dag-executor.ts new file mode 100644 index 00000000..897acad3 --- /dev/null +++ b/electron/main/services/agent-team/dag-executor.ts @@ -0,0 +1,151 @@ +// ============================================================================ +// DAG Executor — Deterministic parallel task scheduling +// Executes tasks in topological order, running independent tasks in parallel. +// Shared between Light Brain and Heavy Brain orchestrators. +// ============================================================================ + +import { EventEmitter } from "events"; +import type { TaskNode, TeamRun } from "../../../../src/types/unified"; +import { TaskExecutor } from "./task-executor"; + +export interface DAGExecutorEvents { + /** A task's status changed */ + "task.updated": (data: { runId: string; task: TaskNode }) => void; +} + +export declare interface DAGExecutor { + on(event: K, listener: DAGExecutorEvents[K]): this; + emit(event: K, ...args: Parameters): boolean; +} + +export class DAGExecutor extends EventEmitter { + constructor( + private taskExecutor: TaskExecutor, + private directory: string, + ) { + super(); + } + + /** + * Execute all ready tasks in the DAG. + * Runs in layers: find all tasks whose dependencies are satisfied, + * execute them in parallel, then repeat until no more tasks are runnable. + * + * @param run - The team run containing the task DAG + * @returns The tasks that were executed in this call + */ + async executeReadyTasks(run: TeamRun): Promise { + const executedTasks: TaskNode[] = []; + + while (true) { + const ready = this.findReadyTasks(run.tasks); + if (ready.length === 0) break; + + // Execute all ready tasks in parallel + const results = await Promise.allSettled( + ready.map((task) => this.runSingleTask(run, task)), + ); + + // Process results + for (let i = 0; i < ready.length; i++) { + const task = ready[i]; + const result = results[i]; + + if (result.status === "fulfilled") { + task.status = "completed"; + task.result = result.value.summary; + task.sessionId = result.value.sessionId; + if (result.value.error) { + task.status = "failed"; + task.error = result.value.error; + } + } else { + task.status = "failed"; + task.error = result.reason?.message ?? String(result.reason); + } + + task.time = { ...task.time, completed: Date.now() }; + this.emit("task.updated", { runId: run.id, task }); + executedTasks.push(task); + } + + // Propagate failures: mark downstream tasks as blocked + this.propagateFailures(run.tasks); + } + + return executedTasks; + } + + /** + * Find tasks that are ready to execute: + * - status is "pending" + * - all dependencies are "completed" + */ + private findReadyTasks(tasks: TaskNode[]): TaskNode[] { + return tasks.filter((task) => { + if (task.status !== "pending") return false; + return task.dependsOn.every((depId) => { + const dep = tasks.find((t) => t.id === depId); + return dep?.status === "completed"; + }); + }); + } + + /** + * Execute a single task with upstream context injection. + */ + private async runSingleTask(run: TeamRun, task: TaskNode) { + task.status = "running"; + task.time = { ...task.time, started: Date.now() }; + this.emit("task.updated", { runId: run.id, task }); + + // Gather upstream results for context injection + const dependencies = task.dependsOn + .map((depId) => run.tasks.find((t) => t.id === depId)) + .filter((t): t is TaskNode => t != null); + + const upstreamContext = TaskExecutor.buildUpstreamContext(dependencies); + + return this.taskExecutor.execute(task, this.directory, upstreamContext); + } + + /** + * Mark tasks as "blocked" if any of their dependencies failed. + */ + private propagateFailures(tasks: TaskNode[]): void { + let changed = true; + while (changed) { + changed = false; + for (const task of tasks) { + if (task.status !== "pending") continue; + + const hasFailedDep = task.dependsOn.some((depId) => { + const dep = tasks.find((t) => t.id === depId); + return dep?.status === "failed" || dep?.status === "blocked"; + }); + + if (hasFailedDep) { + task.status = "blocked"; + task.error = "Blocked by failed upstream task"; + changed = true; + } + } + } + } + + /** + * Check if the DAG execution is complete (no more pending/running tasks). + */ + static isComplete(tasks: TaskNode[]): boolean { + return tasks.every((t) => + t.status === "completed" || t.status === "failed" || t.status === "blocked" || t.status === "cancelled", + ); + } + + /** + * Check if all tasks completed successfully. + */ + static isAllSuccessful(tasks: TaskNode[]): boolean { + return tasks.every((t) => t.status === "completed"); + } +} diff --git a/electron/main/services/agent-team/heavy-brain.ts b/electron/main/services/agent-team/heavy-brain.ts new file mode 100644 index 00000000..ffcb3cdb --- /dev/null +++ b/electron/main/services/agent-team/heavy-brain.ts @@ -0,0 +1,172 @@ +// ============================================================================ +// Heavy Brain — Continuous LLM supervisor orchestration +// A long-running orchestrator session dispatches tasks and adapts dynamically. +// ============================================================================ + +import type { EngineManager } from "../../gateway/engine-manager"; +import type { TaskNode, TeamRun, EngineType } from "../../../../src/types/unified"; +import { DAGExecutor } from "./dag-executor"; +import { TaskExecutor, extractTextFromMessage } from "./task-executor"; +import { dispatchSkill, type DispatchInstruction, type DispatchTask } from "./skills"; +import { buildOrchestratorPrompt, formatTaskResults } from "./prompts"; +import { agentTeamLog } from "./logger"; + +/** Maximum orchestration iterations to prevent infinite loops */ +const MAX_ITERATIONS = 20; + +export class HeavyBrainOrchestrator { + private cancelled = false; + + constructor( + private engineManager: EngineManager, + private autoApproveSessions: Set, + ) {} + + /** + * Run Heavy Brain orchestration: + * 1. Create orchestrator session + * 2. Loop: orchestrator dispatches → execute → send results back + * 3. Until orchestrator signals "complete" or max iterations + */ + async run( + teamRun: TeamRun, + orchestratorEngineType: EngineType | undefined, + onTaskUpdated: (task: TaskNode) => void, + ): Promise { + const defaultEngineType = this.engineManager.getDefaultEngineType(); + const engineType = orchestratorEngineType || defaultEngineType; + + // --- Create orchestrator session --- + teamRun.status = "planning"; + agentTeamLog.info(`[${teamRun.id}] Heavy Brain: creating orchestrator session on ${engineType}`); + + const orchSession = await this.engineManager.createSession(engineType, teamRun.directory); + teamRun.orchestratorSessionId = orchSession.id; + this.registerAutoApprove(orchSession.id); + + const engines = this.engineManager.listEngines(); + const taskExecutor = new TaskExecutor( + this.engineManager, + this.autoApproveSessions, + defaultEngineType, + ); + const dagExecutor = new DAGExecutor(taskExecutor, teamRun.directory); + dagExecutor.on("task.updated", ({ task }) => onTaskUpdated(task)); + + // --- Initial prompt --- + const prompt = buildOrchestratorPrompt( + teamRun.originalPrompt, + engines, + teamRun.directory, + ); + + // First message: skill format + orchestrator prompt + const fullPrompt = `${dispatchSkill.formatPrompt}\n\n---\n\n${prompt}`; + let response = await this.engineManager.sendMessage(orchSession.id, [ + { type: "text", text: fullPrompt }, + ]); + + teamRun.status = "running"; + let iterations = 0; + let taskCounter = teamRun.tasks.length; + + // --- Orchestration loop --- + while (iterations++ < MAX_ITERATIONS && !this.cancelled) { + const responseText = extractTextFromMessage(response); + let instruction = dispatchSkill.parse(responseText); + + // If parse failed, try correction + if (!instruction.ok) { + agentTeamLog.warn( + `[${teamRun.id}] Heavy Brain: parse failed (attempt ${iterations}): ${instruction.error}`, + ); + const correction = dispatchSkill.correctionPrompt(responseText, instruction.error); + response = await this.engineManager.sendMessage(orchSession.id, [ + { type: "text", text: correction }, + ]); + instruction = dispatchSkill.parse(extractTextFromMessage(response)); + + if (!instruction.ok) { + agentTeamLog.error(`[${teamRun.id}] Heavy Brain: parse failed after correction: ${instruction.error}`); + teamRun.status = "failed"; + teamRun.finalResult = `Orchestrator output format error: ${instruction.error}`; + teamRun.time.completed = Date.now(); + return; + } + } + + const data = instruction.data; + + // --- Handle "complete" --- + if (data.action === "complete") { + teamRun.status = "completed"; + teamRun.finalResult = data.result; + teamRun.time.completed = Date.now(); + agentTeamLog.info(`[${teamRun.id}] Heavy Brain: orchestrator signaled complete`); + return; + } + + // --- Handle "dispatch" --- + if (data.action === "dispatch") { + // Add new tasks to the DAG + const newTasks = this.convertDispatchTasks(data.tasks, taskCounter); + taskCounter += newTasks.length; + teamRun.tasks.push(...newTasks); + + agentTeamLog.info( + `[${teamRun.id}] Heavy Brain: dispatching ${newTasks.length} tasks (iteration ${iterations})`, + ); + + // Execute ready tasks + await dagExecutor.executeReadyTasks(teamRun); + + // Send results back to orchestrator + const resultsText = formatTaskResults(teamRun); + response = await this.engineManager.sendMessage(orchSession.id, [ + { type: "text", text: resultsText }, + ]); + } + } + + // --- Max iterations or cancelled --- + if (this.cancelled) { + teamRun.status = "cancelled"; + teamRun.finalResult = "Orchestration was cancelled."; + } else { + teamRun.status = "failed"; + teamRun.finalResult = `Orchestration exceeded maximum iterations (${MAX_ITERATIONS}).`; + } + teamRun.time.completed = Date.now(); + agentTeamLog.info(`[${teamRun.id}] Heavy Brain: ${teamRun.status}`); + } + + /** + * Signal cancellation of the orchestration loop. + */ + cancel(): void { + this.cancelled = true; + } + + /** + * Convert dispatch tasks to TaskNode format. + */ + private convertDispatchTasks(tasks: DispatchTask[], startIndex: number): TaskNode[] { + return tasks.map((t, i): TaskNode => ({ + id: t.id || `task_${startIndex + i}`, + description: t.description, + prompt: t.prompt, + engineType: t.engineType as EngineType | undefined, + dependsOn: t.dependsOn || [], + status: "pending", + })); + } + + private registerAutoApprove(sessionId: string): void { + if (this.autoApproveSessions.size > 200) { + const recent = [...this.autoApproveSessions].slice(-100); + this.autoApproveSessions.clear(); + for (const id of recent) this.autoApproveSessions.add(id); + } + this.autoApproveSessions.add(sessionId); + } +} diff --git a/electron/main/services/agent-team/index.ts b/electron/main/services/agent-team/index.ts new file mode 100644 index 00000000..43ab3b85 --- /dev/null +++ b/electron/main/services/agent-team/index.ts @@ -0,0 +1,209 @@ +// ============================================================================ +// Agent Team Service — Singleton orchestration service +// Manages team runs (Light Brain and Heavy Brain) with auto-approve permissions. +// Follows the same pattern as ScheduledTaskService. +// ============================================================================ + +import { EventEmitter } from "events"; +import { timeId } from "../../utils/id-gen"; +import { agentTeamLog } from "./logger"; +import { LightBrainOrchestrator } from "./light-brain"; +import { HeavyBrainOrchestrator } from "./heavy-brain"; +import type { EngineManager } from "../../gateway/engine-manager"; +import type { + TeamRun, + TeamCreateRequest, + TaskNode, + EngineType, +} from "../../../../src/types/unified"; + +// --- Event types --- + +export interface AgentTeamServiceEvents { + "team.run.updated": (data: { run: TeamRun }) => void; + "team.task.updated": (data: { runId: string; task: TaskNode }) => void; +} + +export declare interface AgentTeamService { + on(event: K, listener: AgentTeamServiceEvents[K]): this; + off(event: K, listener: AgentTeamServiceEvents[K]): this; + emit(event: K, ...args: Parameters): boolean; +} + +// --- Service --- + +export class AgentTeamService extends EventEmitter { + private engineManager: EngineManager | null = null; + private runs = new Map(); + /** Session IDs created by team runs — auto-approve permissions for these. */ + private autoApproveSessions = new Set(); + /** Active Heavy Brain orchestrators (for cancellation). */ + private activeOrchestrators = new Map(); + private initialized = false; + + // --- Lifecycle --- + + init(engineManager: EngineManager): void { + if (this.initialized) return; + this.engineManager = engineManager; + this.subscribePermissionAutoApprove(); + this.initialized = true; + agentTeamLog.info("Agent Team Service initialized"); + } + + async shutdown(): Promise { + // Cancel all running orchestrators + for (const [runId, orchestrator] of this.activeOrchestrators) { + agentTeamLog.info(`Cancelling orchestrator for run ${runId}`); + orchestrator.cancel(); + } + this.activeOrchestrators.clear(); + this.autoApproveSessions.clear(); + this.initialized = false; + agentTeamLog.info("Agent Team Service shut down"); + } + + // --- Auto-approve (same pattern as ScheduledTaskService) --- + + private subscribePermissionAutoApprove(): void { + if (!this.engineManager) return; + + this.engineManager.on("permission.asked", (data: any) => { + const permission = data.permission ?? data; + const sessionId = permission.sessionId; + if (!sessionId || !this.autoApproveSessions.has(sessionId)) return; + + const acceptOption = permission.options?.find( + (o: any) => + o.type?.includes("accept") || + o.type?.includes("allow") || + o.label?.toLowerCase().includes("allow"), + ); + + if (acceptOption) { + agentTeamLog.info(`Auto-approving permission ${permission.id} for session ${sessionId}`); + this.engineManager!.replyPermission(permission.id, { optionId: acceptOption.id }); + } + }); + } + + // --- CRUD --- + + /** + * Create and start a new team run. + * Returns immediately with the run in "planning" status. + * Execution happens asynchronously. + */ + async createRun(req: TeamCreateRequest): Promise { + if (!this.engineManager) { + throw new Error("AgentTeamService not initialized"); + } + + const run: TeamRun = { + id: timeId("team"), + parentSessionId: req.sessionId, + directory: req.directory, + originalPrompt: req.prompt, + mode: req.mode, + status: "planning", + tasks: [], + time: { created: Date.now() }, + }; + + this.runs.set(run.id, run); + this.emitRunUpdated(run); + + agentTeamLog.info(`Created team run ${run.id} (${run.mode} brain)`); + + // Start execution asynchronously + void this.executeRun(run, req.engineType as EngineType | undefined).catch((err) => { + agentTeamLog.error(`Team run ${run.id} failed:`, err); + run.status = "failed"; + run.finalResult = `Orchestration error: ${err.message}`; + run.time.completed = Date.now(); + this.emitRunUpdated(run); + }); + + return run; + } + + /** + * Cancel a running team run. + */ + async cancelRun(runId: string): Promise { + const run = this.runs.get(runId); + if (!run) throw new Error(`Team run not found: ${runId}`); + + // Cancel heavy brain orchestrator if active + const orchestrator = this.activeOrchestrators.get(runId); + if (orchestrator) { + orchestrator.cancel(); + this.activeOrchestrators.delete(runId); + } + + // Cancel all running child sessions + for (const task of run.tasks) { + if (task.sessionId && task.status === "running") { + try { + await this.engineManager?.cancelMessage(task.sessionId); + } catch { + // Best-effort + } + task.status = "cancelled"; + } + if (task.status === "pending") { + task.status = "cancelled"; + } + } + + run.status = "cancelled"; + run.time.completed = Date.now(); + this.emitRunUpdated(run); + agentTeamLog.info(`Cancelled team run ${runId}`); + } + + listRuns(): TeamRun[] { + return Array.from(this.runs.values()); + } + + getRun(runId: string): TeamRun | null { + return this.runs.get(runId) ?? null; + } + + // --- Execution --- + + private async executeRun(run: TeamRun, orchestratorEngineType?: EngineType): Promise { + const onTaskUpdated = (task: TaskNode) => { + this.emit("team.task.updated", { runId: run.id, task }); + this.emitRunUpdated(run); + }; + + if (run.mode === "light") { + const orchestrator = new LightBrainOrchestrator( + this.engineManager!, + this.autoApproveSessions, + ); + await orchestrator.run(run, onTaskUpdated); + } else { + const orchestrator = new HeavyBrainOrchestrator( + this.engineManager!, + this.autoApproveSessions, + ); + this.activeOrchestrators.set(run.id, orchestrator); + try { + await orchestrator.run(run, orchestratorEngineType, onTaskUpdated); + } finally { + this.activeOrchestrators.delete(run.id); + } + } + + this.emitRunUpdated(run); + } + + private emitRunUpdated(run: TeamRun): void { + this.emit("team.run.updated", { run }); + } +} + +/** Singleton instance */ +export const agentTeamService = new AgentTeamService(); diff --git a/electron/main/services/agent-team/light-brain.ts b/electron/main/services/agent-team/light-brain.ts new file mode 100644 index 00000000..ce0e2f20 --- /dev/null +++ b/electron/main/services/agent-team/light-brain.ts @@ -0,0 +1,135 @@ +// ============================================================================ +// Light Brain — Deterministic DAG orchestration +// One LLM call to generate the DAG, then a Node.js state machine executes it. +// ============================================================================ + +import type { EngineManager } from "../../gateway/engine-manager"; +import type { TaskNode, TeamRun, EngineType, EngineInfo } from "../../../../src/types/unified"; +import { DAGExecutor } from "./dag-executor"; +import { TaskExecutor, extractTextFromMessage } from "./task-executor"; +import { dagPlanningSkill, executeWithSkill, type RawTaskNode } from "./skills"; +import { buildPlanningPrompt } from "./prompts"; +import { agentTeamLog } from "./logger"; + +export class LightBrainOrchestrator { + constructor( + private engineManager: EngineManager, + private autoApproveSessions: Set, + ) {} + + /** + * Run Light Brain orchestration: + * 1. Planning call (LLM generates DAG) + * 2. Deterministic execution (DAG state machine) + */ + async run( + teamRun: TeamRun, + onTaskUpdated: (task: TaskNode) => void, + ): Promise { + const defaultEngineType = this.engineManager.getDefaultEngineType(); + const plannerEngineType = (teamRun.mode === "light" ? defaultEngineType : defaultEngineType) as EngineType; + + // --- Phase 1: Planning --- + teamRun.status = "planning"; + agentTeamLog.info(`[${teamRun.id}] Light Brain: planning phase`); + + const engines = this.engineManager.listEngines(); + const tasks = await this.generateDAG(teamRun, plannerEngineType, engines); + + // Convert raw tasks to TaskNodes + teamRun.tasks = tasks.map((raw): TaskNode => ({ + id: raw.id, + description: raw.description, + prompt: raw.prompt, + engineType: raw.engineType as EngineType | undefined, + dependsOn: raw.dependsOn, + status: "pending", + })); + + agentTeamLog.info(`[${teamRun.id}] Light Brain: DAG generated with ${teamRun.tasks.length} tasks`); + + // --- Phase 2: Execution --- + teamRun.status = "running"; + + const taskExecutor = new TaskExecutor( + this.engineManager, + this.autoApproveSessions, + defaultEngineType, + ); + const dagExecutor = new DAGExecutor(taskExecutor, teamRun.directory); + + // Forward task update events + dagExecutor.on("task.updated", ({ task }) => onTaskUpdated(task)); + + await dagExecutor.executeReadyTasks(teamRun); + + // --- Phase 3: Determine final status --- + if (DAGExecutor.isAllSuccessful(teamRun.tasks)) { + teamRun.status = "completed"; + teamRun.finalResult = this.synthesizeResult(teamRun); + } else { + teamRun.status = "failed"; + const failed = teamRun.tasks.filter((t) => t.status === "failed"); + teamRun.finalResult = `${failed.length} task(s) failed: ${failed.map((t) => t.description).join(", ")}`; + } + + teamRun.time.completed = Date.now(); + agentTeamLog.info(`[${teamRun.id}] Light Brain: ${teamRun.status}`); + } + + /** + * Generate the task DAG using a planning LLM call with dagPlanningSkill. + */ + private async generateDAG( + teamRun: TeamRun, + plannerEngineType: EngineType, + engines: EngineInfo[], + ): Promise { + // Create a temporary planning session + const planSession = await this.engineManager.createSession( + plannerEngineType, + teamRun.directory, + ); + + // Register for auto-approve + if (this.autoApproveSessions.size > 200) { + const recent = [...this.autoApproveSessions].slice(-100); + this.autoApproveSessions.clear(); + for (const id of recent) this.autoApproveSessions.add(id); + } + this.autoApproveSessions.add(planSession.id); + + // Build planning prompt + const prompt = buildPlanningPrompt( + teamRun.originalPrompt, + engines, + teamRun.directory, + ); + + // Execute with skill (includes format spec + self-check + retry) + const sendMessage = async (text: string): Promise => { + const msg = await this.engineManager.sendMessage(planSession.id, [ + { type: "text", text }, + ]); + return extractTextFromMessage(msg); + }; + + const result = await executeWithSkill(sendMessage, prompt, dagPlanningSkill); + + if (!result.ok) { + throw new Error(`DAG planning failed: ${result.error}`); + } + + return result.data.tasks; + } + + /** + * Simple synthesis: concatenate all task results. + */ + private synthesizeResult(teamRun: TeamRun): string { + const results = teamRun.tasks + .filter((t) => t.status === "completed" && t.result) + .map((t) => `[${t.description}]: ${t.result}`); + return results.join("\n\n"); + } +} diff --git a/electron/main/services/agent-team/logger.ts b/electron/main/services/agent-team/logger.ts new file mode 100644 index 00000000..64be83b6 --- /dev/null +++ b/electron/main/services/agent-team/logger.ts @@ -0,0 +1,2 @@ +// Re-export the agent-team logger scope from the shared logger module +export { agentTeamLog } from "../logger"; diff --git a/electron/main/services/agent-team/prompts.ts b/electron/main/services/agent-team/prompts.ts new file mode 100644 index 00000000..f36f5ca4 --- /dev/null +++ b/electron/main/services/agent-team/prompts.ts @@ -0,0 +1,112 @@ +// ============================================================================ +// Prompt Templates — Engine-agnostic prompts for agent team orchestration +// ============================================================================ + +import type { EngineInfo, TeamRun } from "../../../../src/types/unified"; + +/** + * Format available engines list for inclusion in prompts. + */ +export function formatEngineList(engines: EngineInfo[]): string { + const running = engines.filter((e) => e.status === "running"); + if (running.length === 0) return "No engines currently available."; + + return running + .map((e) => `- ${e.type}: ${e.name}${e.version ? ` v${e.version}` : ""}`) + .join("\n"); +} + +/** + * Build the planning prompt for Light Brain DAG generation. + * The dagPlanningSkill.formatPrompt is prepended by executeWithSkill(). + */ +export function buildPlanningPrompt( + userRequest: string, + engines: EngineInfo[], + directory: string, +): string { + return `You are a task planner for a multi-engine AI coding assistant. Your job is to decompose the user's request into a directed acyclic graph (DAG) of subtasks that can be executed by different AI engines. + +## Available Engines +${formatEngineList(engines)} + +## Project Directory +${directory} + +## Guidelines +- Break complex tasks into smaller, focused subtasks +- Use dependsOn to express task ordering (parallel tasks have no dependency) +- Each task's prompt must be self-contained — the worker agent cannot see other tasks or the original request +- Include enough context in each prompt for the worker to succeed independently +- If a task produces output needed by a downstream task, mention what the downstream task should expect in its prompt +- Choose engines based on their strengths if applicable, or omit engineType to use the default + +## User Request +${userRequest}`; +} + +/** + * Build the initial prompt for Heavy Brain orchestrator. + * The dispatchSkill.formatPrompt is prepended by executeWithSkill(). + */ +export function buildOrchestratorPrompt( + userRequest: string, + engines: EngineInfo[], + directory: string, +): string { + return `You are an orchestration supervisor managing a team of AI coding agents. You break down complex tasks, dispatch them to worker agents, review results, and coordinate follow-up work. + +## Available Engines +${formatEngineList(engines)} + +## Project Directory +${directory} + +## How It Works +1. You dispatch tasks to worker agents using the JSON protocol above +2. After workers complete, you receive their results as the next message +3. Review results and decide: dispatch more tasks, or mark complete +4. You can dispatch multiple rounds of tasks — each round can depend on prior results + +## Guidelines +- Start by analyzing the request and dispatching initial tasks +- Each worker runs in an isolated session — include all necessary context in the prompt +- If a worker fails, you can retry with a modified prompt or different engine +- When all work is done, use the "complete" action with a comprehensive summary + +## User Request +${userRequest}`; +} + +/** + * Format task results for injection back into the orchestrator (Heavy Brain). + */ +export function formatTaskResults(run: TeamRun): string { + const lines: string[] = ["## Task Execution Results\n"]; + + for (const task of run.tasks) { + if (task.status === "completed") { + const duration = task.time?.started && task.time?.completed + ? `${((task.time.completed - task.time.started) / 1000).toFixed(1)}s` + : ""; + lines.push(`### ${task.id}: "${task.description}" [COMPLETED]${duration ? ` (${duration})` : ""}`); + lines.push(task.result || "(no output)"); + lines.push(""); + } else if (task.status === "failed") { + lines.push(`### ${task.id}: "${task.description}" [FAILED]`); + lines.push(`Error: ${task.error || "unknown error"}`); + lines.push(""); + } else if (task.status === "blocked") { + lines.push(`### ${task.id}: "${task.description}" [BLOCKED]`); + lines.push(`Blocked by failed upstream task.`); + lines.push(""); + } + } + + const completed = run.tasks.filter((t) => t.status === "completed").length; + const failed = run.tasks.filter((t) => t.status === "failed").length; + const total = run.tasks.length; + lines.push(`---\n${completed}/${total} tasks completed${failed > 0 ? `, ${failed} failed` : ""}. What would you like to do next?`); + + return lines.join("\n"); +} diff --git a/electron/main/services/agent-team/skills.ts b/electron/main/services/agent-team/skills.ts new file mode 100644 index 00000000..0f2807b5 --- /dev/null +++ b/electron/main/services/agent-team/skills.ts @@ -0,0 +1,406 @@ +// ============================================================================ +// Structured Output Skills — format spec + self-check + parser bundles +// Injected into agent prompts so agents self-validate before outputting. +// Parser is a safety net; correctionPrompt handles the rare parse failure. +// ============================================================================ + +// --- Skill Framework --- + +/** + * A structured output skill bundles format specification, parser, and + * correction logic for a specific JSON output format. + * + * The formatPrompt is injected into the agent's prompt so it knows the + * expected schema and self-checks before outputting. The parser extracts + * the typed result from raw LLM text. On parse failure, correctionPrompt + * generates a follow-up message to let the agent fix its output in-place + * (same session, no new session needed). + */ +export interface StructuredOutputSkill { + /** Skill name for logging */ + name: string; + + /** Prompt instructions injected into the session: format spec + self-check checklist */ + formatPrompt: string; + + /** Parse LLM text output into T. Returns null on failure with error detail. */ + parse(text: string): { ok: true; data: T } | { ok: false; error: string }; + + /** Generate a correction prompt when parse fails */ + correctionPrompt(rawText: string, error: string): string; +} + +// --- JSON Extraction Utility --- + +/** + * Extract JSON objects/arrays from LLM text output. + * Handles: ```json fenced blocks, bare JSON, and markdown-wrapped JSON. + */ +export function extractJsonBlocks(text: string): string[] { + const blocks: string[] = []; + + // 1. Match ```json ... ``` fenced code blocks + const fenceRegex = /```(?:json)?\s*\n?([\s\S]*?)```/g; + let match: RegExpExecArray | null; + while ((match = fenceRegex.exec(text)) !== null) { + const content = match[1].trim(); + if (content.startsWith("{") || content.startsWith("[")) { + blocks.push(content); + } + } + + // 2. If no fenced blocks found, try to find bare JSON + if (blocks.length === 0) { + // Match outermost { ... } or [ ... ] + const bareRegex = /(\{[\s\S]*\}|\[[\s\S]*\])/g; + while ((match = bareRegex.exec(text)) !== null) { + blocks.push(match[1].trim()); + } + } + + return blocks; +} + +/** + * Try to parse the first valid JSON from extracted blocks. + */ +export function parseFirstJson(text: string): { ok: true; data: T } | { ok: false; error: string } { + const blocks = extractJsonBlocks(text); + if (blocks.length === 0) { + return { ok: false, error: "No JSON block found in output. Expected a ```json code block." }; + } + + const errors: string[] = []; + for (const block of blocks) { + try { + const parsed = JSON.parse(block) as T; + return { ok: true, data: parsed }; + } catch (e) { + errors.push(`JSON parse error: ${(e as Error).message}`); + } + } + + return { ok: false, error: errors.join("; ") }; +} + +// --- DAG Planning Skill (Light Brain) --- + +/** Raw task node as output by the planning LLM */ +export interface RawTaskNode { + id: string; + description: string; + prompt: string; + dependsOn: string[]; + engineType?: string; +} + +interface DagPlanOutput { + tasks: RawTaskNode[]; +} + +function validateDagPlan(data: unknown): { ok: true; data: DagPlanOutput } | { ok: false; error: string } { + if (!data || typeof data !== "object") { + return { ok: false, error: "Expected a JSON object with a 'tasks' array." }; + } + + const obj = data as Record; + if (!Array.isArray(obj.tasks)) { + return { ok: false, error: "Missing 'tasks' array in output." }; + } + + const tasks = obj.tasks as unknown[]; + if (tasks.length === 0) { + return { ok: false, error: "Task list is empty. At least one task is required." }; + } + + const ids = new Set(); + const errors: string[] = []; + + for (let i = 0; i < tasks.length; i++) { + const t = tasks[i] as Record; + if (!t.id || typeof t.id !== "string") { + errors.push(`Task [${i}]: missing or invalid 'id'`); + continue; + } + if (ids.has(t.id)) { + errors.push(`Task [${i}]: duplicate id '${t.id}'`); + } + ids.add(t.id); + + if (!t.description || typeof t.description !== "string") { + errors.push(`Task '${t.id}': missing 'description'`); + } + if (!t.prompt || typeof t.prompt !== "string") { + errors.push(`Task '${t.id}': missing 'prompt'`); + } + if (!Array.isArray(t.dependsOn)) { + errors.push(`Task '${t.id}': missing 'dependsOn' array`); + } + } + + if (errors.length > 0) { + return { ok: false, error: errors.join("; ") }; + } + + // Validate dependency references + for (const t of tasks as RawTaskNode[]) { + for (const dep of t.dependsOn) { + if (!ids.has(dep)) { + errors.push(`Task '${t.id}': dependsOn references unknown task '${dep}'`); + } + } + } + + // Check for circular dependencies + const visited = new Set(); + const inStack = new Set(); + const taskMap = new Map((tasks as RawTaskNode[]).map((t) => [t.id, t])); + + function hasCycle(id: string): boolean { + if (inStack.has(id)) return true; + if (visited.has(id)) return false; + visited.add(id); + inStack.add(id); + const task = taskMap.get(id); + if (task) { + for (const dep of task.dependsOn) { + if (hasCycle(dep)) return true; + } + } + inStack.delete(id); + return false; + } + + for (const id of ids) { + if (hasCycle(id)) { + errors.push("Circular dependency detected in task DAG"); + break; + } + } + + // Check for at least one root task + const hasRoot = (tasks as RawTaskNode[]).some((t) => t.dependsOn.length === 0); + if (!hasRoot) { + errors.push("No root task found (at least one task must have dependsOn: [])"); + } + + if (errors.length > 0) { + return { ok: false, error: errors.join("; ") }; + } + + return { ok: true, data: { tasks: tasks as RawTaskNode[] } }; +} + +export const dagPlanningSkill: StructuredOutputSkill = { + name: "dag-planning", + + formatPrompt: ` +## Output Format Requirements + +You MUST output your task plan as a single JSON code block with the following schema: + +\`\`\`json +{ + "tasks": [ + { + "id": "string (unique, e.g. t1, t2)", + "description": "string (1-sentence summary of the task)", + "prompt": "string (detailed, self-contained instructions for the worker agent)", + "dependsOn": ["array of task IDs this task depends on, use [] if none"], + "engineType": "optional string: claude | copilot | opencode (omit to use default)" + } + ] +} +\`\`\` + +## Self-Check Before Outputting (MANDATORY) + +Before writing the JSON block, verify ALL of the following: +1. JSON syntax is valid (balanced braces, proper quoting, no trailing commas) +2. Every task ID is unique +3. Every ID referenced in dependsOn exists in the task list +4. No circular dependency chains (e.g. A depends on B, B depends on A) +5. Each prompt is self-contained — the worker agent CANNOT see other tasks or the original request +6. At least one task has dependsOn: [] (the DAG must have a root) +7. Output ONLY the JSON block — no additional text before or after +`.trim(), + + parse(text: string) { + const jsonResult = parseFirstJson(text); + if (!jsonResult.ok) return jsonResult; + return validateDagPlan(jsonResult.data); + }, + + correctionPrompt(rawText: string, error: string) { + return ( + `Your previous output had a format error:\n${error}\n\n` + + `Please output ONLY the corrected JSON block following the schema above. ` + + `Do not include any explanation — just the valid JSON.` + ); + }, +}; + +// --- Dispatch Skill (Heavy Brain) --- + +export interface DispatchTask { + id: string; + description: string; + prompt: string; + engineType?: string; + dependsOn?: string[]; +} + +export type DispatchInstruction = + | { action: "dispatch"; tasks: DispatchTask[] } + | { action: "complete"; result: string }; + +function validateDispatchInstruction( + data: unknown, +): { ok: true; data: DispatchInstruction } | { ok: false; error: string } { + if (!data || typeof data !== "object") { + return { ok: false, error: "Expected a JSON object with 'action' field." }; + } + + const obj = data as Record; + + if (obj.action === "complete") { + if (!obj.result || typeof obj.result !== "string") { + return { ok: false, error: "action 'complete' requires a 'result' string." }; + } + return { ok: true, data: { action: "complete", result: obj.result } }; + } + + if (obj.action === "dispatch") { + if (!Array.isArray(obj.tasks) || obj.tasks.length === 0) { + return { ok: false, error: "action 'dispatch' requires a non-empty 'tasks' array." }; + } + + const errors: string[] = []; + const ids = new Set(); + for (let i = 0; i < obj.tasks.length; i++) { + const t = obj.tasks[i] as Record; + if (!t.id || typeof t.id !== "string") { + errors.push(`Task [${i}]: missing 'id'`); + continue; + } + if (ids.has(t.id)) { + errors.push(`Task [${i}]: duplicate id '${t.id}'`); + } + ids.add(t.id); + if (!t.description || typeof t.description !== "string") { + errors.push(`Task '${t.id}': missing 'description'`); + } + if (!t.prompt || typeof t.prompt !== "string") { + errors.push(`Task '${t.id}': missing 'prompt'`); + } + } + + if (errors.length > 0) { + return { ok: false, error: errors.join("; ") }; + } + + return { + ok: true, + data: { + action: "dispatch", + tasks: obj.tasks as DispatchTask[], + }, + }; + } + + return { + ok: false, + error: `Unknown action '${String(obj.action)}'. Expected 'dispatch' or 'complete'.`, + }; +} + +export const dispatchSkill: StructuredOutputSkill = { + name: "orchestrator-dispatch", + + formatPrompt: ` +## Communication Protocol + +You MUST communicate your decisions via JSON code blocks. Two actions are available: + +### 1. Dispatch tasks to worker agents: +\`\`\`json +{ + "action": "dispatch", + "tasks": [ + { + "id": "unique_id", + "description": "1-sentence summary", + "prompt": "detailed, self-contained instructions for the worker agent", + "engineType": "optional: claude | copilot | opencode" + } + ] +} +\`\`\` + +### 2. Mark orchestration as complete: +\`\`\`json +{ + "action": "complete", + "result": "Summary of everything accomplished by the team..." +} +\`\`\` + +## Self-Check Before Outputting (MANDATORY) + +1. JSON syntax is valid +2. action is either "dispatch" or "complete" +3. If dispatch: every task has id, description, and a detailed self-contained prompt +4. If complete: result contains a meaningful summary of all work done +5. Output ONLY the JSON block — no additional text before or after +`.trim(), + + parse(text: string) { + const jsonResult = parseFirstJson(text); + if (!jsonResult.ok) return jsonResult; + return validateDispatchInstruction(jsonResult.data); + }, + + correctionPrompt(rawText: string, error: string) { + return ( + `Your previous output had a format error:\n${error}\n\n` + + `Please output ONLY the corrected JSON block. Use either ` + + `{ "action": "dispatch", "tasks": [...] } or { "action": "complete", "result": "..." }.` + ); + }, +}; + +// --- Skill Execution Helper --- + +/** + * Execute a skill against an LLM session with optional retry on parse failure. + * On first parse failure, sends a correction prompt to let the agent fix in-place. + * + * @param sendMessage - Function to send a message and get the response text + * @param prompt - The user's prompt content + * @param skill - The structured output skill to use + * @param maxRetries - Maximum correction attempts (default: 1) + * @returns Parsed result or null if all attempts fail + */ +export async function executeWithSkill( + sendMessage: (text: string) => Promise, + prompt: string, + skill: StructuredOutputSkill, + maxRetries = 1, +): Promise<{ ok: true; data: T } | { ok: false; error: string }> { + // First attempt: send prompt with skill format instructions + const fullPrompt = `${skill.formatPrompt}\n\n---\n\n${prompt}`; + const responseText = await sendMessage(fullPrompt); + + const result = skill.parse(responseText); + if (result.ok) return result; + + // Retry with correction prompt + for (let i = 0; i < maxRetries; i++) { + const correction = skill.correctionPrompt(responseText, result.error); + const retryText = await sendMessage(correction); + const retryResult = skill.parse(retryText); + if (retryResult.ok) return retryResult; + } + + return result; // Return last failure +} diff --git a/electron/main/services/agent-team/task-executor.ts b/electron/main/services/agent-team/task-executor.ts new file mode 100644 index 00000000..ea89c04d --- /dev/null +++ b/electron/main/services/agent-team/task-executor.ts @@ -0,0 +1,99 @@ +// ============================================================================ +// Task Executor — Executes a single task node as a CodeMux session +// Follows the same pattern as ScheduledTaskService.executeTask() +// ============================================================================ + +import type { EngineManager } from "../../gateway/engine-manager"; +import type { TaskNode, EngineType, UnifiedMessage, UnifiedPart } from "../../../../src/types/unified"; + +/** Result of executing a single task */ +export interface TaskExecutionResult { + sessionId: string; + summary: string; + error?: string; +} + +/** + * Extracts text content from a completed UnifiedMessage. + * Concatenates all text parts from the message. + */ +export function extractTextFromMessage(message: UnifiedMessage): string { + const textParts = (message.parts || []) + .filter((p: UnifiedPart) => p.type === "text") + .map((p) => (p as { text: string }).text); + return textParts.join("\n").trim(); +} + +export class TaskExecutor { + constructor( + private engineManager: EngineManager, + private autoApproveSessions: Set, + private defaultEngineType: EngineType, + ) {} + + /** + * Execute a single task: create session, send prompt, wait for completion. + * + * @param task - The task node to execute + * @param directory - Working directory for the session + * @param upstreamContext - Optional context from completed upstream tasks + */ + async execute( + task: TaskNode, + directory: string, + upstreamContext?: string, + ): Promise { + const engineType = (task.engineType as EngineType) || this.defaultEngineType; + + // 1. Create a new session + const session = await this.engineManager.createSession(engineType, directory); + task.sessionId = session.id; + + // 2. Register session for auto-approve permissions + this.registerAutoApprove(session.id); + + // 3. Build prompt with upstream context + let prompt = task.prompt; + if (upstreamContext) { + prompt = `${upstreamContext}\n\n---\n\nYour task:\n${task.prompt}`; + } + + // 4. Send message and wait for completion + const message = await this.engineManager.sendMessage(session.id, [ + { type: "text", text: prompt }, + ]); + + // 5. Extract result text + const summary = extractTextFromMessage(message); + + if (message.error) { + return { sessionId: session.id, summary, error: message.error }; + } + + return { sessionId: session.id, summary }; + } + + /** + * Build upstream context string from completed dependency tasks. + */ + static buildUpstreamContext(dependencies: TaskNode[]): string | undefined { + const completed = dependencies.filter((d) => d.status === "completed" && d.result); + if (completed.length === 0) return undefined; + + const sections = completed.map( + (d) => `[Task "${d.description}"]: ${d.result}`, + ); + + return `Context from completed upstream tasks:\n---\n${sections.join("\n---\n")}`; + } + + private registerAutoApprove(sessionId: string): void { + // Keep the set bounded (same pattern as ScheduledTaskService) + if (this.autoApproveSessions.size > 200) { + const recent = [...this.autoApproveSessions].slice(-100); + this.autoApproveSessions.clear(); + for (const id of recent) this.autoApproveSessions.add(id); + } + this.autoApproveSessions.add(sessionId); + } +} diff --git a/electron/main/services/logger.ts b/electron/main/services/logger.ts index 3218ea3e..4c882846 100644 --- a/electron/main/services/logger.ts +++ b/electron/main/services/logger.ts @@ -149,6 +149,7 @@ export const telegramLog = log.scope("telegram"); export const wecomLog = log.scope("wecom"); export const teamsLog = log.scope("teams"); export const scheduledTaskLog = log.scope("sched-task"); +export const agentTeamLog = log.scope("agent-team"); export type ScopedLogger = Pick< typeof feishuLog, diff --git a/src/components/PromptInput.tsx b/src/components/PromptInput.tsx index 481a3f85..bc4b1864 100644 --- a/src/components/PromptInput.tsx +++ b/src/components/PromptInput.tsx @@ -134,6 +134,8 @@ interface PromptInputProps { availableCommands?: EngineCommand[]; /** Called when user invokes a slash command (instead of onSend) */ onCommandInvoke?: (commandName: string, args: string, agent: AgentMode) => void; + /** Called when user triggers a team run */ + onTeamSend?: (text: string, mode: "light" | "heavy") => void; } export function PromptInput(props: PromptInputProps) { @@ -142,9 +144,35 @@ export function PromptInput(props: PromptInputProps) { const [textarea, setTextarea] = createSignal(); const [images, setImages] = createSignal([]); const [dragOver, setDragOver] = createSignal(false); + const [showTeamMenu, setShowTeamMenu] = createSignal(false); + let teamMenuRef: HTMLDivElement | undefined; let fileInputRef: HTMLInputElement | undefined; let pasteCounter = 0; + // Close team menu on click outside + const handleTeamClickOutside = (e: MouseEvent) => { + if (teamMenuRef && !teamMenuRef.contains(e.target as Node)) { + setShowTeamMenu(false); + } + }; + createEffect(() => { + if (showTeamMenu()) { + document.addEventListener("mousedown", handleTeamClickOutside); + } else { + document.removeEventListener("mousedown", handleTeamClickOutside); + } + }); + onCleanup(() => document.removeEventListener("mousedown", handleTeamClickOutside)); + + const handleTeamSend = (mode: "light" | "heavy") => { + const trimmed = text().trim(); + if (!trimmed || !props.onTeamSend) return; + props.onTeamSend(trimmed, mode); + setText(""); + setImages([]); + setShowTeamMenu(false); + }; + // --- Slash command autocomplete state --- const [showCommandMenu, setShowCommandMenu] = createSignal(false); const [commandSelectedIndex, setCommandSelectedIndex] = createSignal(0); @@ -583,6 +611,55 @@ export function PromptInput(props: PromptInputProps) { + {/* Team run trigger */} + +
{ teamMenuRef = el; }}> + + +
+ + +
+
+
+
+ + + 0}> +
+ + {(task) => { + const icon = () => { + switch (task.status) { + case "completed": return "\u2713"; + case "failed": return "\u2717"; + case "running": return "\u25CB"; + case "blocked": return "\u25CB"; + case "cancelled": return "\u2014"; + default: return "\u00B7"; + } + }; + const color = () => { + switch (task.status) { + case "completed": return "text-green-600 dark:text-green-400"; + case "failed": return "text-red-600 dark:text-red-400"; + case "running": return "text-amber-600 dark:text-amber-400"; + default: return "text-gray-400 dark:text-gray-500"; + } + }; + return ( +
+ {icon()} + {task.id}: {task.description} + + + +
+ ); + }} +
+
+
+ +
+ {run().finalResult} +
+
+ + ); + }} + + e.type === currentEngineType())?.capabilities?.imageAttachment ?? false} availableCommands={availableCommands()} onCommandInvoke={handleCommandInvoke} + onTeamSend={handleTeamSend} />
diff --git a/src/stores/team.ts b/src/stores/team.ts new file mode 100644 index 00000000..c0987ea1 --- /dev/null +++ b/src/stores/team.ts @@ -0,0 +1,94 @@ +// ============================================================================ +// Team Store — Minimal reactive store for Agent Team runs +// ============================================================================ + +import { createStore } from "solid-js/store"; +import { gateway } from "../lib/gateway-api"; +import type { TeamRun, TaskNode } from "../types/unified"; + +interface TeamStoreState { + /** All known team runs */ + runs: TeamRun[]; + /** Currently active/focused run ID */ + activeRunId: string | null; +} + +const [teamStore, setTeamStore] = createStore({ + runs: [], + activeRunId: null, +}); + +export { teamStore }; + +/** Initialize notification handlers for team events */ +export function initTeamStore(): void { + // These handlers are set during gateway-api initialization + // See connectTeamHandlers() +} + +/** Connect team notification handlers to gateway-api */ +export function connectTeamHandlers(): { + onTeamRunUpdated: (run: TeamRun) => void; + onTeamTaskUpdated: (runId: string, task: TaskNode) => void; +} { + return { + onTeamRunUpdated: (run: TeamRun) => { + setTeamStore("runs", (runs) => { + const idx = runs.findIndex((r) => r.id === run.id); + if (idx >= 0) { + const updated = [...runs]; + updated[idx] = run; + return updated; + } + return [...runs, run]; + }); + }, + + onTeamTaskUpdated: (runId: string, task: TaskNode) => { + setTeamStore("runs", (runs) => + runs.map((run) => { + if (run.id !== runId) return run; + return { + ...run, + tasks: run.tasks.map((t) => (t.id === task.id ? task : t)), + }; + }), + ); + }, + }; +} + +/** Create a new team run */ +export async function createTeamRun( + sessionId: string, + prompt: string, + mode: "light" | "heavy", + directory: string, + engineType?: string, +): Promise { + const run = await gateway.createTeamRun({ + sessionId, + prompt, + mode, + directory, + engineType, + }); + setTeamStore("runs", (runs) => [...runs, run]); + setTeamStore("activeRunId", run.id); + return run; +} + +/** Cancel a team run */ +export async function cancelTeamRun(runId: string): Promise { + await gateway.cancelTeamRun(runId); +} + +/** Get the active team run for a given session */ +export function getTeamRunForSession(sessionId: string): TeamRun | undefined { + return teamStore.runs.find((r) => r.parentSessionId === sessionId); +} + +/** Get team run by ID */ +export function getTeamRun(runId: string): TeamRun | undefined { + return teamStore.runs.find((r) => r.id === runId); +} diff --git a/src/types/unified.ts b/src/types/unified.ts index c2a83e6b..3363d89c 100644 --- a/src/types/unified.ts +++ b/src/types/unified.ts @@ -624,6 +624,12 @@ export const GatewayRequestType = { WORKTREE_REMOVE: "worktree.remove", WORKTREE_MERGE: "worktree.merge", WORKTREE_LIST_BRANCHES: "worktree.listBranches", + + // Agent Team + TEAM_CREATE: "team.create", + TEAM_CANCEL: "team.cancel", + TEAM_LIST: "team.list", + TEAM_GET: "team.get", } as const; // --- Notification type constants --- @@ -658,6 +664,10 @@ export const GatewayNotificationType = { WORKTREE_CREATED: "worktree.created", WORKTREE_REMOVED: "worktree.removed", WORKTREE_MERGE_RESULT: "worktree.mergeResult", + + // Agent Team + TEAM_RUN_UPDATED: "team.run.updated", + TEAM_TASK_UPDATED: "team.task.updated", } as const; // --- Request / Response payload types --- @@ -942,3 +952,82 @@ export interface ScheduledTaskRunResult { taskId: string; conversationId: string; } + +// ============================================================================ +// Agent Team Types +// ============================================================================ + +export type TeamRunStatus = "planning" | "running" | "completed" | "failed" | "cancelled"; +export type TaskNodeStatus = "pending" | "blocked" | "running" | "completed" | "failed" | "cancelled"; +export type TeamMode = "light" | "heavy"; + +/** A single task node in the execution DAG */ +export interface TaskNode { + id: string; + /** Human-readable description of this task */ + description: string; + /** The prompt to send to the child session */ + prompt: string; + /** Which engine to run this task on (optional — defaults to project engine) */ + engineType?: EngineType; + /** IDs of tasks that must complete before this one starts */ + dependsOn: string[]; + /** Current execution status */ + status: TaskNodeStatus; + /** ConversationId of the child session (set when task starts running) */ + sessionId?: string; + /** Result summary extracted from the completed message */ + result?: string; + /** Error message if failed */ + error?: string; + /** Timing */ + time?: { started?: number; completed?: number }; + /** Optional: worktreeId for file-isolation tasks */ + worktreeId?: string; +} + +/** Represents a complete Agent Team run */ +export interface TeamRun { + id: string; + /** The parent session that initiated this team run */ + parentSessionId: string; + /** Directory for all child sessions */ + directory: string; + /** User's original request */ + originalPrompt: string; + /** Light or Heavy brain mode */ + mode: TeamMode; + /** Current overall status */ + status: TeamRunStatus; + /** All task nodes in the DAG */ + tasks: TaskNode[]; + /** Orchestrator session ID (Heavy Brain only) */ + orchestratorSessionId?: string; + /** Timing */ + time: { created: number; completed?: number }; + /** Final synthesized result */ + finalResult?: string; +} + +// --- Agent Team Gateway types --- + +export interface TeamCreateRequest { + /** Parent session initiating the team run */ + sessionId: string; + /** User's task description */ + prompt: string; + /** Light or Heavy brain mode */ + mode: TeamMode; + /** Engine for the planner (light) or orchestrator (heavy) */ + engineType?: EngineType; + /** Working directory */ + directory: string; +} + +export interface TeamCancelRequest { + runId: string; +} + +export interface TeamGetRequest { + runId: string; +} diff --git a/tests/unit/electron/services/agent-team/dag-executor.test.ts b/tests/unit/electron/services/agent-team/dag-executor.test.ts new file mode 100644 index 00000000..4b55ef83 --- /dev/null +++ b/tests/unit/electron/services/agent-team/dag-executor.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock electron and logger before imports +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp/test"), + isPackaged: false, + on: vi.fn(), + }, +})); + +vi.mock("../../../../../electron/main/services/logger", () => ({ + agentTeamLog: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { DAGExecutor } from "../../../../../electron/main/services/agent-team/dag-executor"; +import { TaskExecutor } from "../../../../../electron/main/services/agent-team/task-executor"; +import type { TeamRun, TaskNode } from "../../../../../src/types/unified"; + +function makeTask(overrides: Partial & { id: string }): TaskNode { + return { + description: `Task ${overrides.id}`, + prompt: `Do ${overrides.id}`, + dependsOn: [], + status: "pending", + ...overrides, + }; +} + +function makeRun(tasks: TaskNode[]): TeamRun { + return { + id: "team_test", + parentSessionId: "parent", + directory: "/test", + originalPrompt: "test", + mode: "light", + status: "running", + tasks, + time: { created: Date.now() }, + }; +} + +describe("DAGExecutor", () => { + let mockTaskExecutor: TaskExecutor; + + beforeEach(() => { + // Create a mock TaskExecutor + mockTaskExecutor = { + execute: vi.fn(async (task: TaskNode) => ({ + sessionId: `session_${task.id}`, + summary: `Result of ${task.id}`, + })), + } as any; + }); + + it("executes independent tasks in parallel", async () => { + const tasks = [ + makeTask({ id: "t1" }), + makeTask({ id: "t2" }), + makeTask({ id: "t3" }), + ]; + const run = makeRun(tasks); + + const dagExecutor = new DAGExecutor(mockTaskExecutor, "/test"); + await dagExecutor.executeReadyTasks(run); + + // All 3 tasks should be completed + expect(tasks.every((t) => t.status === "completed")).toBe(true); + // TaskExecutor.execute should be called 3 times + expect(mockTaskExecutor.execute).toHaveBeenCalledTimes(3); + }); + + it("respects dependencies: t2 waits for t1", async () => { + const executionOrder: string[] = []; + mockTaskExecutor.execute = vi.fn(async (task: TaskNode) => { + executionOrder.push(task.id); + return { sessionId: `s_${task.id}`, summary: `Result of ${task.id}` }; + }); + + const tasks = [ + makeTask({ id: "t1" }), + makeTask({ id: "t2", dependsOn: ["t1"] }), + ]; + const run = makeRun(tasks); + + const dagExecutor = new DAGExecutor(mockTaskExecutor, "/test"); + await dagExecutor.executeReadyTasks(run); + + expect(tasks[0].status).toBe("completed"); + expect(tasks[1].status).toBe("completed"); + // t1 must execute before t2 + expect(executionOrder.indexOf("t1")).toBeLessThan(executionOrder.indexOf("t2")); + }); + + it("propagates failures: downstream tasks become blocked", async () => { + mockTaskExecutor.execute = vi.fn(async (task: TaskNode) => { + if (task.id === "t1") throw new Error("t1 failed"); + return { sessionId: `s_${task.id}`, summary: `ok` }; + }); + + const tasks = [ + makeTask({ id: "t1" }), + makeTask({ id: "t2", dependsOn: ["t1"] }), + makeTask({ id: "t3" }), + ]; + const run = makeRun(tasks); + + const dagExecutor = new DAGExecutor(mockTaskExecutor, "/test"); + await dagExecutor.executeReadyTasks(run); + + expect(tasks[0].status).toBe("failed"); + expect(tasks[1].status).toBe("blocked"); + expect(tasks[2].status).toBe("completed"); + }); + + it("emits task.updated events", async () => { + const tasks = [makeTask({ id: "t1" })]; + const run = makeRun(tasks); + + const dagExecutor = new DAGExecutor(mockTaskExecutor, "/test"); + const events: string[] = []; + dagExecutor.on("task.updated", ({ task }) => { + events.push(`${task.id}:${task.status}`); + }); + + await dagExecutor.executeReadyTasks(run); + + expect(events).toContain("t1:running"); + expect(events).toContain("t1:completed"); + }); + + it("handles diamond dependency pattern", async () => { + // t1 → t2, t3 → t4 (diamond) + const tasks = [ + makeTask({ id: "t1" }), + makeTask({ id: "t2", dependsOn: ["t1"] }), + makeTask({ id: "t3", dependsOn: ["t1"] }), + makeTask({ id: "t4", dependsOn: ["t2", "t3"] }), + ]; + const run = makeRun(tasks); + + const dagExecutor = new DAGExecutor(mockTaskExecutor, "/test"); + await dagExecutor.executeReadyTasks(run); + + expect(tasks.every((t) => t.status === "completed")).toBe(true); + }); +}); + +describe("DAGExecutor static helpers", () => { + it("isComplete returns true when all tasks are terminal", () => { + const tasks: TaskNode[] = [ + makeTask({ id: "t1", status: "completed" } as any), + makeTask({ id: "t2", status: "failed" } as any), + makeTask({ id: "t3", status: "blocked" } as any), + ]; + expect(DAGExecutor.isComplete(tasks)).toBe(true); + }); + + it("isComplete returns false when tasks are pending", () => { + const tasks: TaskNode[] = [ + makeTask({ id: "t1", status: "completed" } as any), + makeTask({ id: "t2", status: "pending" } as any), + ]; + expect(DAGExecutor.isComplete(tasks)).toBe(false); + }); + + it("isAllSuccessful returns true when all completed", () => { + const tasks: TaskNode[] = [ + makeTask({ id: "t1", status: "completed" } as any), + makeTask({ id: "t2", status: "completed" } as any), + ]; + expect(DAGExecutor.isAllSuccessful(tasks)).toBe(true); + }); + + it("isAllSuccessful returns false when any failed", () => { + const tasks: TaskNode[] = [ + makeTask({ id: "t1", status: "completed" } as any), + makeTask({ id: "t2", status: "failed" } as any), + ]; + expect(DAGExecutor.isAllSuccessful(tasks)).toBe(false); + }); +}); diff --git a/tests/unit/electron/services/agent-team/skills.test.ts b/tests/unit/electron/services/agent-team/skills.test.ts new file mode 100644 index 00000000..debad8a0 --- /dev/null +++ b/tests/unit/electron/services/agent-team/skills.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect } from "vitest"; +import { + extractJsonBlocks, + parseFirstJson, + dagPlanningSkill, + dispatchSkill, + executeWithSkill, +} from "../../../../../electron/main/services/agent-team/skills"; + +// ============================================================================= +// extractJsonBlocks +// ============================================================================= + +describe("extractJsonBlocks", () => { + it("extracts JSON from ```json fenced block", () => { + const text = 'Here is the plan:\n```json\n{"tasks": []}\n```\nDone.'; + const blocks = extractJsonBlocks(text); + expect(blocks).toEqual(['{"tasks": []}']); + }); + + it("extracts JSON from ``` fenced block (no json tag)", () => { + const text = "```\n{\"action\": \"complete\"}\n```"; + const blocks = extractJsonBlocks(text); + expect(blocks).toEqual(['{"action": "complete"}']); + }); + + it("extracts multiple fenced blocks", () => { + const text = "```json\n{\"a\":1}\n```\ntext\n```json\n{\"b\":2}\n```"; + const blocks = extractJsonBlocks(text); + expect(blocks).toHaveLength(2); + }); + + it("falls back to bare JSON when no fences", () => { + const text = 'The result is {"tasks": [{"id": "t1"}]}'; + const blocks = extractJsonBlocks(text); + expect(blocks).toHaveLength(1); + expect(JSON.parse(blocks[0])).toEqual({ tasks: [{ id: "t1" }] }); + }); + + it("returns empty for no JSON", () => { + const text = "No JSON here, just text."; + expect(extractJsonBlocks(text)).toEqual([]); + }); + + it("handles multiline JSON in fenced block", () => { + const text = '```json\n{\n "tasks": [\n {\n "id": "t1",\n "description": "test"\n }\n ]\n}\n```'; + const blocks = extractJsonBlocks(text); + expect(blocks).toHaveLength(1); + expect(JSON.parse(blocks[0]).tasks[0].id).toBe("t1"); + }); +}); + +// ============================================================================= +// parseFirstJson +// ============================================================================= + +describe("parseFirstJson", () => { + it("parses valid JSON from fenced block", () => { + const text = '```json\n{"key": "value"}\n```'; + const result = parseFirstJson<{ key: string }>(text); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.key).toBe("value"); + }); + + it("returns error for no JSON", () => { + const result = parseFirstJson("No JSON here"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("No JSON block found"); + }); + + it("returns error for invalid JSON syntax", () => { + const text = '```json\n{invalid json}\n```'; + const result = parseFirstJson(text); + expect(result.ok).toBe(false); + }); +}); + +// ============================================================================= +// dagPlanningSkill.parse +// ============================================================================= + +describe("dagPlanningSkill.parse", () => { + it("parses valid DAG", () => { + const text = `\`\`\`json +{ + "tasks": [ + { + "id": "t1", + "description": "Analyze code", + "prompt": "Read and analyze the codebase", + "dependsOn": [] + }, + { + "id": "t2", + "description": "Implement changes", + "prompt": "Make the changes based on analysis", + "dependsOn": ["t1"] + } + ] +} +\`\`\``; + const result = dagPlanningSkill.parse(text); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.tasks).toHaveLength(2); + expect(result.data.tasks[0].id).toBe("t1"); + expect(result.data.tasks[1].dependsOn).toEqual(["t1"]); + } + }); + + it("rejects empty tasks array", () => { + const text = '```json\n{"tasks": []}\n```'; + const result = dagPlanningSkill.parse(text); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("empty"); + }); + + it("rejects duplicate task IDs", () => { + const text = `\`\`\`json +{"tasks": [ + {"id": "t1", "description": "A", "prompt": "Do A", "dependsOn": []}, + {"id": "t1", "description": "B", "prompt": "Do B", "dependsOn": []} +]} +\`\`\``; + const result = dagPlanningSkill.parse(text); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("duplicate"); + }); + + it("rejects missing fields", () => { + const text = '```json\n{"tasks": [{"id": "t1"}]}\n```'; + const result = dagPlanningSkill.parse(text); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("description"); + expect(result.error).toContain("prompt"); + } + }); + + it("rejects unknown dependency reference", () => { + const text = `\`\`\`json +{"tasks": [ + {"id": "t1", "description": "A", "prompt": "Do A", "dependsOn": ["t99"]} +]} +\`\`\``; + const result = dagPlanningSkill.parse(text); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("t99"); + }); + + it("rejects circular dependencies", () => { + const text = `\`\`\`json +{"tasks": [ + {"id": "t1", "description": "A", "prompt": "Do A", "dependsOn": ["t2"]}, + {"id": "t2", "description": "B", "prompt": "Do B", "dependsOn": ["t1"]} +]} +\`\`\``; + const result = dagPlanningSkill.parse(text); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("Circular"); + }); + + it("rejects DAG with no root", () => { + const text = `\`\`\`json +{"tasks": [ + {"id": "t1", "description": "A", "prompt": "Do A", "dependsOn": ["t2"]}, + {"id": "t2", "description": "B", "prompt": "Do B", "dependsOn": ["t3"]}, + {"id": "t3", "description": "C", "prompt": "Do C", "dependsOn": ["t1"]} +]} +\`\`\``; + const result = dagPlanningSkill.parse(text); + expect(result.ok).toBe(false); + }); + + it("accepts optional engineType field", () => { + const text = `\`\`\`json +{"tasks": [ + {"id": "t1", "description": "A", "prompt": "Do A", "dependsOn": [], "engineType": "claude"} +]} +\`\`\``; + const result = dagPlanningSkill.parse(text); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.tasks[0].engineType).toBe("claude"); + }); +}); + +// ============================================================================= +// dispatchSkill.parse +// ============================================================================= + +describe("dispatchSkill.parse", () => { + it("parses dispatch instruction", () => { + const text = `\`\`\`json +{ + "action": "dispatch", + "tasks": [ + {"id": "t1", "description": "Analyze", "prompt": "Analyze the code"} + ] +} +\`\`\``; + const result = dispatchSkill.parse(text); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.action).toBe("dispatch"); + if (result.data.action === "dispatch") { + expect(result.data.tasks).toHaveLength(1); + } + } + }); + + it("parses complete instruction", () => { + const text = '```json\n{"action": "complete", "result": "All tasks done successfully."}\n```'; + const result = dispatchSkill.parse(text); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.action).toBe("complete"); + if (result.data.action === "complete") { + expect(result.data.result).toBe("All tasks done successfully."); + } + } + }); + + it("rejects unknown action", () => { + const text = '```json\n{"action": "unknown"}\n```'; + const result = dispatchSkill.parse(text); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("Unknown action"); + }); + + it("rejects dispatch with empty tasks", () => { + const text = '```json\n{"action": "dispatch", "tasks": []}\n```'; + const result = dispatchSkill.parse(text); + expect(result.ok).toBe(false); + }); + + it("rejects complete without result", () => { + const text = '```json\n{"action": "complete"}\n```'; + const result = dispatchSkill.parse(text); + expect(result.ok).toBe(false); + }); + + it("rejects dispatch task missing required fields", () => { + const text = '```json\n{"action": "dispatch", "tasks": [{"id": "t1"}]}\n```'; + const result = dispatchSkill.parse(text); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("description"); + }); +}); + +// ============================================================================= +// correctionPrompt +// ============================================================================= + +describe("correctionPrompt", () => { + it("dagPlanningSkill generates correction", () => { + const correction = dagPlanningSkill.correctionPrompt("bad output", "Missing tasks array"); + expect(correction).toContain("Missing tasks array"); + expect(correction).toContain("corrected JSON"); + }); + + it("dispatchSkill generates correction", () => { + const correction = dispatchSkill.correctionPrompt("bad output", "Unknown action"); + expect(correction).toContain("Unknown action"); + expect(correction).toContain("corrected JSON"); + }); +}); + +// ============================================================================= +// executeWithSkill +// ============================================================================= + +describe("executeWithSkill", () => { + it("succeeds on first attempt with valid output", async () => { + const sendMessage = async (_text: string) => + '```json\n{"tasks": [{"id": "t1", "description": "test", "prompt": "do it", "dependsOn": []}]}\n```'; + + const result = await executeWithSkill(sendMessage, "user request", dagPlanningSkill); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.tasks).toHaveLength(1); + }); + + it("retries on first failure and succeeds", async () => { + let callCount = 0; + const sendMessage = async (_text: string) => { + callCount++; + if (callCount === 1) return "not json"; + return '```json\n{"tasks": [{"id": "t1", "description": "test", "prompt": "do it", "dependsOn": []}]}\n```'; + }; + + const result = await executeWithSkill(sendMessage, "user request", dagPlanningSkill); + expect(result.ok).toBe(true); + expect(callCount).toBe(2); + }); + + it("fails after max retries", async () => { + const sendMessage = async (_text: string) => "still not json"; + + const result = await executeWithSkill(sendMessage, "user request", dagPlanningSkill, 1); + expect(result.ok).toBe(false); + }); + + it("includes format prompt in first message", async () => { + let receivedPrompt = ""; + const sendMessage = async (text: string) => { + receivedPrompt = text; + return '```json\n{"tasks": [{"id": "t1", "description": "test", "prompt": "do it", "dependsOn": []}]}\n```'; + }; + + await executeWithSkill(sendMessage, "my request", dagPlanningSkill); + expect(receivedPrompt).toContain("Self-Check Before Outputting"); + expect(receivedPrompt).toContain("my request"); + }); +}); From d2461ffee5d5dfb170797bfd9079aa17595f4392 Mon Sep 17 00:00:00 2001 From: FridayLiu Date: Sat, 18 Apr 2026 07:51:34 +0000 Subject: [PATCH 2/6] feat: add agent team orchestration and relay Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- electron/main/engines/claude/index.ts | 12 +- electron/main/engines/codex/index.ts | 28 +- electron/main/engines/copilot/index.ts | 24 +- electron/main/engines/opencode/index.ts | 20 +- electron/main/gateway/engine-manager.ts | 21 +- electron/main/gateway/ws-server.ts | 7 + .../main/services/agent-team/heavy-brain.ts | 279 ++++++++++++++++-- electron/main/services/agent-team/index.ts | 31 +- .../main/services/agent-team/light-brain.ts | 28 +- electron/main/services/agent-team/prompts.ts | 93 +++++- electron/main/services/agent-team/skills.ts | 92 ++++-- .../main/services/agent-team/user-channel.ts | 76 +++++ src/components/PromptInput.tsx | 19 +- src/lib/gateway-api.ts | 4 + src/lib/gateway-client.ts | 4 + src/locales/en.ts | 12 + src/locales/ru.ts | 6 + src/locales/zh.ts | 6 + src/pages/Chat.tsx | 154 +++++++--- src/stores/team.ts | 40 ++- src/types/unified.ts | 6 + tests/unit/src/stores/team.test.ts | 145 +++++++++ 22 files changed, 966 insertions(+), 141 deletions(-) create mode 100644 electron/main/services/agent-team/user-channel.ts create mode 100644 tests/unit/src/stores/team.test.ts diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index 0fe9358d..c9e4f191 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -215,6 +215,8 @@ export class ClaudeCodeAdapter extends EngineAdapter { private sessionDirectories = new Map(); /** Persisted ccSessionId per session, for SDK session resumption across restarts */ private sessionCcIds = new Map(); + /** Custom system prompts per session (e.g. orchestration instructions for agent team) */ + private sessionSystemPrompts = new Map(); /** Sessions that were just resumed after a dead process — emit notice on next message */ private pendingResumeNotice = new Set(); @@ -562,6 +564,9 @@ export class ClaudeCodeAdapter extends EngineAdapter { if (meta?.ccSessionId && typeof meta.ccSessionId === "string") { this.sessionCcIds.set(sessionId, meta.ccSessionId); } + if (meta?.systemPrompt && typeof meta.systemPrompt === "string") { + this.sessionSystemPrompts.set(sessionId, meta.systemPrompt); + } this.emit("session.created", { session }); // Warm up in background — store the promise so listCommands() can await it. @@ -626,6 +631,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { } this.sessionDirectories.delete(sessionId); + this.sessionSystemPrompts.delete(sessionId); this.messageHistory.delete(sessionId); this.messageBuffers.delete(sessionId); this.sessionModes.delete(sessionId); @@ -1813,11 +1819,15 @@ export class ClaudeCodeAdapter extends EngineAdapter { // narrower than the internal Options type. The SDK internally passes these // through to ProcessTransport which accepts all Options fields. - // Build system prompt append: identity + cached user skills + // Build system prompt append: identity + cached user skills + optional custom system prompt let promptAppend = CODEMUX_IDENTITY_PROMPT; if (this.cachedSkillNames.length > 0) { promptAppend += `\n\nThe user has installed the following additional skills (invokable via the Skill tool): ${this.cachedSkillNames.join(", ")}. When the user's request matches one of these skills, use the Skill tool to invoke it.`; } + const customSystemPrompt = this.sessionSystemPrompts.get(sessionId); + if (customSystemPrompt) { + promptAppend += "\n\n" + customSystemPrompt; + } const sdkOptions: any = { model: opts.model ?? this.currentModelId ?? "claude-sonnet-4-20250514", diff --git a/electron/main/engines/codex/index.ts b/electron/main/engines/codex/index.ts index a0ec9f79..862bbffe 100644 --- a/electron/main/engines/codex/index.ts +++ b/electron/main/engines/codex/index.ts @@ -214,6 +214,8 @@ export class CodexAdapter extends EngineAdapter { private sessionReasoningEfforts = new Map(); private sessionServiceTiers = new Map(); private sessionDirectories = new Map(); + /** Custom system prompts per session (e.g. orchestration instructions for agent team) */ + private sessionSystemPrompts = new Map(); private currentModelId: string = CODEX_FALLBACK_MODEL; private currentMode: string = DEFAULT_MODE_ID; @@ -362,17 +364,18 @@ export class CodexAdapter extends EngineAdapter { await this.start(); const normalizedDirectory = normalizeDirectory(directory); + const customSystemPrompt = (meta?.systemPrompt && typeof meta.systemPrompt === "string") ? meta.systemPrompt : undefined; const existingThreadId = resolveThreadId(undefined, meta); let threadResponse: ThreadResponse; if (existingThreadId) { try { - threadResponse = await this.resumeThread(existingThreadId, normalizedDirectory); + threadResponse = await this.resumeThread(existingThreadId, normalizedDirectory, customSystemPrompt); } catch (error) { codexLog.warn(`Failed to resume Codex thread ${existingThreadId}, starting a new one instead:`, error); - threadResponse = await this.startThread(normalizedDirectory); + threadResponse = await this.startThread(normalizedDirectory, customSystemPrompt); } } else { - threadResponse = await this.startThread(normalizedDirectory); + threadResponse = await this.startThread(normalizedDirectory, customSystemPrompt); } const threadId = threadResponse.thread?.id; @@ -389,6 +392,9 @@ export class CodexAdapter extends EngineAdapter { if (!this.sessionDirectories.has(sessionId)) { this.sessionDirectories.set(sessionId, normalizedDirectory); } + if (customSystemPrompt) { + this.sessionSystemPrompts.set(sessionId, customSystemPrompt); + } if (threadResponse.model) { this.sessionModels.set(sessionId, threadResponse.model); this.currentModelId = threadResponse.model; @@ -1893,16 +1899,19 @@ export class CodexAdapter extends EngineAdapter { return undefined; } - private async startThread(directory: string): Promise { + private async startThread(directory: string, customSystemPrompt?: string): Promise { const modeId = this.currentMode; const approvalPolicy = clampApprovalPolicy(modeToApprovalPolicy(modeId), this.configRequirements); const sandboxMode = clampSandboxMode(modeToSandboxMode(modeId), this.configRequirements); + const baseInstructions = customSystemPrompt + ? CODEMUX_IDENTITY_PROMPT + "\n\n" + customSystemPrompt + : CODEMUX_IDENTITY_PROMPT; const response = asRecord(await this.client!.request("thread/start", { cwd: directory, model: this.currentModelId, approvalPolicy, sandbox: sandboxMode, - baseInstructions: CODEMUX_IDENTITY_PROMPT, + baseInstructions, serviceName: "codemux", experimentalRawEvents: false, persistExtendedHistory: true, @@ -1911,13 +1920,17 @@ export class CodexAdapter extends EngineAdapter { return response; } - private async resumeThread(threadId: string, directory: string): Promise { + private async resumeThread(threadId: string, directory: string, customSystemPrompt?: string): Promise { const sessionId = toEngineSessionId(threadId); const modeId = this.sessionModes.get(sessionId) ?? this.currentMode; const approvalPolicy = clampApprovalPolicy(modeToApprovalPolicy(modeId), this.configRequirements); const sandboxMode = clampSandboxMode(modeToSandboxMode(modeId), this.configRequirements); const modelId = this.sessionModels.get(sessionId) ?? this.currentModelId; const serviceTier = this.sessionServiceTiers.get(sessionId); + const systemPrompt = customSystemPrompt ?? this.sessionSystemPrompts.get(sessionId); + const baseInstructions = systemPrompt + ? CODEMUX_IDENTITY_PROMPT + "\n\n" + systemPrompt + : CODEMUX_IDENTITY_PROMPT; const response = asRecord(await this.client!.request("thread/resume", { threadId, @@ -1925,7 +1938,7 @@ export class CodexAdapter extends EngineAdapter { model: modelId, approvalPolicy, sandbox: sandboxMode, - baseInstructions: CODEMUX_IDENTITY_PROMPT, + baseInstructions, persistExtendedHistory: true, ...(serviceTier ? { serviceTier } : {}), })) as ThreadResponse; @@ -2171,6 +2184,7 @@ export class CodexAdapter extends EngineAdapter { this.sessionReasoningEfforts.delete(sessionId); this.sessionServiceTiers.delete(sessionId); this.sessionDirectories.delete(sessionId); + this.sessionSystemPrompts.delete(sessionId); this.rejectQueuedMessagesForSession(sessionId, "Session deleted"); this.rejectPendingForSession(sessionId, "Session deleted"); diff --git a/electron/main/engines/copilot/index.ts b/electron/main/engines/copilot/index.ts index 01c842ca..20f422cf 100644 --- a/electron/main/engines/copilot/index.ts +++ b/electron/main/engines/copilot/index.ts @@ -128,6 +128,8 @@ export class CopilotSdkAdapter extends EngineAdapter { private sessionTodos = new Map>(); private allowedAlwaysKinds = new Set(); private cachedCommands: EngineCommand[] = []; + /** Custom system prompt per session (e.g. orchestration instructions for agent team) */ + private sessionSystemPrompts = new Map(); private messageBuffers = new Map(); private messageHistory = new Map(); @@ -297,18 +299,24 @@ export class CopilotSdkAdapter extends EngineAdapter { } } - async createSession(directory: string): Promise { + async createSession(directory: string, meta?: Record): Promise { this.ensureClient(); const normalizedDir = directory.replaceAll("\\", "/"); const mode = "autopilot"; + // Build system message: identity prompt + optional custom system prompt (e.g. orchestration instructions) + let systemContent = CODEMUX_IDENTITY_PROMPT; + if (meta?.systemPrompt && typeof meta.systemPrompt === "string") { + systemContent += "\n\n" + meta.systemPrompt; + } + const config: SessionConfig = { workingDirectory: directory, streaming: true, model: this.currentModelId ?? undefined, onPermissionRequest: (req, ctx) => this.handlePermissionRequest(req as any, ctx), onUserInputRequest: (req, ctx) => this.handleUserInputRequest(req as any, ctx), - systemMessage: { mode: "append" as const, content: CODEMUX_IDENTITY_PROMPT }, + systemMessage: { mode: "append" as const, content: systemContent }, }; const sdkSession = await this.client!.createSession(config); @@ -318,6 +326,9 @@ export class CopilotSdkAdapter extends EngineAdapter { this.activeSessions.set(sessionId, sdkSession); this.sessionModes.set(sessionId, mode); this.sessionDirectories.set(sessionId, directory); + if (meta?.systemPrompt && typeof meta.systemPrompt === "string") { + this.sessionSystemPrompts.set(sessionId, meta.systemPrompt); + } const now = Date.now(); const session: UnifiedSession = { @@ -375,6 +386,7 @@ export class CopilotSdkAdapter extends EngineAdapter { this.sessionModes.delete(sessionId); this.sessionDirectories.delete(sessionId); this.sessionTodos.delete(sessionId); + this.sessionSystemPrompts.delete(sessionId); } async sendMessage( @@ -990,12 +1002,16 @@ export class CopilotSdkAdapter extends EngineAdapter { const workingDirectory = directory || this.sessionDirectories.get(sessionId); const sdkReasoningEffort = this.getSdkReasoningEffort(sessionId); + const customSystemPrompt = this.sessionSystemPrompts.get(sessionId); + const systemContent = customSystemPrompt + ? CODEMUX_IDENTITY_PROMPT + "\n\n" + customSystemPrompt + : CODEMUX_IDENTITY_PROMPT; const config: ResumeSessionConfig = { streaming: true, workingDirectory, model: this.currentModelId ?? undefined, ...(sdkReasoningEffort ? { reasoningEffort: sdkReasoningEffort } : {}), - systemMessage: { mode: "append" as const, content: CODEMUX_IDENTITY_PROMPT }, + systemMessage: { mode: "append" as const, content: systemContent }, onPermissionRequest: (req, ctx) => this.handlePermissionRequest(req as any, ctx), onUserInputRequest: (req, ctx) => this.handleUserInputRequest(req as any, ctx), }; @@ -1017,7 +1033,7 @@ export class CopilotSdkAdapter extends EngineAdapter { workingDirectory, model: this.currentModelId ?? undefined, ...(sdkReasoningEffort ? { reasoningEffort: sdkReasoningEffort } : {}), - systemMessage: { mode: "append" as const, content: CODEMUX_IDENTITY_PROMPT }, + systemMessage: { mode: "append" as const, content: systemContent }, onPermissionRequest: (req, ctx) => this.handlePermissionRequest(req as any, ctx), onUserInputRequest: (req, ctx) => this.handleUserInputRequest(req as any, ctx), }; diff --git a/electron/main/engines/opencode/index.ts b/electron/main/engines/opencode/index.ts index 8bbc4c45..7cd15cad 100644 --- a/electron/main/engines/opencode/index.ts +++ b/electron/main/engines/opencode/index.ts @@ -94,6 +94,9 @@ export class OpenCodeAdapter extends EngineAdapter { // final message when session.status: idle arrives (see resolveSessionIdle). private lastEmittedMessage = new Map(); + /** Custom system prompts per session (e.g. orchestration instructions for agent team) */ + private sessionSystemPrompts = new Map(); + // Track primary (first) user message IDs per session to avoid false-positive // queued.consumed emissions when the primary user message.updated arrives late. private primaryUserMsgIds = new Map(); @@ -119,6 +122,15 @@ export class OpenCodeAdapter extends EngineAdapter { this.port = options?.port ?? OPENCODE_PORT; } + /** Get the system prompt for a session, composing identity + custom system prompt */ + private getSystemPrompt(sessionId?: string): string { + if (sessionId) { + const custom = this.sessionSystemPrompts.get(sessionId); + if (custom) return CODEMUX_IDENTITY_PROMPT + "\n\n" + custom; + } + return CODEMUX_IDENTITY_PROMPT; + } + private get baseUrl(): string { return `http://127.0.0.1:${this.port}`; } @@ -785,7 +797,7 @@ export class OpenCodeAdapter extends EngineAdapter { // --- Sessions --- - async createSession(directory: string): Promise { + async createSession(directory: string, meta?: Record): Promise { this.switchDirectory(directory); const client = this.ensureClient(); @@ -796,6 +808,9 @@ export class OpenCodeAdapter extends EngineAdapter { const session = convertSession(this.engineType, result.data); this.sessions.set(session.id, session); + if (meta?.systemPrompt && typeof meta.systemPrompt === "string") { + this.sessionSystemPrompts.set(session.id, meta.systemPrompt); + } return session; } @@ -875,6 +890,7 @@ export class OpenCodeAdapter extends EngineAdapter { // Clean up user message IDs for this session to prevent memory leak this.userMessageIds.delete(sessionId); + this.sessionSystemPrompts.delete(sessionId); } // --- Messages --- @@ -933,7 +949,7 @@ export class OpenCodeAdapter extends EngineAdapter { parts, agent: options?.mode, model, - system: CODEMUX_IDENTITY_PROMPT, + system: this.getSystemPrompt(sessionId), }); const promptError = (promptResult as any).error; diff --git a/electron/main/gateway/engine-manager.ts b/electron/main/gateway/engine-manager.ts index bfa81173..0d908450 100644 --- a/electron/main/gateway/engine-manager.ts +++ b/electron/main/gateway/engine-manager.ts @@ -690,6 +690,7 @@ export class EngineManager extends EventEmitter { engineType: EngineType | undefined, directory: string, worktreeId?: string, + meta?: Record, ): Promise { const resolvedType = engineType || this.getDefaultEngineType(); const adapter = this.getAdapterOrThrow(resolvedType); // Validate engine exists @@ -719,7 +720,8 @@ export class EngineManager extends EventEmitter { // or Claude V2 session init) happens at session creation time, so features // like slash command autocomplete work before the user sends a message. try { - const engineSession = await adapter.createSession(conv.directory, conv.engineMeta); + const adapterMeta = { ...conv.engineMeta, ...meta }; + const engineSession = await adapter.createSession(conv.directory, adapterMeta); conversationStore.setEngineSession(conv.id, engineSession.id, engineSession.engineMeta); this.engineToConvMap.set(engineSession.id, conv.id); } catch (err) { @@ -886,6 +888,23 @@ export class EngineManager extends EventEmitter { this.emit("message.updated", { sessionId, message: finalized }); } + // Merge buffered content parts into the returned message. + // Adapters stream text/file content via message.part.updated events which + // are buffered in contentPartsBuffer but NOT included in the message + // returned by adapter.sendMessage(). Callers that read result.parts + // (e.g. AgentTeamService) would otherwise see an empty parts array. + const bufferedContent = this.contentPartsBuffer.get(result.id); + if (bufferedContent && bufferedContent.length > 0) { + const existingIds = new Set((result.parts || []).map((p) => p.id)); + const merged = [...(result.parts || [])]; + for (const bp of bufferedContent) { + if (!existingIds.has(bp.id)) { + merged.push(bp); + } + } + result.parts = merged; + } + return result; } finally { this.activeSessions.delete(sessionId); diff --git a/electron/main/gateway/ws-server.ts b/electron/main/gateway/ws-server.ts index 361fa155..a7bdb6cf 100644 --- a/electron/main/gateway/ws-server.ts +++ b/electron/main/gateway/ws-server.ts @@ -44,6 +44,7 @@ import { type TeamCreateRequest, type TeamCancelRequest, type TeamGetRequest, + type TeamSendMessageRequest, } from "../../../src/types/unified"; import { isCodexServiceTier } from "../../../src/types/unified"; @@ -518,6 +519,12 @@ export class GatewayServer { return agentTeamService.cancelRun(req.runId); } + case GatewayRequestType.TEAM_SEND_MESSAGE: { + const req = p as TeamSendMessageRequest; + agentTeamService.sendMessageToRun(req.runId, req.text); + return; + } + case GatewayRequestType.TEAM_LIST: { return agentTeamService.listRuns(); } diff --git a/electron/main/services/agent-team/heavy-brain.ts b/electron/main/services/agent-team/heavy-brain.ts index ffcb3cdb..19fd7ec6 100644 --- a/electron/main/services/agent-team/heavy-brain.ts +++ b/electron/main/services/agent-team/heavy-brain.ts @@ -1,21 +1,39 @@ // ============================================================================ // Heavy Brain — Continuous LLM supervisor orchestration // A long-running orchestrator session dispatches tasks and adapts dynamically. +// Results are reported incrementally as each task completes. // ============================================================================ import type { EngineManager } from "../../gateway/engine-manager"; import type { TaskNode, TeamRun, EngineType } from "../../../../src/types/unified"; -import { DAGExecutor } from "./dag-executor"; -import { TaskExecutor, extractTextFromMessage } from "./task-executor"; -import { dispatchSkill, type DispatchInstruction, type DispatchTask } from "./skills"; -import { buildOrchestratorPrompt, formatTaskResults } from "./prompts"; +import { TaskExecutor, extractTextFromMessage, type TaskExecutionResult } from "./task-executor"; +import { dispatchSkill, type DispatchTask } from "./skills"; +import { buildOrchestratorPrompt, formatSingleTaskResult, formatTaskResults, formatUserMessage } from "./prompts"; import { agentTeamLog } from "./logger"; +import { UserChannel } from "./user-channel"; /** Maximum orchestration iterations to prevent infinite loops */ const MAX_ITERATIONS = 20; +/** + * Race a Map of promises: resolves with the first one to settle. + * Returns the key and result; the remaining promises keep running. + */ +function raceMap(map: Map>): Promise<{ id: string; result: T }> { + return Promise.race( + Array.from(map.entries()).map(([id, p]) => + p.then( + (result) => ({ id, result }), + (err) => ({ id, result: { sessionId: "", summary: "", error: err?.message ?? String(err) } as unknown as T }), + ), + ), + ); +} + export class HeavyBrainOrchestrator { private cancelled = false; + /** Shared user message channel — external code calls userChannel.send() */ + readonly userChannel = new UserChannel(); constructor( private engineManager: EngineManager, @@ -25,7 +43,7 @@ export class HeavyBrainOrchestrator { /** * Run Heavy Brain orchestration: * 1. Create orchestrator session - * 2. Loop: orchestrator dispatches → execute → send results back + * 2. Loop: orchestrator dispatches → execute with incremental results → decide next * 3. Until orchestrator signals "complete" or max iterations */ async run( @@ -40,27 +58,33 @@ export class HeavyBrainOrchestrator { teamRun.status = "planning"; agentTeamLog.info(`[${teamRun.id}] Heavy Brain: creating orchestrator session on ${engineType}`); - const orchSession = await this.engineManager.createSession(engineType, teamRun.directory); + const engines = this.engineManager.listEngines(); + const prompt = buildOrchestratorPrompt( + teamRun.originalPrompt, + engines, + teamRun.directory, + ); + + // Inject format spec + orchestrator role as system-level prompt. + const systemPrompt = `${dispatchSkill.formatPrompt}\n\n---\n\n${prompt}`; + + const orchSession = await this.engineManager.createSession( + engineType, + teamRun.directory, + undefined, + { systemPrompt }, + ); teamRun.orchestratorSessionId = orchSession.id; this.registerAutoApprove(orchSession.id); - const engines = this.engineManager.listEngines(); const taskExecutor = new TaskExecutor( this.engineManager, this.autoApproveSessions, defaultEngineType, ); - const dagExecutor = new DAGExecutor(taskExecutor, teamRun.directory); - dagExecutor.on("task.updated", ({ task }) => onTaskUpdated(task)); // --- Initial prompt --- - const prompt = buildOrchestratorPrompt( - teamRun.originalPrompt, - engines, - teamRun.directory, - ); - - // First message: skill format + orchestrator prompt + // Also send as user message for engines that don't support custom system prompts const fullPrompt = `${dispatchSkill.formatPrompt}\n\n---\n\n${prompt}`; let response = await this.engineManager.sendMessage(orchSession.id, [ { type: "text", text: fullPrompt }, @@ -108,7 +132,6 @@ export class HeavyBrainOrchestrator { // --- Handle "dispatch" --- if (data.action === "dispatch") { - // Add new tasks to the DAG const newTasks = this.convertDispatchTasks(data.tasks, taskCounter); taskCounter += newTasks.length; teamRun.tasks.push(...newTasks); @@ -117,14 +140,14 @@ export class HeavyBrainOrchestrator { `[${teamRun.id}] Heavy Brain: dispatching ${newTasks.length} tasks (iteration ${iterations})`, ); - // Execute ready tasks - await dagExecutor.executeReadyTasks(teamRun); - - // Send results back to orchestrator - const resultsText = formatTaskResults(teamRun); - response = await this.engineManager.sendMessage(orchSession.id, [ - { type: "text", text: resultsText }, - ]); + // Execute tasks and report results incrementally + response = await this.executeAndReportIncrementally( + teamRun, + newTasks, + orchSession.id, + taskExecutor, + onTaskUpdated, + ); } } @@ -141,15 +164,213 @@ export class HeavyBrainOrchestrator { } /** - * Signal cancellation of the orchestration loop. + * Execute tasks in parallel and report each result to the orchestrator + * as it completes. User messages (via UserChannel) are prioritized over + * task results. The orchestrator can: + * - dispatch new tasks (added to the running set) + * - signal complete (remaining tasks are cancelled) + * - acknowledge and wait (continueWaiting) + * + * Returns the last orchestrator response for the main loop to parse. */ - cancel(): void { - this.cancelled = true; + private async executeAndReportIncrementally( + teamRun: TeamRun, + initialTasks: TaskNode[], + orchSessionId: string, + executor: TaskExecutor, + onTaskUpdated: (task: TaskNode) => void, + ): Promise { + // Start all tasks in parallel + const running = new Map>(); + for (const task of initialTasks) { + task.status = "running"; + task.time = { started: Date.now() }; + onTaskUpdated(task); + running.set(task.id, executor.execute(task, teamRun.directory)); + } + + let lastResponse: import("../../../../src/types/unified").UnifiedMessage; + + // Process completions and user messages + while (running.size > 0 && !this.cancelled) { + // --- Priority 1: check for buffered user message --- + const pendingUserMsg = this.userChannel.takePending(); + if (pendingUserMsg) { + lastResponse = await this.sendToOrchestrator( + teamRun, orchSessionId, + formatUserMessage(pendingUserMsg, running.size), + ); + lastResponse = await this.handleOrchestratorResponse( + teamRun, orchSessionId, lastResponse, running, executor, onTaskUpdated, + ); + if (this.isTerminal(lastResponse)) return lastResponse; + continue; + } + + // --- Priority 2: race task completions vs user messages --- + type RaceResult = + | { type: "task"; id: string; result: TaskExecutionResult } + | { type: "user"; text: string }; + + const taskPromise = raceMap(running).then( + (r): RaceResult => ({ type: "task", ...r }), + ); + const userPromise = this.userChannel.waitForMessage().then( + (text): RaceResult => ({ type: "user", text }), + ); + + const winner = await Promise.race([userPromise, taskPromise]); + + if (winner.type === "user") { + // User message arrived — send to orchestrator immediately + agentTeamLog.info(`[${teamRun.id}] Human feedback received (${running.size} tasks running)`); + lastResponse = await this.sendToOrchestrator( + teamRun, orchSessionId, + formatUserMessage(winner.text, running.size), + ); + lastResponse = await this.handleOrchestratorResponse( + teamRun, orchSessionId, lastResponse, running, executor, onTaskUpdated, + ); + if (this.isTerminal(lastResponse)) return lastResponse; + continue; + } + + // --- Task completed --- + running.delete(winner.id); + + // Update task status + const task = teamRun.tasks.find((t) => t.id === winner.id); + if (task) { + task.time = { ...task.time, completed: Date.now() }; + task.sessionId = winner.result.sessionId; + if (winner.result.error) { + task.status = "failed"; + task.error = winner.result.error; + task.result = winner.result.summary; + } else { + task.status = "completed"; + task.result = winner.result.summary; + } + onTaskUpdated(task); + + agentTeamLog.info( + `[${teamRun.id}] Task ${winner.id} ${task.status} (${running.size} remaining)`, + ); + } + + // Send this task's result to orchestrator + const resultMsg = formatSingleTaskResult(task!, running.size); + lastResponse = await this.sendToOrchestrator(teamRun, orchSessionId, resultMsg); + lastResponse = await this.handleOrchestratorResponse( + teamRun, orchSessionId, lastResponse, running, executor, onTaskUpdated, + ); + if (this.isTerminal(lastResponse)) return lastResponse; + } + + // All tasks done — send final summary for orchestrator to decide + const summaryText = formatTaskResults(teamRun); + lastResponse = await this.engineManager.sendMessage(orchSessionId, [ + { type: "text", text: summaryText }, + ]); + + return lastResponse!; } /** - * Convert dispatch tasks to TaskNode format. + * Send a message to the orchestrator and return the response. */ + private async sendToOrchestrator( + teamRun: TeamRun, + orchSessionId: string, + text: string, + ): Promise { + return this.engineManager.sendMessage(orchSessionId, [ + { type: "text", text }, + ]); + } + + /** + * Parse an orchestrator response and handle dispatch/complete actions. + * Returns the (possibly updated) lastResponse. Mutates `running` if new + * tasks are dispatched or remaining tasks are cancelled. + */ + private async handleOrchestratorResponse( + teamRun: TeamRun, + orchSessionId: string, + lastResponse: import("../../../../src/types/unified").UnifiedMessage, + running: Map>, + executor: TaskExecutor, + onTaskUpdated: (task: TaskNode) => void, + ): Promise { + const responseText = extractTextFromMessage(lastResponse); + let instruction = dispatchSkill.parse(responseText); + + // If parse failed, try correction (strict protocol: no free-text allowed) + if (!instruction.ok) { + agentTeamLog.warn( + `[${teamRun.id}] Orchestrator response not valid JSON: ${instruction.error}`, + ); + const correction = dispatchSkill.correctionPrompt(responseText, instruction.error); + const retryResponse = await this.engineManager.sendMessage(orchSessionId, [ + { type: "text", text: correction }, + ]); + lastResponse = retryResponse; + instruction = dispatchSkill.parse(extractTextFromMessage(retryResponse)); + if (!instruction.ok) { + agentTeamLog.warn( + `[${teamRun.id}] Orchestrator correction also failed: ${instruction.error}`, + ); + return lastResponse; + } + } + + if (instruction.data.action === "complete") { + // Cancel remaining tasks + for (const remainId of running.keys()) { + const t = teamRun.tasks.find((t) => t.id === remainId); + if (t) { + t.status = "cancelled"; + t.time = { ...t.time, completed: Date.now() }; + onTaskUpdated(t); + } + } + running.clear(); + // Mark as terminal via _terminal flag so the caller knows to return + (lastResponse as any)._terminal = true; + return lastResponse; + } + + if (instruction.data.action === "dispatch") { + const newTasks = this.convertDispatchTasks( + instruction.data.tasks, + teamRun.tasks.length, + ); + teamRun.tasks.push(...newTasks); + for (const t of newTasks) { + t.status = "running"; + t.time = { started: Date.now() }; + onTaskUpdated(t); + running.set(t.id, executor.execute(t, teamRun.directory)); + } + agentTeamLog.info( + `[${teamRun.id}] Orchestrator dispatched ${newTasks.length} new tasks mid-execution`, + ); + } + + // action === "continueWaiting" → keep waiting + return lastResponse; + } + + /** Check if the orchestrator signaled completion */ + private isTerminal(response: import("../../../../src/types/unified").UnifiedMessage): boolean { + return !!(response as any)._terminal; + } + + cancel(): void { + this.cancelled = true; + this.userChannel.dispose(); + } + private convertDispatchTasks(tasks: DispatchTask[], startIndex: number): TaskNode[] { return tasks.map((t, i): TaskNode => ({ id: t.id || `task_${startIndex + i}`, diff --git a/electron/main/services/agent-team/index.ts b/electron/main/services/agent-team/index.ts index 43ab3b85..3d715deb 100644 --- a/electron/main/services/agent-team/index.ts +++ b/electron/main/services/agent-team/index.ts @@ -9,6 +9,7 @@ import { timeId } from "../../utils/id-gen"; import { agentTeamLog } from "./logger"; import { LightBrainOrchestrator } from "./light-brain"; import { HeavyBrainOrchestrator } from "./heavy-brain"; +import type { UserChannel } from "./user-channel"; import type { EngineManager } from "../../gateway/engine-manager"; import type { TeamRun, @@ -39,6 +40,8 @@ export class AgentTeamService extends EventEmitter { private autoApproveSessions = new Set(); /** Active Heavy Brain orchestrators (for cancellation). */ private activeOrchestrators = new Map(); + /** Active user channels for human-in-the-loop (both Light and Heavy Brain). */ + private activeUserChannels = new Map(); private initialized = false; // --- Lifecycle --- @@ -58,6 +61,7 @@ export class AgentTeamService extends EventEmitter { orchestrator.cancel(); } this.activeOrchestrators.clear(); + this.activeUserChannels.clear(); this.autoApproveSessions.clear(); this.initialized = false; agentTeamLog.info("Agent Team Service shut down"); @@ -140,6 +144,7 @@ export class AgentTeamService extends EventEmitter { orchestrator.cancel(); this.activeOrchestrators.delete(runId); } + this.activeUserChannels.delete(runId); // Cancel all running child sessions for (const task of run.tasks) { @@ -162,6 +167,26 @@ export class AgentTeamService extends EventEmitter { agentTeamLog.info(`Cancelled team run ${runId}`); } + /** + * Send a user message to a running orchestrator (Light or Heavy Brain). + * The message will be forwarded with highest priority. + */ + sendMessageToRun(runId: string, text: string): void { + const run = this.runs.get(runId); + if (!run) throw new Error(`Team run not found: ${runId}`); + if (run.status !== "running" && run.status !== "planning") { + throw new Error(`Team run ${runId} is not active (status: ${run.status})`); + } + + const channel = this.activeUserChannels.get(runId); + if (!channel) { + throw new Error(`No active user channel for run ${runId}`); + } + + channel.send(text); + agentTeamLog.info(`User message sent to run ${runId}`); + } + listRuns(): TeamRun[] { return Array.from(this.runs.values()); } @@ -178,6 +203,8 @@ export class AgentTeamService extends EventEmitter { this.emitRunUpdated(run); }; + const resolvedEngine = orchestratorEngineType ?? this.engineManager!.getDefaultEngineType(); + if (run.mode === "light") { const orchestrator = new LightBrainOrchestrator( this.engineManager!, @@ -190,10 +217,12 @@ export class AgentTeamService extends EventEmitter { this.autoApproveSessions, ); this.activeOrchestrators.set(run.id, orchestrator); + this.activeUserChannels.set(run.id, orchestrator.userChannel); try { - await orchestrator.run(run, orchestratorEngineType, onTaskUpdated); + await orchestrator.run(run, resolvedEngine, onTaskUpdated); } finally { this.activeOrchestrators.delete(run.id); + this.activeUserChannels.delete(run.id); } } diff --git a/electron/main/services/agent-team/light-brain.ts b/electron/main/services/agent-team/light-brain.ts index ce0e2f20..97a1439c 100644 --- a/electron/main/services/agent-team/light-brain.ts +++ b/electron/main/services/agent-team/light-brain.ts @@ -86,9 +86,20 @@ export class LightBrainOrchestrator { engines: EngineInfo[], ): Promise { // Create a temporary planning session + // Inject format spec + planner role as system-level prompt for engines + // that support it (e.g. Copilot). Also sent as user message for compatibility. + const prompt = buildPlanningPrompt( + teamRun.originalPrompt, + engines, + teamRun.directory, + ); + const systemPrompt = `${dagPlanningSkill.formatPrompt}\n\n---\n\n${prompt}`; + const planSession = await this.engineManager.createSession( plannerEngineType, teamRun.directory, + undefined, + { systemPrompt }, ); // Register for auto-approve @@ -99,22 +110,21 @@ export class LightBrainOrchestrator { } this.autoApproveSessions.add(planSession.id); - // Build planning prompt - const prompt = buildPlanningPrompt( - teamRun.originalPrompt, - engines, - teamRun.directory, - ); - // Execute with skill (includes format spec + self-check + retry) const sendMessage = async (text: string): Promise => { const msg = await this.engineManager.sendMessage(planSession.id, [ { type: "text", text }, ]); - return extractTextFromMessage(msg); + agentTeamLog.info(`[${teamRun.id}] sendMessage returned: role=${msg.role}, parts=${JSON.stringify(msg.parts?.map(p => ({ type: p.type, textLen: (p as any).text?.length })))}`); + const extracted = extractTextFromMessage(msg); + agentTeamLog.info(`[${teamRun.id}] extractTextFromMessage: ${extracted.length} chars`); + if (extracted.length === 0) { + agentTeamLog.warn(`[${teamRun.id}] Empty text! Full message: ${JSON.stringify(msg).slice(0, 1000)}`); + } + return extracted; }; - const result = await executeWithSkill(sendMessage, prompt, dagPlanningSkill); + const result = await executeWithSkill(sendMessage, prompt, dagPlanningSkill, 1, agentTeamLog); if (!result.ok) { throw new Error(`DAG planning failed: ${result.error}`); diff --git a/electron/main/services/agent-team/prompts.ts b/electron/main/services/agent-team/prompts.ts index f36f5ca4..e9a2dd48 100644 --- a/electron/main/services/agent-team/prompts.ts +++ b/electron/main/services/agent-team/prompts.ts @@ -2,7 +2,7 @@ // Prompt Templates — Engine-agnostic prompts for agent team orchestration // ============================================================================ -import type { EngineInfo, TeamRun } from "../../../../src/types/unified"; +import type { EngineInfo, TaskNode, TeamRun } from "../../../../src/types/unified"; /** * Format available engines list for inclusion in prompts. @@ -25,15 +25,22 @@ export function buildPlanningPrompt( engines: EngineInfo[], directory: string, ): string { - return `You are a task planner for a multi-engine AI coding assistant. Your job is to decompose the user's request into a directed acyclic graph (DAG) of subtasks that can be executed by different AI engines. + return `You are a **task decomposition agent**. Your only job is to analyze the user's request and output a JSON task plan. You do NOT execute any tasks yourself. You do NOT spawn subagents. -## Available Engines +An **external orchestration system** will read your JSON output, create separate sessions on other machines, and run each task independently. + +## Important +- You are producing a **plan**, not executing it. Do not attempt to carry out any tasks or spawn subagents. +- Your output will be machine-parsed. The external system creates separate agent sessions for each task and sends them the prompt you write. +- You may use tools to explore the project structure if that helps you write better task prompts, but your **final answer** must be the JSON task plan described in the Output Format above. + +## Available Worker Engines ${formatEngineList(engines)} ## Project Directory ${directory} -## Guidelines +## Planning Guidelines - Break complex tasks into smaller, focused subtasks - Use dependsOn to express task ordering (parallel tasks have no dependency) - Each task's prompt must be self-contained — the worker agent cannot see other tasks or the original request @@ -54,23 +61,31 @@ export function buildOrchestratorPrompt( engines: EngineInfo[], directory: string, ): string { - return `You are an orchestration supervisor managing a team of AI coding agents. You break down complex tasks, dispatch them to worker agents, review results, and coordinate follow-up work. + return `You are a **task decomposition and review agent**. Your only job is to break down work into subtasks by outputting JSON, then review results returned to you. + +You do NOT execute any tasks yourself. You do NOT spawn subagents. You do NOT call tools to perform the work. An **external orchestration system** reads the JSON you output, creates separate sessions on other machines, and runs each task independently. You will receive the results as your next message. + +## Your Workflow +1. Analyze the user request — you may use tools (read files, search code) to understand the project and write better task descriptions +2. Output a JSON block describing subtasks (see Communication Protocol above) — the external system handles execution +3. Results arrive incrementally — you receive each task's result as a separate message as soon as it completes +4. After each result, you can: output a "dispatch" JSON to add more tasks, output a "complete" JSON to finish early, or output "continueWaiting" to wait for more results +5. You may receive **human feedback** at any time — it takes priority over task results. Read it carefully and adjust your plan accordingly. +6. After all tasks finish, decide: dispatch another round of tasks, or output "complete" with a summary + +## Important Constraints +- **Never do the work yourself** — your role is solely to decide WHAT needs to be done and write task descriptions +- **Never spawn subagents or delegate via tools** — only output JSON; the external system handles all execution +- Your JSON output is machine-parsed, not human-read -## Available Engines +## Available Worker Engines ${formatEngineList(engines)} ## Project Directory ${directory} -## How It Works -1. You dispatch tasks to worker agents using the JSON protocol above -2. After workers complete, you receive their results as the next message -3. Review results and decide: dispatch more tasks, or mark complete -4. You can dispatch multiple rounds of tasks — each round can depend on prior results - -## Guidelines -- Start by analyzing the request and dispatching initial tasks -- Each worker runs in an isolated session — include all necessary context in the prompt +## Task Design Guidelines +- Each task runs in an isolated session with no shared context — include all necessary information in the task prompt - If a worker fails, you can retry with a modified prompt or different engine - When all work is done, use the "complete" action with a comprehensive summary @@ -110,3 +125,51 @@ export function formatTaskResults(run: TeamRun): string { return lines.join("\n"); } + +/** + * Format a single completed task result for incremental reporting (Heavy Brain). + */ +export function formatSingleTaskResult(task: TaskNode, remainingCount: number): string { + const duration = task.time?.started && task.time?.completed + ? ` (${((task.time.completed - task.time.started) / 1000).toFixed(1)}s)` + : ""; + + const lines: string[] = []; + + if (task.status === "completed") { + lines.push(`## Task Completed: ${task.id}${duration}`); + lines.push(`**${task.description}**\n`); + lines.push(task.result || "(no output)"); + } else if (task.status === "failed") { + lines.push(`## Task Failed: ${task.id}${duration}`); + lines.push(`**${task.description}**\n`); + lines.push(`Error: ${task.error || "unknown error"}`); + } + + lines.push(""); + if (remainingCount > 0) { + lines.push(`---\n${remainingCount} task(s) still running. You may output a JSON block to dispatch new tasks or mark complete, or just acknowledge to wait for more results.`); + } else { + lines.push(`---\nAll tasks have finished. Output a JSON block: dispatch more tasks, or mark complete with a summary.`); + } + + return lines.join("\n"); +} + +/** + * Format a human feedback message for injection into the orchestrator (Heavy Brain). + * Human feedback has higher priority than task results. + */ +export function formatUserMessage(text: string, remainingTasks: number): string { + const lines = [ + `## Human Feedback`, + ``, + text, + ``, + `---`, + remainingTasks > 0 + ? `${remainingTasks} task(s) still running. Respond with a JSON block.` + : `No tasks currently running. Respond with a JSON block.`, + ]; + return lines.join("\n"); +} diff --git a/electron/main/services/agent-team/skills.ts b/electron/main/services/agent-team/skills.ts index 0f2807b5..11d0f87d 100644 --- a/electron/main/services/agent-team/skills.ts +++ b/electron/main/services/agent-team/skills.ts @@ -39,22 +39,41 @@ export interface StructuredOutputSkill { export function extractJsonBlocks(text: string): string[] { const blocks: string[] = []; - // 1. Match ```json ... ``` fenced code blocks - const fenceRegex = /```(?:json)?\s*\n?([\s\S]*?)```/g; - let match: RegExpExecArray | null; - while ((match = fenceRegex.exec(text)) !== null) { - const content = match[1].trim(); - if (content.startsWith("{") || content.startsWith("[")) { - blocks.push(content); + // Strategy: find top-level JSON objects/arrays by bracket-balanced scanning. + // This handles JSON values that contain ``` fences (e.g. prompts with code blocks) + // which break naive regex-based fence matching. + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch !== "{" && ch !== "[") continue; + + const close = ch === "{" ? "}" : "]"; + let depth = 1; + let inString = false; + let escaped = false; + let j = i + 1; + + for (; j < text.length && depth > 0; j++) { + const c = text[j]; + if (escaped) { + escaped = false; + continue; + } + if (c === "\\") { + if (inString) escaped = true; + continue; + } + if (c === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (c === ch) depth++; + else if (c === close) depth--; } - } - // 2. If no fenced blocks found, try to find bare JSON - if (blocks.length === 0) { - // Match outermost { ... } or [ ... ] - const bareRegex = /(\{[\s\S]*\}|\[[\s\S]*\])/g; - while ((match = bareRegex.exec(text)) !== null) { - blocks.push(match[1].trim()); + if (depth === 0) { + blocks.push(text.slice(i, j)); + i = j - 1; // skip past this block } } @@ -197,7 +216,7 @@ export const dagPlanningSkill: StructuredOutputSkill = { formatPrompt: ` ## Output Format Requirements -You MUST output your task plan as a single JSON code block with the following schema: +Your **final answer** MUST be a single JSON code block with the following schema. This JSON will be parsed by an external orchestration system — it is not for human reading. \`\`\`json { @@ -222,7 +241,7 @@ Before writing the JSON block, verify ALL of the following: 4. No circular dependency chains (e.g. A depends on B, B depends on A) 5. Each prompt is self-contained — the worker agent CANNOT see other tasks or the original request 6. At least one task has dependsOn: [] (the DAG must have a root) -7. Output ONLY the JSON block — no additional text before or after +7. Your final answer is ONLY the JSON block — no additional text before or after `.trim(), parse(text: string) { @@ -252,7 +271,8 @@ export interface DispatchTask { export type DispatchInstruction = | { action: "dispatch"; tasks: DispatchTask[] } - | { action: "complete"; result: string }; + | { action: "complete"; result: string } + | { action: "continueWaiting" }; function validateDispatchInstruction( data: unknown, @@ -270,6 +290,10 @@ function validateDispatchInstruction( return { ok: true, data: { action: "complete", result: obj.result } }; } + if (obj.action === "continueWaiting") { + return { ok: true, data: { action: "continueWaiting" } }; + } + if (obj.action === "dispatch") { if (!Array.isArray(obj.tasks) || obj.tasks.length === 0) { return { ok: false, error: "action 'dispatch' requires a non-empty 'tasks' array." }; @@ -310,7 +334,7 @@ function validateDispatchInstruction( return { ok: false, - error: `Unknown action '${String(obj.action)}'. Expected 'dispatch' or 'complete'.`, + error: `Unknown action '${String(obj.action)}'. Expected 'dispatch', 'complete', or 'continue'.`, }; } @@ -320,9 +344,9 @@ export const dispatchSkill: StructuredOutputSkill = { formatPrompt: ` ## Communication Protocol -You MUST communicate your decisions via JSON code blocks. Two actions are available: +You communicate your decisions via JSON code blocks. This JSON is parsed by an external orchestration system — it is not for human reading. Every response MUST be a single JSON block with one of these actions: -### 1. Dispatch tasks to worker agents: +### 1. Dispatch new tasks: \`\`\`json { "action": "dispatch", @@ -345,13 +369,20 @@ You MUST communicate your decisions via JSON code blocks. Two actions are availa } \`\`\` +### 3. Acknowledge and wait for more results: +\`\`\`json +{ + "action": "continueWaiting" +} +\`\`\` + ## Self-Check Before Outputting (MANDATORY) 1. JSON syntax is valid -2. action is either "dispatch" or "complete" +2. action is "dispatch", "complete", or "continueWaiting" 3. If dispatch: every task has id, description, and a detailed self-contained prompt 4. If complete: result contains a meaningful summary of all work done -5. Output ONLY the JSON block — no additional text before or after +5. Your response is ONLY the JSON block — no additional text before or after `.trim(), parse(text: string) { @@ -363,8 +394,8 @@ You MUST communicate your decisions via JSON code blocks. Two actions are availa correctionPrompt(rawText: string, error: string) { return ( `Your previous output had a format error:\n${error}\n\n` + - `Please output ONLY the corrected JSON block. Use either ` + - `{ "action": "dispatch", "tasks": [...] } or { "action": "complete", "result": "..." }.` + `Please output ONLY the corrected JSON block. Use one of: ` + + `{ "action": "dispatch", "tasks": [...] }, { "action": "complete", "result": "..." }, or { "action": "continueWaiting" }.` ); }, }; @@ -386,21 +417,30 @@ export async function executeWithSkill( prompt: string, skill: StructuredOutputSkill, maxRetries = 1, + log?: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }, ): Promise<{ ok: true; data: T } | { ok: false; error: string }> { // First attempt: send prompt with skill format instructions const fullPrompt = `${skill.formatPrompt}\n\n---\n\n${prompt}`; const responseText = await sendMessage(fullPrompt); + log?.info(`[${skill.name}] LLM response (${responseText.length} chars): ${responseText.slice(0, 500)}${responseText.length > 500 ? "..." : ""}`); + const result = skill.parse(responseText); if (result.ok) return result; + log?.warn(`[${skill.name}] Parse failed: ${result.error}`); + // Retry with correction prompt + let lastError = result.error; for (let i = 0; i < maxRetries; i++) { - const correction = skill.correctionPrompt(responseText, result.error); + const correction = skill.correctionPrompt(responseText, lastError); const retryText = await sendMessage(correction); + log?.info(`[${skill.name}] Retry ${i + 1} response (${retryText.length} chars): ${retryText.slice(0, 500)}${retryText.length > 500 ? "..." : ""}`); const retryResult = skill.parse(retryText); if (retryResult.ok) return retryResult; + lastError = retryResult.error; + log?.warn(`[${skill.name}] Retry ${i + 1} parse failed: ${lastError}`); } - return result; // Return last failure + return { ok: false, error: lastError }; } diff --git a/electron/main/services/agent-team/user-channel.ts b/electron/main/services/agent-team/user-channel.ts new file mode 100644 index 00000000..e11161e8 --- /dev/null +++ b/electron/main/services/agent-team/user-channel.ts @@ -0,0 +1,76 @@ +// ============================================================================ +// UserChannel — Shared human-in-the-loop message channel for orchestrators. +// Allows users to inject messages into any orchestration loop (Light/Heavy Brain). +// The orchestration loop races user messages against task completions. +// ============================================================================ + +/** + * A channel for receiving user messages during orchestration. + * The orchestrator creates a channel, then races `waitForMessage()` against + * task completions. External code calls `send()` to inject a user message. + */ +export class UserChannel { + /** Pending resolve callback — set when the orchestrator is waiting */ + private waitResolve: ((text: string) => void) | null = null; + /** Buffered message — set when a message arrives while not waiting */ + private pendingMessage: string | null = null; + + /** + * Send a user message into the channel. + * If the orchestrator is currently waiting (in Promise.race), resolves immediately. + * Otherwise buffers until the next waitForMessage() call. + */ + send(text: string): void { + if (this.waitResolve) { + const resolve = this.waitResolve; + this.waitResolve = null; + resolve(text); + } else { + // Buffer — next waitForMessage() will return immediately + this.pendingMessage = text; + } + } + + /** + * Check if there's a buffered message without consuming it. + */ + hasPending(): boolean { + return this.pendingMessage !== null; + } + + /** + * Consume and return the buffered message, if any. + */ + takePending(): string | null { + const msg = this.pendingMessage; + this.pendingMessage = null; + return msg; + } + + /** + * Returns a promise that resolves when a user message arrives. + * Used in Promise.race alongside task completion promises. + * If a message is already buffered, resolves immediately. + */ + waitForMessage(): Promise { + // Return buffered message immediately + if (this.pendingMessage !== null) { + const msg = this.pendingMessage; + this.pendingMessage = null; + return Promise.resolve(msg); + } + + return new Promise((resolve) => { + this.waitResolve = resolve; + }); + } + + /** + * Cancel any pending wait (e.g. when orchestration ends). + * Does not reject — just clears the callback so GC can collect the promise. + */ + dispose(): void { + this.waitResolve = null; + this.pendingMessage = null; + } +} diff --git a/src/components/PromptInput.tsx b/src/components/PromptInput.tsx index bc4b1864..e10f10b1 100644 --- a/src/components/PromptInput.tsx +++ b/src/components/PromptInput.tsx @@ -136,6 +136,8 @@ interface PromptInputProps { onCommandInvoke?: (commandName: string, args: string, agent: AgentMode) => void; /** Called when user triggers a team run */ onTeamSend?: (text: string, mode: "light" | "heavy") => void; + /** When true, prompt sends are relayed to the active Heavy Brain orchestrator */ + relayToOrchestrator?: boolean; } export function PromptInput(props: PromptInputProps) { @@ -180,6 +182,7 @@ export function PromptInput(props: PromptInputProps) { /** Parse the current text: detect `/command args` prefix */ const commandQuery = createMemo(() => { + if (props.relayToOrchestrator) return null; const val = text(); if (!val.startsWith("/")) return null; // Only trigger for single-line prefix (no newlines before command) @@ -415,7 +418,7 @@ export function PromptInput(props: PromptInputProps) { const doSend = () => { const trimmed = text().trim(); // Detect slash command: text starts with / and onCommandInvoke is provided - if (trimmed.startsWith("/") && props.onCommandInvoke) { + if (!props.relayToOrchestrator && trimmed.startsWith("/") && props.onCommandInvoke) { const spaceIdx = trimmed.indexOf(" "); const commandName = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx); const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); @@ -452,6 +455,9 @@ export function PromptInput(props: PromptInputProps) { // Placeholder text based on active mode and generating state const modePlaceholder = createMemo(() => { + if (props.relayToOrchestrator) { + return t().prompt.teamRelayPlaceholder; + } if (props.isGenerating) { if (props.canEnqueue) return t().prompt.typeNextMessage ?? "Type your next message..."; return t().prompt.waitingForResponse ?? "Waiting for response..."; @@ -479,12 +485,13 @@ export function PromptInput(props: PromptInputProps) { return (
+ +
+ {t().prompt.teamRelayNotice} +
+
+ {/* Input area */}
{ + return gatewayClient.sendTeamMessage(runId, text); + } + listTeamRuns(): Promise { return gatewayClient.listTeamRuns(); } diff --git a/src/lib/gateway-client.ts b/src/lib/gateway-client.ts index 7882326a..458062b7 100644 --- a/src/lib/gateway-client.ts +++ b/src/lib/gateway-client.ts @@ -618,6 +618,10 @@ export class GatewayClient { return this.request(GatewayRequestType.TEAM_CANCEL, { runId }); } + sendTeamMessage(runId: string, text: string): Promise { + return this.request(GatewayRequestType.TEAM_SEND_MESSAGE, { runId, text }); + } + listTeamRuns(): Promise { return this.request(GatewayRequestType.TEAM_LIST); } diff --git a/src/locales/en.ts b/src/locales/en.ts index b0c862ae..1a1d4df8 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -237,6 +237,9 @@ export interface LocaleDict { imageUnsupportedType: string; imageLimitReached: string; removeImage: string; + teamRelayPlaceholder: string; + teamRelayNotice: string; + teamRelayImageUnsupported: string; /** Slash command autocomplete: no matching commands */ noCommandsFound: string; reasoningEffortLow: string; @@ -498,6 +501,9 @@ export interface LocaleDict { gatewayReconnected: string; engineError: string; defaultEngineSaveFailed: string; + teamRunStarted: string; + teamRunFailed: string; + teamMessageRelayFailed: string; }; // File Explorer @@ -855,6 +861,9 @@ export const en: LocaleDict = { imageUnsupportedType: "Unsupported image type", imageLimitReached: "Maximum 4 images per message", removeImage: "Remove image", + teamRelayPlaceholder: "Message the active orchestrator...", + teamRelayNotice: "Heavy Brain is active - messages from this session go directly to the orchestrator.", + teamRelayImageUnsupported: "Image attachments are not supported while messaging the active orchestrator.", noCommandsFound: "No commands found", reasoningEffortLow: "Low", reasoningEffortMedium: "Medium", @@ -1111,6 +1120,9 @@ export const en: LocaleDict = { gatewayReconnected: "Connection restored.", engineError: "Engine error: {message}", defaultEngineSaveFailed: "Failed to save the default engine.", + teamRunStarted: "Team run started.", + teamRunFailed: "Team run failed: {message}", + teamMessageRelayFailed: "Failed to send the message to the active orchestrator: {message}", }, // File Explorer diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 79117815..bd04b708 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -239,6 +239,9 @@ export const ru: LocaleDict = { imageUnsupportedType: "Неподдерживаемый формат изображения", imageLimitReached: "Максимум 4 изображения на сообщение", removeImage: "Удалить изображение", + teamRelayPlaceholder: "Напишите активному оркестратору...", + teamRelayNotice: "Heavy Brain активен — сообщения из этой сессии отправляются напрямую оркестратору.", + teamRelayImageUnsupported: "При отправке сообщения активному оркестратору вложения изображений не поддерживаются.", noCommandsFound: "Команды не найдены", reasoningEffortLow: "Низкий", reasoningEffortMedium: "Средний", @@ -499,6 +502,9 @@ export const ru: LocaleDict = { gatewayReconnected: "Соединение восстановлено.", engineError: "Ошибка движка: {message}", defaultEngineSaveFailed: "Не удалось сохранить движок по умолчанию.", + teamRunStarted: "Team run запущен.", + teamRunFailed: "Не удалось запустить team run: {message}", + teamMessageRelayFailed: "Не удалось отправить сообщение активному оркестратору: {message}", }, // File Explorer diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 5f739b93..a52ad10d 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -238,6 +238,9 @@ export const zh: LocaleDict = { imageUnsupportedType: "不支持的图片格式", imageLimitReached: "每条消息最多 4 张图片", removeImage: "移除图片", + teamRelayPlaceholder: "直接给当前 orchestrator 留言...", + teamRelayNotice: "Heavy Brain 正在运行中;你在这个 session 发送的消息会直接转发给 orchestrator。", + teamRelayImageUnsupported: "直接给 orchestrator 发消息时暂不支持图片附件。", noCommandsFound: "未找到匹配的命令", reasoningEffortLow: "低", reasoningEffortMedium: "中", @@ -496,6 +499,9 @@ export const zh: LocaleDict = { gatewayReconnected: "连接已恢复。", engineError: "引擎错误:{message}", defaultEngineSaveFailed: "默认引擎保存失败。", + teamRunStarted: "Team run 已启动。", + teamRunFailed: "Team run 启动失败:{message}", + teamMessageRelayFailed: "转发消息给当前 orchestrator 失败:{message}", }, // File Explorer diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index 0d3690a0..b306d62d 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -56,7 +56,15 @@ import { handleFileChanged, refreshGitStatus } from "../stores/file"; import { configStore, setConfigStore, getSelectedModelForEngine, isEngineEnabled, getDefaultEngineType, getEffectiveReasoningEffortForEngine, getServiceTierForEngine } from "../stores/config"; import { scheduledTaskStore, setScheduledTaskStore } from "../stores/scheduled-task"; -import { connectTeamHandlers, createTeamRun, getTeamRunForSession, cancelTeamRun } from "../stores/team"; +import { + connectTeamHandlers, + createTeamRun, + getActiveHeavyTeamRunForSession, + getActiveTeamRunForSession, + getTeamRunForSession, + cancelTeamRun, + sendTeamRunMessage, +} from "../stores/team"; import { teamStore } from "../stores/team"; import { computeActiveSessions } from "../lib/active-sessions"; @@ -1737,12 +1745,21 @@ export default function Chat() { if (!sessionId) return; const session = sessionStore.list.find(s => s.id === sessionId); const directory = session?.directory || "."; + if (getActiveTeamRunForSession(sessionId)) { + return; + } try { await createTeamRun(sessionId, text, mode, directory, currentEngineType()); - notify("Team run started", "info", 3000); + notify(t().notification.teamRunStarted, "info", 3000); } catch (err: any) { logger.error("[TeamRun] Failed to create:", err); - notify(`Team run failed: ${err.message}`, "error", 5000); + notify( + formatMessage(t().notification.teamRunFailed, { + message: err?.message ?? String(err), + }), + "error", + 5000, + ); } }; @@ -1752,6 +1769,18 @@ export default function Chat() { return getTeamRunForSession(sid); }); + const activeTeamRun = createMemo(() => { + const sid = sessionStore.current; + if (!sid) return undefined; + return getActiveTeamRunForSession(sid); + }); + + const activeHeavyRelayRun = createMemo(() => { + const sid = sessionStore.current; + if (!sid) return undefined; + return getActiveHeavyTeamRunForSession(sid); + }); + const handleCancelTeamRun = async () => { const run = currentTeamRun(); if (!run) return; @@ -1762,10 +1791,77 @@ export default function Chat() { } }; + const appendOptimisticUserText = (sessionId: string, text: string): string => { + const nonce = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const tempMessageId = `msg-temp-${nonce}`; + const tempPartId = `part-temp-${nonce}`; + + const tempMessageInfo: UnifiedMessage = { + id: tempMessageId, + sessionId, + role: "user", + time: { + created: Date.now(), + }, + parts: [], + }; + + const tempPart: UnifiedPart = { + id: tempPartId, + messageId: tempMessageId, + sessionId, + type: "text", + text, + } as UnifiedPart; + + const messages = messageStore.message[sessionId] || []; + const tempExists = messages.some((m) => m.id === tempMessageId); + if (!tempExists) { + setMessageStore("message", sessionId, (draft) => [...draft, tempMessageInfo]); + } + + setMessageStore("part", tempMessageId, [tempPart]); + setUserScrolledUp(false); + setTimeout(() => scrollToBottom(), 0); + + return tempMessageId; + }; + + const removeOptimisticMessage = (sessionId: string, messageId: string) => { + setMessageStore("message", sessionId, (draft) => + draft.filter((m) => m.id !== messageId), + ); + setMessageStore("part", messageId, undefined as any); + }; + const handleSendMessage = async (text: string, agent: AgentMode, images?: import("../types/unified").ImageAttachment[]) => { const sessionId = sessionStore.current; if (!sessionId) return; + const relayRun = activeHeavyRelayRun(); + if (relayRun) { + if (images && images.length > 0) { + showSendError(t().prompt.teamRelayImageUnsupported); + return; + } + + const tempMessageId = appendOptimisticUserText(sessionId, text); + try { + await sendTeamRunMessage(relayRun.id, text); + } catch (error) { + logger.error("[TeamRun] Failed to relay message:", error); + notify( + formatMessage(t().notification.teamMessageRelayFailed, { + message: error instanceof Error ? error.message : String(error), + }), + "error", + 5000, + ); + removeOptimisticMessage(sessionId, tempMessageId); + } + return; + } + // Allow sending when idle, or when generating if engine supports enqueue const isBusy = sending(); if (isBusy && !canEnqueue()) return; @@ -1786,7 +1882,6 @@ export default function Chat() { const reasoningEffort = getEffectiveReasoningEffortForEngine(currentEngineType()); const serviceTier = getServiceTierForEngine(currentEngineType()); const tempMessageId = `msg-temp-${Date.now()}`; - const tempPartId = `part-temp-${Date.now()}`; // --- Enqueue path: fire-and-forget --- // When the engine is busy and supports enqueue, we must NOT await the RPC. @@ -1828,38 +1923,7 @@ export default function Chat() { } // --- Normal path: create temp user message and await the RPC --- - const tempMessageInfo: UnifiedMessage = { - id: tempMessageId, - sessionId: sessionId, - role: "user", - time: { - created: Date.now(), - }, - parts: [], - }; - - const tempPart: UnifiedPart = { - id: tempPartId, - messageId: tempMessageId, - sessionId: sessionId, - type: "text", - text, - } as UnifiedPart; - - const messages = messageStore.message[sessionId] || []; - - // User temp messages are always the newest — append to end. - // Don't use binarySearch here: engine message IDs (e.g. UUID from OpenCode) - // may sort before "msg-temp-" in lexicographic order, causing the user message - // to land after all assistant messages and breaking turn grouping. - const tempExists = messages.some(m => m.id === tempMessageId); - if (!tempExists) { - setMessageStore("message", sessionId, (draft) => [...draft, tempMessageInfo]); - } - - setMessageStore("part", tempMessageId, [tempPart]); - setUserScrolledUp(false); - setTimeout(() => scrollToBottom(), 0); + const optimisticMessageId = appendOptimisticUserText(sessionId, text); try { await gateway.sendMessage(sessionId, text, { @@ -1884,10 +1948,7 @@ export default function Chat() { logger.error("[SendMessage] Failed to send message:", error); notify(t().notification.messageSendFailed); // Remove the optimistic temp message on failure - setMessageStore("message", sessionId, (draft) => - draft.filter((m) => m.id !== tempMessageId), - ); - setMessageStore("part", tempMessageId, undefined as any); + removeOptimisticMessage(sessionId, optimisticMessageId); setSendingFor(sessionId, false); } }; @@ -2353,6 +2414,11 @@ export default function Chat() {
+ +
+ {t().prompt.teamRelayNotice} +
+
{run().finalResult} @@ -2373,10 +2439,14 @@ export default function Chat() { onAgentChange={setCurrentAgent} availableModes={configStore.engines.find(e => e.type === currentEngineType())?.capabilities?.availableModes} disabled={!sessionStore.current} - imageAttachmentEnabled={configStore.engines.find(e => e.type === currentEngineType())?.capabilities?.imageAttachment ?? false} + imageAttachmentEnabled={ + (configStore.engines.find(e => e.type === currentEngineType())?.capabilities?.imageAttachment ?? false) + && !activeHeavyRelayRun() + } availableCommands={availableCommands()} onCommandInvoke={handleCommandInvoke} - onTeamSend={handleTeamSend} + onTeamSend={activeTeamRun() ? undefined : handleTeamSend} + relayToOrchestrator={!!activeHeavyRelayRun()} />
diff --git a/src/stores/team.ts b/src/stores/team.ts index c0987ea1..874ca7ed 100644 --- a/src/stores/team.ts +++ b/src/stores/team.ts @@ -20,6 +20,23 @@ const [teamStore, setTeamStore] = createStore({ export { teamStore }; +function isActiveTeamRun(run: TeamRun): boolean { + return run.status === "planning" || run.status === "running"; +} + +function pickPreferredRun(runs: TeamRun[]): TeamRun | undefined { + if (runs.length === 0) return undefined; + return runs.reduce((best, run) => { + if (!best) return run; + const bestActive = isActiveTeamRun(best); + const runActive = isActiveTeamRun(run); + if (runActive !== bestActive) { + return runActive ? run : best; + } + return run.time.created > best.time.created ? run : best; + }, runs[0]); +} + /** Initialize notification handlers for team events */ export function initTeamStore(): void { // These handlers are set during gateway-api initialization @@ -85,7 +102,28 @@ export async function cancelTeamRun(runId: string): Promise { /** Get the active team run for a given session */ export function getTeamRunForSession(sessionId: string): TeamRun | undefined { - return teamStore.runs.find((r) => r.parentSessionId === sessionId); + return pickPreferredRun(teamStore.runs.filter((r) => r.parentSessionId === sessionId)); +} + +/** Get the active team run for a given session, if any */ +export function getActiveTeamRunForSession(sessionId: string): TeamRun | undefined { + return pickPreferredRun( + teamStore.runs.filter((r) => r.parentSessionId === sessionId && isActiveTeamRun(r)), + ); +} + +/** Get the active Heavy Brain run for a given session, if any */ +export function getActiveHeavyTeamRunForSession(sessionId: string): TeamRun | undefined { + return pickPreferredRun( + teamStore.runs.filter( + (r) => r.parentSessionId === sessionId && r.mode === "heavy" && isActiveTeamRun(r), + ), + ); +} + +/** Send a user follow-up message to an active team run orchestrator */ +export async function sendTeamRunMessage(runId: string, text: string): Promise { + await gateway.sendTeamMessage(runId, text); } /** Get team run by ID */ diff --git a/src/types/unified.ts b/src/types/unified.ts index a52385ec..2eca0bb4 100644 --- a/src/types/unified.ts +++ b/src/types/unified.ts @@ -639,6 +639,7 @@ export const GatewayRequestType = { // Agent Team TEAM_CREATE: "team.create", TEAM_CANCEL: "team.cancel", + TEAM_SEND_MESSAGE: "team.send-message", TEAM_LIST: "team.list", TEAM_GET: "team.get", } as const; @@ -1044,3 +1045,8 @@ export interface TeamCancelRequest { export interface TeamGetRequest { runId: string; } + +export interface TeamSendMessageRequest { + runId: string; + text: string; +} diff --git a/tests/unit/src/stores/team.test.ts b/tests/unit/src/stores/team.test.ts new file mode 100644 index 00000000..c3124d09 --- /dev/null +++ b/tests/unit/src/stores/team.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const gatewayMock = vi.hoisted(() => ({ + createTeamRun: vi.fn(), + cancelTeamRun: vi.fn(), + sendTeamMessage: vi.fn(), +})); + +const { storeContainer } = vi.hoisted(() => { + const storeContainer: { data: any; setter: any } = { data: {}, setter: null }; + return { storeContainer }; +}); + +vi.mock("../../../../src/lib/gateway-api", () => ({ + gateway: gatewayMock, +})); + +vi.mock("solid-js/store", () => ({ + createStore: vi.fn((initial: any) => { + Object.keys(storeContainer.data).forEach((key) => delete storeContainer.data[key]); + Object.assign(storeContainer.data, initial); + + storeContainer.setter = (pathOrValue: any, ...args: any[]) => { + if (args.length === 0) { + Object.keys(storeContainer.data).forEach((key) => delete storeContainer.data[key]); + Object.assign(storeContainer.data, pathOrValue); + return; + } + + if (args.length === 1) { + const next = typeof args[0] === "function" + ? args[0](storeContainer.data[pathOrValue]) + : args[0]; + storeContainer.data[pathOrValue] = next; + return; + } + + if (!storeContainer.data[pathOrValue] || typeof storeContainer.data[pathOrValue] !== "object") { + storeContainer.data[pathOrValue] = {}; + } + storeContainer.data[pathOrValue] = { + ...storeContainer.data[pathOrValue], + [args[0]]: args[1], + }; + }; + + return [storeContainer.data, storeContainer.setter]; + }), +})); + +import type { TeamRun } from "../../../../src/types/unified"; +import { + connectTeamHandlers, + getActiveHeavyTeamRunForSession, + getActiveTeamRunForSession, + getTeamRunForSession, + sendTeamRunMessage, + teamStore, +} from "../../../../src/stores/team"; + +function makeRun(overrides: Partial = {}): TeamRun { + return { + id: "team-1", + parentSessionId: "session-1", + directory: "/repo", + originalPrompt: "Investigate issue", + mode: "heavy", + status: "completed", + tasks: [], + time: { created: 1 }, + ...overrides, + }; +} + +describe("team store selectors", () => { + beforeEach(() => { + vi.clearAllMocks(); + teamStore.runs.splice(0, teamStore.runs.length); + teamStore.activeRunId = null; + }); + + describe("getTeamRunForSession", () => { + it("prefers an active run over a newer completed run", () => { + const handlers = connectTeamHandlers(); + const activeRun = makeRun({ + id: "team-active", + status: "running", + time: { created: 10 }, + }); + const completedRun = makeRun({ + id: "team-complete", + status: "completed", + time: { created: 20 }, + }); + + handlers.onTeamRunUpdated(completedRun); + handlers.onTeamRunUpdated(activeRun); + + expect(getTeamRunForSession("session-1")?.id).toBe("team-active"); + }); + + it("falls back to the newest run when none are active", () => { + const handlers = connectTeamHandlers(); + handlers.onTeamRunUpdated(makeRun({ id: "team-old", time: { created: 10 } })); + handlers.onTeamRunUpdated(makeRun({ id: "team-new", time: { created: 20 } })); + + expect(getTeamRunForSession("session-1")?.id).toBe("team-new"); + }); + }); + + describe("active team run helpers", () => { + it("returns the active run for the session", () => { + const handlers = connectTeamHandlers(); + handlers.onTeamRunUpdated(makeRun({ id: "team-complete", status: "completed" })); + handlers.onTeamRunUpdated( + makeRun({ id: "team-light", mode: "light", status: "planning", time: { created: 5 } }), + ); + + expect(getActiveTeamRunForSession("session-1")?.id).toBe("team-light"); + }); + + it("returns only active heavy runs for orchestrator relays", () => { + const handlers = connectTeamHandlers(); + handlers.onTeamRunUpdated( + makeRun({ id: "team-light", mode: "light", status: "running", time: { created: 30 } }), + ); + handlers.onTeamRunUpdated( + makeRun({ id: "team-heavy-old", mode: "heavy", status: "completed", time: { created: 40 } }), + ); + handlers.onTeamRunUpdated( + makeRun({ id: "team-heavy-active", mode: "heavy", status: "running", time: { created: 20 } }), + ); + + expect(getActiveHeavyTeamRunForSession("session-1")?.id).toBe("team-heavy-active"); + }); + }); + + describe("sendTeamRunMessage", () => { + it("delegates to the gateway team message API", async () => { + await sendTeamRunMessage("team-123", "Need a tighter plan"); + + expect(gatewayMock.sendTeamMessage).toHaveBeenCalledWith("team-123", "Need a tighter plan"); + }); + }); +}); From d9dc8fc14c339a18280a8b017423f245b75124cc Mon Sep 17 00:00:00 2001 From: FridayLiu Date: Sun, 19 Apr 2026 10:04:33 +0000 Subject: [PATCH 3/6] Harden agent-team orchestration Implement the agent-team hardening roadmap: DAG-aware heavy execution, real cancellation, planner engine selection, persisted run recovery, inactivity guardrails, minimal observability UI, and protocol-level test coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- electron/main/engines/codex/index.ts | 12 +- .../main/services/agent-team/dag-executor.ts | 18 +- .../main/services/agent-team/guardrails.ts | 4 + .../main/services/agent-team/heavy-brain.ts | 783 +++++++++++++----- electron/main/services/agent-team/index.ts | 254 +++++- .../main/services/agent-team/light-brain.ts | 26 +- electron/main/services/agent-team/prompts.ts | 6 +- electron/main/services/agent-team/skills.ts | 37 +- .../main/services/agent-team/task-executor.ts | 282 ++++++- src/locales/en.ts | 32 + src/locales/ru.ts | 16 + src/locales/zh.ts | 16 + src/pages/Chat.tsx | 328 +++++--- src/stores/team.ts | 39 +- src/types/unified.ts | 2 + tests/unit/electron/gateway/ws-server.test.ts | 149 ++++ .../services/agent-team/dag-executor.test.ts | 50 ++ .../services/agent-team/heavy-brain.test.ts | 527 ++++++++++++ .../services/agent-team/index.test.ts | 293 +++++++ .../services/agent-team/light-brain.test.ts | 94 +++ .../services/agent-team/skills.test.ts | 45 + .../services/agent-team/task-executor.test.ts | 135 +++ tests/unit/src/lib/gateway-api.test.ts | 66 ++ tests/unit/src/stores/team.test.ts | 37 + 24 files changed, 2829 insertions(+), 422 deletions(-) create mode 100644 electron/main/services/agent-team/guardrails.ts create mode 100644 tests/unit/electron/services/agent-team/heavy-brain.test.ts create mode 100644 tests/unit/electron/services/agent-team/index.test.ts create mode 100644 tests/unit/electron/services/agent-team/light-brain.test.ts create mode 100644 tests/unit/electron/services/agent-team/task-executor.test.ts diff --git a/electron/main/engines/codex/index.ts b/electron/main/engines/codex/index.ts index 862bbffe..2d61743c 100644 --- a/electron/main/engines/codex/index.ts +++ b/electron/main/engines/codex/index.ts @@ -366,16 +366,22 @@ export class CodexAdapter extends EngineAdapter { const normalizedDirectory = normalizeDirectory(directory); const customSystemPrompt = (meta?.systemPrompt && typeof meta.systemPrompt === "string") ? meta.systemPrompt : undefined; const existingThreadId = resolveThreadId(undefined, meta); + const startThread = () => + customSystemPrompt + ? this.startThread(normalizedDirectory, customSystemPrompt) + : this.startThread(normalizedDirectory); let threadResponse: ThreadResponse; if (existingThreadId) { try { - threadResponse = await this.resumeThread(existingThreadId, normalizedDirectory, customSystemPrompt); + threadResponse = customSystemPrompt + ? await this.resumeThread(existingThreadId, normalizedDirectory, customSystemPrompt) + : await this.resumeThread(existingThreadId, normalizedDirectory); } catch (error) { codexLog.warn(`Failed to resume Codex thread ${existingThreadId}, starting a new one instead:`, error); - threadResponse = await this.startThread(normalizedDirectory, customSystemPrompt); + threadResponse = await startThread(); } } else { - threadResponse = await this.startThread(normalizedDirectory, customSystemPrompt); + threadResponse = await startThread(); } const threadId = threadResponse.thread?.id; diff --git a/electron/main/services/agent-team/dag-executor.ts b/electron/main/services/agent-team/dag-executor.ts index 897acad3..9e6e69ad 100644 --- a/electron/main/services/agent-team/dag-executor.ts +++ b/electron/main/services/agent-team/dag-executor.ts @@ -7,6 +7,7 @@ import { EventEmitter } from "events"; import type { TaskNode, TeamRun } from "../../../../src/types/unified"; import { TaskExecutor } from "./task-executor"; +import { AGENT_TEAM_MAX_CONCURRENT_TASKS } from "./guardrails"; export interface DAGExecutorEvents { /** A task's status changed */ @@ -22,6 +23,7 @@ export class DAGExecutor extends EventEmitter { constructor( private taskExecutor: TaskExecutor, private directory: string, + private maxConcurrentTasks = AGENT_TEAM_MAX_CONCURRENT_TASKS, ) { super(); } @@ -38,10 +40,10 @@ export class DAGExecutor extends EventEmitter { const executedTasks: TaskNode[] = []; while (true) { - const ready = this.findReadyTasks(run.tasks); + const ready = DAGExecutor.findReadyTasks(run.tasks).slice(0, this.maxConcurrentTasks); if (ready.length === 0) break; - // Execute all ready tasks in parallel + // Execute the next ready batch up to the concurrency limit. const results = await Promise.allSettled( ready.map((task) => this.runSingleTask(run, task)), ); @@ -70,7 +72,7 @@ export class DAGExecutor extends EventEmitter { } // Propagate failures: mark downstream tasks as blocked - this.propagateFailures(run.tasks); + DAGExecutor.propagateFailures(run.tasks); } return executedTasks; @@ -81,7 +83,7 @@ export class DAGExecutor extends EventEmitter { * - status is "pending" * - all dependencies are "completed" */ - private findReadyTasks(tasks: TaskNode[]): TaskNode[] { + static findReadyTasks(tasks: TaskNode[]): TaskNode[] { return tasks.filter((task) => { if (task.status !== "pending") return false; return task.dependsOn.every((depId) => { @@ -106,13 +108,14 @@ export class DAGExecutor extends EventEmitter { const upstreamContext = TaskExecutor.buildUpstreamContext(dependencies); - return this.taskExecutor.execute(task, this.directory, upstreamContext); + return this.taskExecutor.execute(task, this.directory, { upstreamContext }); } /** * Mark tasks as "blocked" if any of their dependencies failed. */ - private propagateFailures(tasks: TaskNode[]): void { + static propagateFailures(tasks: TaskNode[]): TaskNode[] { + const blockedTasks: TaskNode[] = []; let changed = true; while (changed) { changed = false; @@ -127,10 +130,13 @@ export class DAGExecutor extends EventEmitter { if (hasFailedDep) { task.status = "blocked"; task.error = "Blocked by failed upstream task"; + blockedTasks.push(task); changed = true; } } } + + return blockedTasks; } /** diff --git a/electron/main/services/agent-team/guardrails.ts b/electron/main/services/agent-team/guardrails.ts new file mode 100644 index 00000000..edda9306 --- /dev/null +++ b/electron/main/services/agent-team/guardrails.ts @@ -0,0 +1,4 @@ +export const AGENT_TEAM_MAX_CONCURRENT_TASKS = 100; +export const AGENT_TEAM_INACTIVITY_TIMEOUT_MS = 15 * 60 * 1000; +export const AGENT_TEAM_MAX_TASK_RETRIES = 1; +export const AGENT_TEAM_RETRY_BACKOFF_MS = 1000; diff --git a/electron/main/services/agent-team/heavy-brain.ts b/electron/main/services/agent-team/heavy-brain.ts index 19fd7ec6..ea54392c 100644 --- a/electron/main/services/agent-team/heavy-brain.ts +++ b/electron/main/services/agent-team/heavy-brain.ts @@ -5,39 +5,81 @@ // ============================================================================ import type { EngineManager } from "../../gateway/engine-manager"; -import type { TaskNode, TeamRun, EngineType } from "../../../../src/types/unified"; -import { TaskExecutor, extractTextFromMessage, type TaskExecutionResult } from "./task-executor"; -import { dispatchSkill, type DispatchTask } from "./skills"; +import type { TaskNode, TeamRun, EngineType, UnifiedMessage } from "../../../../src/types/unified"; +import { DAGExecutor } from "./dag-executor"; +import { + TaskExecutor, + extractTextFromMessage, + trackAutoApproveSession, + type AutoApproveSessionTracker, + type TaskExecutionResult, +} from "./task-executor"; +import { dispatchSkill, type DispatchInstruction, type DispatchTask } from "./skills"; import { buildOrchestratorPrompt, formatSingleTaskResult, formatTaskResults, formatUserMessage } from "./prompts"; import { agentTeamLog } from "./logger"; import { UserChannel } from "./user-channel"; +import { AGENT_TEAM_MAX_CONCURRENT_TASKS } from "./guardrails"; /** Maximum orchestration iterations to prevent infinite loops */ const MAX_ITERATIONS = 20; +interface RunningTaskState { + promise: Promise; + sessionId?: string; +} + /** * Race a Map of promises: resolves with the first one to settle. * Returns the key and result; the remaining promises keep running. */ -function raceMap(map: Map>): Promise<{ id: string; result: T }> { +function raceMap(map: Map): Promise<{ id: string; result: TaskExecutionResult }> { return Promise.race( - Array.from(map.entries()).map(([id, p]) => - p.then( + Array.from(map.entries()).map(([id, state]) => + state.promise.then( (result) => ({ id, result }), - (err) => ({ id, result: { sessionId: "", summary: "", error: err?.message ?? String(err) } as unknown as T }), + (err) => ({ + id, + result: { + sessionId: "", + summary: "", + error: err instanceof Error ? err.message : String(err), + }, + }), ), ), ); } +type RunningTasks = Map; +type TerminalMessage = UnifiedMessage & { _terminal?: true }; + +type ParseInstructionResult = + | { ok: true; data: DispatchInstruction; response: UnifiedMessage } + | { ok: false; error: string; response: UnifiedMessage }; + +type MergeDispatchTasksResult = + | { ok: true; tasks: TaskNode[] } + | { ok: false; error: string }; + export class HeavyBrainOrchestrator { private cancelled = false; + private terminal = false; + private nextAutoTaskIndex = 0; + private activeRun: { + teamRun: TeamRun; + onTaskUpdated: (task: TaskNode) => void; + } | null = null; + private activeRunningTasks: RunningTasks | null = null; + private cancelSignal: Promise = Promise.resolve(); + private resolveCancelSignal: (() => void) | null = null; + private cancelledSessionIds = new Set(); /** Shared user message channel — external code calls userChannel.send() */ readonly userChannel = new UserChannel(); constructor( private engineManager: EngineManager, - private autoApproveSessions: Set, + private autoApproveSessions: AutoApproveSessionTracker, + private maxConcurrentTasks = AGENT_TEAM_MAX_CONCURRENT_TASKS, ) {} /** @@ -51,116 +93,131 @@ export class HeavyBrainOrchestrator { orchestratorEngineType: EngineType | undefined, onTaskUpdated: (task: TaskNode) => void, ): Promise { + this.cancelled = false; + this.terminal = false; + this.nextAutoTaskIndex = teamRun.tasks.length; + this.activeRun = { teamRun, onTaskUpdated }; + this.activeRunningTasks = null; + this.cancelledSessionIds.clear(); + this.cancelSignal = new Promise((resolve) => { + this.resolveCancelSignal = resolve; + }); + const defaultEngineType = this.engineManager.getDefaultEngineType(); const engineType = orchestratorEngineType || defaultEngineType; - // --- Create orchestrator session --- - teamRun.status = "planning"; - agentTeamLog.info(`[${teamRun.id}] Heavy Brain: creating orchestrator session on ${engineType}`); + try { + // --- Create orchestrator session --- + teamRun.status = "planning"; + agentTeamLog.info(`[${teamRun.id}] Heavy Brain: creating orchestrator session on ${engineType}`); - const engines = this.engineManager.listEngines(); - const prompt = buildOrchestratorPrompt( - teamRun.originalPrompt, - engines, - teamRun.directory, - ); + const engines = this.engineManager.listEngines(); + const prompt = buildOrchestratorPrompt( + teamRun.originalPrompt, + engines, + teamRun.directory, + ); - // Inject format spec + orchestrator role as system-level prompt. - const systemPrompt = `${dispatchSkill.formatPrompt}\n\n---\n\n${prompt}`; + // Inject format spec + orchestrator role as system-level prompt. + const systemPrompt = `${dispatchSkill.formatPrompt}\n\n---\n\n${prompt}`; - const orchSession = await this.engineManager.createSession( - engineType, - teamRun.directory, - undefined, - { systemPrompt }, - ); - teamRun.orchestratorSessionId = orchSession.id; - this.registerAutoApprove(orchSession.id); + const orchSession = await this.engineManager.createSession( + engineType, + teamRun.directory, + undefined, + { systemPrompt }, + ); + teamRun.orchestratorSessionId = orchSession.id; + this.registerAutoApprove(orchSession.id); - const taskExecutor = new TaskExecutor( - this.engineManager, - this.autoApproveSessions, - defaultEngineType, - ); + if (this.terminal) { + return; + } - // --- Initial prompt --- - // Also send as user message for engines that don't support custom system prompts - const fullPrompt = `${dispatchSkill.formatPrompt}\n\n---\n\n${prompt}`; - let response = await this.engineManager.sendMessage(orchSession.id, [ - { type: "text", text: fullPrompt }, - ]); + const taskExecutor = new TaskExecutor( + this.engineManager, + this.autoApproveSessions, + defaultEngineType, + ); - teamRun.status = "running"; - let iterations = 0; - let taskCounter = teamRun.tasks.length; + // --- Initial prompt --- + // Also send as user message for engines that don't support custom system prompts + const fullPrompt = `${dispatchSkill.formatPrompt}\n\n---\n\n${prompt}`; + const initialResponse = await this.sendToOrchestrator(orchSession.id, fullPrompt); + if (!initialResponse || this.terminal) { + return; + } + let response = initialResponse; - // --- Orchestration loop --- - while (iterations++ < MAX_ITERATIONS && !this.cancelled) { - const responseText = extractTextFromMessage(response); - let instruction = dispatchSkill.parse(responseText); + teamRun.status = "running"; + let iterations = 0; - // If parse failed, try correction - if (!instruction.ok) { - agentTeamLog.warn( - `[${teamRun.id}] Heavy Brain: parse failed (attempt ${iterations}): ${instruction.error}`, - ); - const correction = dispatchSkill.correctionPrompt(responseText, instruction.error); - response = await this.engineManager.sendMessage(orchSession.id, [ - { type: "text", text: correction }, - ]); - instruction = dispatchSkill.parse(extractTextFromMessage(response)); - - if (!instruction.ok) { - agentTeamLog.error(`[${teamRun.id}] Heavy Brain: parse failed after correction: ${instruction.error}`); - teamRun.status = "failed"; - teamRun.finalResult = `Orchestrator output format error: ${instruction.error}`; - teamRun.time.completed = Date.now(); + // --- Orchestration loop --- + while (iterations++ < MAX_ITERATIONS && !this.terminal) { + const parsed = await this.parseInstruction(teamRun, orchSession.id, response); + response = parsed.response; + + if (this.terminal) { return; } - } - const data = instruction.data; + if (!parsed.ok) { + await this.failRun(teamRun, `Orchestrator output format error: ${parsed.error}`, onTaskUpdated); + return; + } - // --- Handle "complete" --- - if (data.action === "complete") { - teamRun.status = "completed"; - teamRun.finalResult = data.result; - teamRun.time.completed = Date.now(); - agentTeamLog.info(`[${teamRun.id}] Heavy Brain: orchestrator signaled complete`); - return; - } + const data = parsed.data; + + // --- Handle "complete" --- + if (data.action === "complete") { + await this.completeRun(teamRun, data.result, onTaskUpdated); + return; + } - // --- Handle "dispatch" --- - if (data.action === "dispatch") { - const newTasks = this.convertDispatchTasks(data.tasks, taskCounter); - taskCounter += newTasks.length; - teamRun.tasks.push(...newTasks); + // --- Handle "dispatch" --- + if (data.action === "dispatch") { + const mergeResult = this.mergeDispatchTasks(teamRun, data.tasks, onTaskUpdated); + if (!mergeResult.ok) { + await this.failRun(teamRun, `Orchestrator task graph error: ${mergeResult.error}`, onTaskUpdated); + return; + } + + agentTeamLog.info( + `[${teamRun.id}] Heavy Brain: dispatching ${mergeResult.tasks.length} tasks (iteration ${iterations})`, + ); + + // Execute tasks and report results incrementally + response = await this.executeAndReportIncrementally( + teamRun, + orchSession.id, + taskExecutor, + onTaskUpdated, + ); + + if (this.isTerminal(response)) { + return; + } + } + } - agentTeamLog.info( - `[${teamRun.id}] Heavy Brain: dispatching ${newTasks.length} tasks (iteration ${iterations})`, - ); + if (this.terminal) { + return; + } - // Execute tasks and report results incrementally - response = await this.executeAndReportIncrementally( + if (this.cancelled) { + await this.finalizeRun(teamRun, "cancelled", "Orchestration was cancelled.", onTaskUpdated); + } else { + await this.failRun( teamRun, - newTasks, - orchSession.id, - taskExecutor, + `Orchestration exceeded maximum iterations (${MAX_ITERATIONS}).`, onTaskUpdated, ); } + } finally { + this.activeRun = null; + this.activeRunningTasks = null; + this.resolveCancelSignal = null; } - - // --- Max iterations or cancelled --- - if (this.cancelled) { - teamRun.status = "cancelled"; - teamRun.finalResult = "Orchestration was cancelled."; - } else { - teamRun.status = "failed"; - teamRun.finalResult = `Orchestration exceeded maximum iterations (${MAX_ITERATIONS}).`; - } - teamRun.time.completed = Date.now(); - agentTeamLog.info(`[${teamRun.id}] Heavy Brain: ${teamRun.status}`); } /** @@ -175,118 +232,180 @@ export class HeavyBrainOrchestrator { */ private async executeAndReportIncrementally( teamRun: TeamRun, - initialTasks: TaskNode[], orchSessionId: string, executor: TaskExecutor, onTaskUpdated: (task: TaskNode) => void, - ): Promise { - // Start all tasks in parallel - const running = new Map>(); - for (const task of initialTasks) { - task.status = "running"; - task.time = { started: Date.now() }; - onTaskUpdated(task); - running.set(task.id, executor.execute(task, teamRun.directory)); - } + ): Promise { + const running: RunningTasks = new Map(); + this.activeRunningTasks = running; + this.propagateBlockedTasks(teamRun, onTaskUpdated); + this.startReadyTasks(teamRun, running, executor, onTaskUpdated); - let lastResponse: import("../../../../src/types/unified").UnifiedMessage; + let lastResponse: UnifiedMessage | undefined; // Process completions and user messages - while (running.size > 0 && !this.cancelled) { + while (running.size > 0 && !this.terminal) { // --- Priority 1: check for buffered user message --- const pendingUserMsg = this.userChannel.takePending(); if (pendingUserMsg) { - lastResponse = await this.sendToOrchestrator( - teamRun, orchSessionId, - formatUserMessage(pendingUserMsg, running.size), + const response = await this.sendToOrchestrator( + orchSessionId, + formatUserMessage(pendingUserMsg, this.countOpenTasks(teamRun.tasks)), ); + if (!response || this.terminal) { + return this.markTerminal(lastResponse ?? this.createSyntheticMessage(orchSessionId)); + } + lastResponse = response; lastResponse = await this.handleOrchestratorResponse( - teamRun, orchSessionId, lastResponse, running, executor, onTaskUpdated, + teamRun, + orchSessionId, + lastResponse, + running, + onTaskUpdated, ); if (this.isTerminal(lastResponse)) return lastResponse; + this.startReadyTasks(teamRun, running, executor, onTaskUpdated); continue; } // --- Priority 2: race task completions vs user messages --- type RaceResult = | { type: "task"; id: string; result: TaskExecutionResult } - | { type: "user"; text: string }; + | { type: "user"; text: string } + | { type: "cancel" }; const taskPromise = raceMap(running).then( - (r): RaceResult => ({ type: "task", ...r }), + (result): RaceResult => ({ type: "task", ...result }), ); const userPromise = this.userChannel.waitForMessage().then( (text): RaceResult => ({ type: "user", text }), ); + const cancelPromise = this.cancelSignal.then( + (): RaceResult => ({ type: "cancel" }), + ); - const winner = await Promise.race([userPromise, taskPromise]); + const winner = await Promise.race([userPromise, taskPromise, cancelPromise]); + + if (winner.type === "cancel") { + return this.markTerminal(lastResponse ?? this.createSyntheticMessage(orchSessionId)); + } if (winner.type === "user") { // User message arrived — send to orchestrator immediately agentTeamLog.info(`[${teamRun.id}] Human feedback received (${running.size} tasks running)`); - lastResponse = await this.sendToOrchestrator( - teamRun, orchSessionId, - formatUserMessage(winner.text, running.size), + const response = await this.sendToOrchestrator( + orchSessionId, + formatUserMessage(winner.text, this.countOpenTasks(teamRun.tasks)), ); + if (!response || this.terminal) { + return this.markTerminal(lastResponse ?? this.createSyntheticMessage(orchSessionId)); + } + lastResponse = response; lastResponse = await this.handleOrchestratorResponse( - teamRun, orchSessionId, lastResponse, running, executor, onTaskUpdated, + teamRun, + orchSessionId, + lastResponse, + running, + onTaskUpdated, ); if (this.isTerminal(lastResponse)) return lastResponse; + this.startReadyTasks(teamRun, running, executor, onTaskUpdated); continue; } // --- Task completed --- running.delete(winner.id); + if (this.terminal) { + continue; + } + // Update task status - const task = teamRun.tasks.find((t) => t.id === winner.id); - if (task) { - task.time = { ...task.time, completed: Date.now() }; - task.sessionId = winner.result.sessionId; - if (winner.result.error) { - task.status = "failed"; - task.error = winner.result.error; - task.result = winner.result.summary; - } else { - task.status = "completed"; - task.result = winner.result.summary; - } - onTaskUpdated(task); + const task = teamRun.tasks.find((candidate) => candidate.id === winner.id); + if (!task) { + continue; + } - agentTeamLog.info( - `[${teamRun.id}] Task ${winner.id} ${task.status} (${running.size} remaining)`, - ); + task.time = { ...task.time, completed: Date.now() }; + task.sessionId = winner.result.sessionId; + if (winner.result.error) { + task.status = "failed"; + task.error = winner.result.error; + task.result = winner.result.summary; + } else { + task.status = "completed"; + task.result = winner.result.summary; } + onTaskUpdated(task); + this.propagateBlockedTasks(teamRun, onTaskUpdated); + + agentTeamLog.info( + `[${teamRun.id}] Task ${winner.id} ${task.status} (${running.size} remaining)`, + ); // Send this task's result to orchestrator - const resultMsg = formatSingleTaskResult(task!, running.size); - lastResponse = await this.sendToOrchestrator(teamRun, orchSessionId, resultMsg); + const resultMsg = formatSingleTaskResult(task, this.countOpenTasks(teamRun.tasks)); + const response = await this.sendToOrchestrator(orchSessionId, resultMsg); + if (!response || this.terminal) { + return this.markTerminal(lastResponse ?? this.createSyntheticMessage(orchSessionId)); + } + lastResponse = response; lastResponse = await this.handleOrchestratorResponse( - teamRun, orchSessionId, lastResponse, running, executor, onTaskUpdated, + teamRun, + orchSessionId, + lastResponse, + running, + onTaskUpdated, ); if (this.isTerminal(lastResponse)) return lastResponse; + this.startReadyTasks(teamRun, running, executor, onTaskUpdated); + } + + if (this.terminal) { + return this.markTerminal(lastResponse ?? this.createSyntheticMessage(orchSessionId)); } // All tasks done — send final summary for orchestrator to decide const summaryText = formatTaskResults(teamRun); - lastResponse = await this.engineManager.sendMessage(orchSessionId, [ - { type: "text", text: summaryText }, - ]); + const response = await this.sendToOrchestrator(orchSessionId, summaryText); + if (!response) { + return this.markTerminal(lastResponse ?? this.createSyntheticMessage(orchSessionId)); + } - return lastResponse!; + return response; } /** * Send a message to the orchestrator and return the response. */ private async sendToOrchestrator( - teamRun: TeamRun, orchSessionId: string, text: string, - ): Promise { - return this.engineManager.sendMessage(orchSessionId, [ + ): Promise { + const responsePromise = this.engineManager.sendMessage(orchSessionId, [ { type: "text", text }, ]); + + const guardedResponse = responsePromise.catch((error) => { + if (this.cancelled || this.terminal) { + agentTeamLog.debug( + `[${orchSessionId}] Ignoring orchestrator response after cancellation: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + throw error; + }); + + const winner = await Promise.race([ + guardedResponse.then((response) => ({ type: "response" as const, response })), + this.cancelSignal.then(() => ({ type: "cancel" as const })), + ]); + + if (winner.type === "cancel") { + return null; + } + + return winner.response; } /** @@ -297,97 +416,353 @@ export class HeavyBrainOrchestrator { private async handleOrchestratorResponse( teamRun: TeamRun, orchSessionId: string, - lastResponse: import("../../../../src/types/unified").UnifiedMessage, - running: Map>, - executor: TaskExecutor, + lastResponse: UnifiedMessage, + running: RunningTasks, onTaskUpdated: (task: TaskNode) => void, - ): Promise { - const responseText = extractTextFromMessage(lastResponse); - let instruction = dispatchSkill.parse(responseText); + ): Promise { + const parsed = await this.parseInstruction(teamRun, orchSessionId, lastResponse); + lastResponse = parsed.response; - // If parse failed, try correction (strict protocol: no free-text allowed) - if (!instruction.ok) { - agentTeamLog.warn( - `[${teamRun.id}] Orchestrator response not valid JSON: ${instruction.error}`, - ); - const correction = dispatchSkill.correctionPrompt(responseText, instruction.error); - const retryResponse = await this.engineManager.sendMessage(orchSessionId, [ - { type: "text", text: correction }, - ]); - lastResponse = retryResponse; - instruction = dispatchSkill.parse(extractTextFromMessage(retryResponse)); - if (!instruction.ok) { - agentTeamLog.warn( - `[${teamRun.id}] Orchestrator correction also failed: ${instruction.error}`, - ); - return lastResponse; - } + if (this.terminal) { + return this.markTerminal(lastResponse); } - if (instruction.data.action === "complete") { - // Cancel remaining tasks - for (const remainId of running.keys()) { - const t = teamRun.tasks.find((t) => t.id === remainId); - if (t) { - t.status = "cancelled"; - t.time = { ...t.time, completed: Date.now() }; - onTaskUpdated(t); - } - } + if (!parsed.ok) { + await this.failRun(teamRun, `Orchestrator output format error: ${parsed.error}`, onTaskUpdated); running.clear(); - // Mark as terminal via _terminal flag so the caller knows to return - (lastResponse as any)._terminal = true; - return lastResponse; + return this.markTerminal(lastResponse); } - if (instruction.data.action === "dispatch") { - const newTasks = this.convertDispatchTasks( - instruction.data.tasks, - teamRun.tasks.length, - ); - teamRun.tasks.push(...newTasks); - for (const t of newTasks) { - t.status = "running"; - t.time = { started: Date.now() }; - onTaskUpdated(t); - running.set(t.id, executor.execute(t, teamRun.directory)); + if (parsed.data.action === "complete") { + await this.completeRun(teamRun, parsed.data.result, onTaskUpdated); + running.clear(); + return this.markTerminal(lastResponse); + } + + if (parsed.data.action === "dispatch") { + const mergeResult = this.mergeDispatchTasks(teamRun, parsed.data.tasks, onTaskUpdated); + if (!mergeResult.ok) { + await this.failRun(teamRun, `Orchestrator task graph error: ${mergeResult.error}`, onTaskUpdated); + running.clear(); + return this.markTerminal(lastResponse); } + agentTeamLog.info( - `[${teamRun.id}] Orchestrator dispatched ${newTasks.length} new tasks mid-execution`, + `[${teamRun.id}] Orchestrator dispatched ${mergeResult.tasks.length} new tasks mid-execution`, ); } - // action === "continueWaiting" → keep waiting return lastResponse; } /** Check if the orchestrator signaled completion */ - private isTerminal(response: import("../../../../src/types/unified").UnifiedMessage): boolean { - return !!(response as any)._terminal; + private isTerminal(response: UnifiedMessage): boolean { + return !!(response as TerminalMessage)._terminal; } - cancel(): void { + async cancel(): Promise { + if (this.terminal) { + return; + } + this.cancelled = true; this.userChannel.dispose(); + this.resolveCancelSignal?.(); + + const activeRun = this.activeRun; + if (!activeRun) { + this.terminal = true; + return; + } + + if (activeRun.teamRun.orchestratorSessionId) { + await this.cancelSession( + activeRun.teamRun.orchestratorSessionId, + `run ${activeRun.teamRun.id} orchestrator`, + ); + } + + await this.finalizeRun( + activeRun.teamRun, + "cancelled", + "Orchestration was cancelled.", + activeRun.onTaskUpdated, + ); } - private convertDispatchTasks(tasks: DispatchTask[], startIndex: number): TaskNode[] { - return tasks.map((t, i): TaskNode => ({ - id: t.id || `task_${startIndex + i}`, + private convertDispatchTasks(tasks: DispatchTask[]): TaskNode[] { + return tasks.map((t): TaskNode => ({ + id: t.id || `task_${this.nextAutoTaskIndex++}`, description: t.description, prompt: t.prompt, engineType: t.engineType as EngineType | undefined, dependsOn: t.dependsOn || [], + worktreeId: t.worktreeId, status: "pending", })); } private registerAutoApprove(sessionId: string): void { - if (this.autoApproveSessions.size > 200) { - const recent = [...this.autoApproveSessions].slice(-100); - this.autoApproveSessions.clear(); - for (const id of recent) this.autoApproveSessions.add(id); + trackAutoApproveSession(this.autoApproveSessions, sessionId); + } + + private async parseInstruction( + teamRun: TeamRun, + orchSessionId: string, + response: UnifiedMessage, + ): Promise { + const responseText = extractTextFromMessage(response); + let instruction = dispatchSkill.parse(responseText); + + if (instruction.ok) { + return { ok: true, data: instruction.data, response }; + } + + agentTeamLog.warn( + `[${teamRun.id}] Orchestrator response not valid JSON: ${instruction.error}`, + ); + + const correction = dispatchSkill.correctionPrompt(responseText, instruction.error); + const retryResponse = await this.sendToOrchestrator(orchSessionId, correction); + if (!retryResponse) { + return { ok: false, error: "Orchestration was cancelled.", response }; + } + + instruction = dispatchSkill.parse(extractTextFromMessage(retryResponse)); + if (!instruction.ok) { + return { ok: false, error: instruction.error, response: retryResponse }; + } + + return { ok: true, data: instruction.data, response: retryResponse }; + } + + private mergeDispatchTasks( + teamRun: TeamRun, + tasks: DispatchTask[], + onTaskUpdated: (task: TaskNode) => void, + ): MergeDispatchTasksResult { + const newTasks = this.convertDispatchTasks(tasks); + const validation = this.validateMergedTasks(teamRun.tasks, newTasks); + if (!validation.ok) { + return validation; + } + + teamRun.tasks.push(...newTasks); + for (const task of newTasks) { + onTaskUpdated(task); + } + this.propagateBlockedTasks(teamRun, onTaskUpdated); + + return { ok: true, tasks: newTasks }; + } + + private validateMergedTasks( + existingTasks: TaskNode[], + newTasks: TaskNode[], + ): MergeDispatchTasksResult { + const combined = [...existingTasks, ...newTasks]; + const ids = new Set(); + const errors: string[] = []; + + for (const task of combined) { + if (ids.has(task.id)) { + errors.push(`Duplicate task id '${task.id}'`); + } else { + ids.add(task.id); + } + } + + for (const task of combined) { + for (const depId of task.dependsOn) { + if (!ids.has(depId)) { + errors.push(`Task '${task.id}' dependsOn unknown task '${depId}'`); + } + } + } + + const visited = new Set(); + const inStack = new Set(); + const taskMap = new Map(combined.map((task) => [task.id, task])); + + const hasCycle = (taskId: string): boolean => { + if (inStack.has(taskId)) return true; + if (visited.has(taskId)) return false; + + visited.add(taskId); + inStack.add(taskId); + + const task = taskMap.get(taskId); + if (task) { + for (const depId of task.dependsOn) { + if (hasCycle(depId)) return true; + } + } + + inStack.delete(taskId); + return false; + }; + + for (const taskId of ids) { + if (hasCycle(taskId)) { + errors.push("Circular dependency detected in task graph"); + break; + } + } + + if (errors.length > 0) { + return { ok: false, error: errors.join("; ") }; + } + + return { ok: true, tasks: newTasks }; + } + + private propagateBlockedTasks( + teamRun: TeamRun, + onTaskUpdated: (task: TaskNode) => void, + ): void { + const blockedTasks = DAGExecutor.propagateFailures(teamRun.tasks); + for (const task of blockedTasks) { + task.time = { ...task.time, completed: task.time?.completed ?? Date.now() }; + onTaskUpdated(task); + } + } + + private startReadyTasks( + teamRun: TeamRun, + running: RunningTasks, + executor: TaskExecutor, + onTaskUpdated: (task: TaskNode) => void, + ): void { + const capacity = Math.max(this.maxConcurrentTasks - running.size, 0); + if (capacity === 0) { + return; + } + + const readyTasks = DAGExecutor.findReadyTasks(teamRun.tasks).slice(0, capacity); + + for (const task of readyTasks) { + const dependencies = task.dependsOn + .map((depId) => teamRun.tasks.find((candidate) => candidate.id === depId)) + .filter((candidate): candidate is TaskNode => candidate != null); + + const upstreamContext = TaskExecutor.buildUpstreamContext(dependencies); + + task.status = "running"; + task.time = { ...task.time, started: Date.now() }; + onTaskUpdated(task); + + let state!: RunningTaskState; + const promise = executor.execute(task, teamRun.directory, { + upstreamContext, + onSessionCreated: (sessionId) => { + state.sessionId = sessionId; + if (this.cancelled || this.terminal) { + void this.cancelSession(sessionId, `task ${task.id}`); + } + }, + shouldCancel: () => this.cancelled || this.terminal, + }); + state = { promise }; + running.set(task.id, state); + } + } + + private countOpenTasks(tasks: TaskNode[]): number { + return tasks.filter((task) => task.status === "pending" || task.status === "running").length; + } + + private async completeRun( + teamRun: TeamRun, + result: string, + onTaskUpdated: (task: TaskNode) => void, + ): Promise { + if (await this.finalizeRun(teamRun, "completed", result, onTaskUpdated)) { + agentTeamLog.info(`[${teamRun.id}] Heavy Brain: orchestrator signaled complete`); + } + } + + private async failRun( + teamRun: TeamRun, + message: string, + onTaskUpdated: (task: TaskNode) => void, + ): Promise { + if (await this.finalizeRun(teamRun, "failed", message, onTaskUpdated)) { + agentTeamLog.error(`[${teamRun.id}] Heavy Brain: ${message}`); + } + } + + private markTerminal(response: UnifiedMessage): UnifiedMessage { + (response as TerminalMessage)._terminal = true; + return response; + } + + private async finalizeRun( + teamRun: TeamRun, + status: TeamRun["status"], + result: string, + onTaskUpdated: (task: TaskNode) => void, + ): Promise { + if (this.terminal) { + return false; } - this.autoApproveSessions.add(sessionId); + + this.terminal = true; + this.resolveCancelSignal?.(); + await this.cancelOutstandingTasks(teamRun, onTaskUpdated); + teamRun.status = status; + teamRun.finalResult = result; + teamRun.time.completed = Date.now(); + return true; + } + + private async cancelOutstandingTasks( + teamRun: TeamRun, + onTaskUpdated: (task: TaskNode) => void, + ): Promise { + const cancellationPromises: Promise[] = []; + + for (const task of teamRun.tasks) { + if (task.status === "running") { + const sessionId = this.activeRunningTasks?.get(task.id)?.sessionId ?? task.sessionId; + if (sessionId) { + cancellationPromises.push(this.cancelSession(sessionId, `task ${task.id}`)); + } + } + + if (task.status !== "pending" && task.status !== "running") { + continue; + } + + task.status = "cancelled"; + task.time = { ...task.time, completed: task.time?.completed ?? Date.now() }; + onTaskUpdated(task); + } + + await Promise.all(cancellationPromises); + } + + private async cancelSession(sessionId: string, label: string): Promise { + if (this.cancelledSessionIds.has(sessionId)) { + return; + } + + this.cancelledSessionIds.add(sessionId); + try { + await this.engineManager.cancelMessage(sessionId); + } catch (error) { + agentTeamLog.warn( + `[${label}] Failed to cancel session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private createSyntheticMessage(sessionId: string): UnifiedMessage { + return { + id: `team-terminal-${Date.now()}`, + sessionId, + role: "assistant", + time: { created: Date.now(), completed: Date.now() }, + parts: [], + }; } } diff --git a/electron/main/services/agent-team/index.ts b/electron/main/services/agent-team/index.ts index 3d715deb..40860c58 100644 --- a/electron/main/services/agent-team/index.ts +++ b/electron/main/services/agent-team/index.ts @@ -5,6 +5,9 @@ // ============================================================================ import { EventEmitter } from "events"; +import fs from "node:fs"; +import path from "node:path"; +import { app } from "electron"; import { timeId } from "../../utils/id-gen"; import { agentTeamLog } from "./logger"; import { LightBrainOrchestrator } from "./light-brain"; @@ -18,6 +21,13 @@ import type { EngineType, } from "../../../../src/types/unified"; +const SAVE_DEBOUNCE_MS = 500; + +interface TeamRunFileFormat { + version: 1; + runs: TeamRun[]; +} + // --- Event types --- export interface AgentTeamServiceEvents { @@ -38,31 +48,42 @@ export class AgentTeamService extends EventEmitter { private runs = new Map(); /** Session IDs created by team runs — auto-approve permissions for these. */ private autoApproveSessions = new Set(); + /** Run-scoped auto-approve session tracking for deterministic cleanup. */ + private autoApproveSessionsByRun = new Map>(); /** Active Heavy Brain orchestrators (for cancellation). */ private activeOrchestrators = new Map(); - /** Active user channels for human-in-the-loop (both Light and Heavy Brain). */ - private activeUserChannels = new Map(); + /** Active Heavy Brain relay channels for human-in-the-loop messages. */ + private activeRelayChannels = new Map(); + private saveTimer: ReturnType | null = null; private initialized = false; // --- Lifecycle --- init(engineManager: EngineManager): void { if (this.initialized) return; + this.runs.clear(); + this.autoApproveSessions.clear(); + this.autoApproveSessionsByRun.clear(); + this.activeOrchestrators.clear(); + this.activeRelayChannels.clear(); this.engineManager = engineManager; + this.loadFromDisk(); this.subscribePermissionAutoApprove(); this.initialized = true; - agentTeamLog.info("Agent Team Service initialized"); + agentTeamLog.info(`Agent Team Service initialized with ${this.runs.size} run(s)`); } async shutdown(): Promise { // Cancel all running orchestrators for (const [runId, orchestrator] of this.activeOrchestrators) { agentTeamLog.info(`Cancelling orchestrator for run ${runId}`); - orchestrator.cancel(); + await orchestrator.cancel(); } this.activeOrchestrators.clear(); - this.activeUserChannels.clear(); + this.activeRelayChannels.clear(); + this.autoApproveSessionsByRun.clear(); this.autoApproveSessions.clear(); + this.flushPendingSave(); this.initialized = false; agentTeamLog.info("Agent Team Service shut down"); } @@ -141,35 +162,48 @@ export class AgentTeamService extends EventEmitter { // Cancel heavy brain orchestrator if active const orchestrator = this.activeOrchestrators.get(runId); if (orchestrator) { - orchestrator.cancel(); + await orchestrator.cancel(); this.activeOrchestrators.delete(runId); - } - this.activeUserChannels.delete(runId); - - // Cancel all running child sessions - for (const task of run.tasks) { - if (task.sessionId && task.status === "running") { + } else { + if (run.orchestratorSessionId) { try { - await this.engineManager?.cancelMessage(task.sessionId); - } catch { - // Best-effort + await this.engineManager?.cancelMessage(run.orchestratorSessionId); + } catch (error) { + agentTeamLog.warn( + `Failed to cancel orchestrator session ${run.orchestratorSessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); } - task.status = "cancelled"; } - if (task.status === "pending") { - task.status = "cancelled"; + + for (const task of run.tasks) { + if (task.sessionId && task.status === "running") { + try { + await this.engineManager?.cancelMessage(task.sessionId); + } catch (error) { + agentTeamLog.warn( + `Failed to cancel task session ${task.sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + if (task.status === "pending" || task.status === "running") { + task.status = "cancelled"; + task.time = { ...task.time, completed: task.time?.completed ?? Date.now() }; + } } + + run.status = "cancelled"; + run.finalResult = "Orchestration was cancelled."; + run.time.completed = Date.now(); } - run.status = "cancelled"; - run.time.completed = Date.now(); + this.cleanupRunRuntimeState(runId); this.emitRunUpdated(run); agentTeamLog.info(`Cancelled team run ${runId}`); } /** - * Send a user message to a running orchestrator (Light or Heavy Brain). - * The message will be forwarded with highest priority. + * Relay a user message to an active Heavy Brain orchestrator. */ sendMessageToRun(runId: string, text: string): void { const run = this.runs.get(runId); @@ -177,10 +211,15 @@ export class AgentTeamService extends EventEmitter { if (run.status !== "running" && run.status !== "planning") { throw new Error(`Team run ${runId} is not active (status: ${run.status})`); } + if (run.mode !== "heavy") { + throw new Error( + `Relay messaging is only supported for active Heavy Brain runs. Run ${runId} is ${run.mode}.`, + ); + } - const channel = this.activeUserChannels.get(runId); + const channel = this.activeRelayChannels.get(runId); if (!channel) { - throw new Error(`No active user channel for run ${runId}`); + throw new Error(`No active Heavy Brain relay channel for run ${runId}`); } channel.send(text); @@ -188,7 +227,7 @@ export class AgentTeamService extends EventEmitter { } listRuns(): TeamRun[] { - return Array.from(this.runs.values()); + return Array.from(this.runs.values()).sort((a, b) => b.time.created - a.time.created); } getRun(runId: string): TeamRun | null { @@ -204,32 +243,161 @@ export class AgentTeamService extends EventEmitter { }; const resolvedEngine = orchestratorEngineType ?? this.engineManager!.getDefaultEngineType(); + const registerAutoApproveSession = (sessionId: string) => { + this.registerAutoApproveSession(run.id, sessionId); + }; - if (run.mode === "light") { - const orchestrator = new LightBrainOrchestrator( - this.engineManager!, - this.autoApproveSessions, - ); - await orchestrator.run(run, onTaskUpdated); - } else { - const orchestrator = new HeavyBrainOrchestrator( - this.engineManager!, - this.autoApproveSessions, - ); - this.activeOrchestrators.set(run.id, orchestrator); - this.activeUserChannels.set(run.id, orchestrator.userChannel); - try { + try { + if (run.mode === "light") { + const orchestrator = new LightBrainOrchestrator( + this.engineManager!, + registerAutoApproveSession, + ); + await orchestrator.run(run, onTaskUpdated, resolvedEngine); + } else { + const orchestrator = new HeavyBrainOrchestrator( + this.engineManager!, + registerAutoApproveSession, + ); + this.activeOrchestrators.set(run.id, orchestrator); + this.activeRelayChannels.set(run.id, orchestrator.userChannel); await orchestrator.run(run, resolvedEngine, onTaskUpdated); - } finally { - this.activeOrchestrators.delete(run.id); - this.activeUserChannels.delete(run.id); } + } finally { + this.cleanupRunRuntimeState(run.id); + this.emitRunUpdated(run); } + } - this.emitRunUpdated(run); + private registerAutoApproveSession(runId: string, sessionId: string): void { + this.autoApproveSessions.add(sessionId); + + const runSessions = this.autoApproveSessionsByRun.get(runId) ?? new Set(); + runSessions.add(sessionId); + this.autoApproveSessionsByRun.set(runId, runSessions); + } + + private unregisterAutoApproveSessions(runId: string): void { + const runSessions = this.autoApproveSessionsByRun.get(runId); + if (!runSessions) return; + + for (const sessionId of runSessions) { + this.autoApproveSessions.delete(sessionId); + } + this.autoApproveSessionsByRun.delete(runId); + } + + private cleanupRunRuntimeState(runId: string): void { + this.activeOrchestrators.delete(runId); + this.activeRelayChannels.delete(runId); + this.unregisterAutoApproveSessions(runId); + } + + private getFilePath(): string { + return path.join(app.getPath("userData"), "agent-team-runs.json"); + } + + private loadFromDisk(): void { + const filePath = this.getFilePath(); + + try { + if (!fs.existsSync(filePath)) { + agentTeamLog.info("No agent-team-runs.json found, starting empty"); + return; + } + + const raw = fs.readFileSync(filePath, "utf-8"); + const data = JSON.parse(raw) as TeamRunFileFormat; + + if (data.version !== 1 || !Array.isArray(data.runs)) { + agentTeamLog.warn("Invalid agent-team-runs.json format, ignoring"); + return; + } + + for (const run of data.runs) { + this.runs.set(run.id, run); + } + + const recoveredCount = this.recoverInterruptedRuns(); + if (recoveredCount > 0) { + this.writeToDisk(); + } + + agentTeamLog.info(`Loaded ${data.runs.length} team run(s) from disk`); + } catch (error) { + agentTeamLog.error("Failed to load agent-team-runs.json:", error); + } + } + + private recoverInterruptedRuns(): number { + let recoveredCount = 0; + const completionTime = Date.now(); + + for (const run of this.runs.values()) { + if (run.status !== "planning" && run.status !== "running") { + continue; + } + + recoveredCount += 1; + run.status = "failed"; + run.finalResult = "Agent Team run was interrupted because CodeMux restarted before it completed."; + run.time.completed = completionTime; + + for (const task of run.tasks) { + if (task.status !== "pending" && task.status !== "running") { + continue; + } + + task.status = "cancelled"; + task.time = { ...task.time, completed: task.time?.completed ?? completionTime }; + } + } + + return recoveredCount; + } + + private writeToDisk(): void { + const filePath = this.getFilePath(); + const data: TeamRunFileFormat = { + version: 1, + runs: this.listRuns(), + }; + + try { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const tmpPath = `${filePath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8"); + fs.renameSync(tmpPath, filePath); + } catch (error) { + agentTeamLog.error("Failed to write agent-team-runs.json:", error); + } + } + + private scheduleSave(): void { + if (this.saveTimer) { + clearTimeout(this.saveTimer); + } + + this.saveTimer = setTimeout(() => { + this.saveTimer = null; + this.writeToDisk(); + }, SAVE_DEBOUNCE_MS); + } + + private flushPendingSave(): void { + if (!this.saveTimer) return; + + clearTimeout(this.saveTimer); + this.saveTimer = null; + this.writeToDisk(); } private emitRunUpdated(run: TeamRun): void { + this.scheduleSave(); this.emit("team.run.updated", { run }); } } diff --git a/electron/main/services/agent-team/light-brain.ts b/electron/main/services/agent-team/light-brain.ts index 97a1439c..a862802d 100644 --- a/electron/main/services/agent-team/light-brain.ts +++ b/electron/main/services/agent-team/light-brain.ts @@ -6,7 +6,12 @@ import type { EngineManager } from "../../gateway/engine-manager"; import type { TaskNode, TeamRun, EngineType, EngineInfo } from "../../../../src/types/unified"; import { DAGExecutor } from "./dag-executor"; -import { TaskExecutor, extractTextFromMessage } from "./task-executor"; +import { + TaskExecutor, + extractTextFromMessage, + trackAutoApproveSession, + type AutoApproveSessionTracker, +} from "./task-executor"; import { dagPlanningSkill, executeWithSkill, type RawTaskNode } from "./skills"; import { buildPlanningPrompt } from "./prompts"; import { agentTeamLog } from "./logger"; @@ -14,7 +19,7 @@ import { agentTeamLog } from "./logger"; export class LightBrainOrchestrator { constructor( private engineManager: EngineManager, - private autoApproveSessions: Set, + private autoApproveSessions: AutoApproveSessionTracker, ) {} /** @@ -25,16 +30,19 @@ export class LightBrainOrchestrator { async run( teamRun: TeamRun, onTaskUpdated: (task: TaskNode) => void, + plannerEngineType?: EngineType, ): Promise { const defaultEngineType = this.engineManager.getDefaultEngineType(); - const plannerEngineType = (teamRun.mode === "light" ? defaultEngineType : defaultEngineType) as EngineType; + const resolvedPlannerEngineType = plannerEngineType ?? defaultEngineType; // --- Phase 1: Planning --- teamRun.status = "planning"; - agentTeamLog.info(`[${teamRun.id}] Light Brain: planning phase`); + agentTeamLog.info( + `[${teamRun.id}] Light Brain: planning phase using ${resolvedPlannerEngineType}`, + ); const engines = this.engineManager.listEngines(); - const tasks = await this.generateDAG(teamRun, plannerEngineType, engines); + const tasks = await this.generateDAG(teamRun, resolvedPlannerEngineType, engines); // Convert raw tasks to TaskNodes teamRun.tasks = tasks.map((raw): TaskNode => ({ @@ -43,6 +51,7 @@ export class LightBrainOrchestrator { prompt: raw.prompt, engineType: raw.engineType as EngineType | undefined, dependsOn: raw.dependsOn, + worktreeId: raw.worktreeId, status: "pending", })); @@ -103,12 +112,7 @@ export class LightBrainOrchestrator { ); // Register for auto-approve - if (this.autoApproveSessions.size > 200) { - const recent = [...this.autoApproveSessions].slice(-100); - this.autoApproveSessions.clear(); - for (const id of recent) this.autoApproveSessions.add(id); - } - this.autoApproveSessions.add(planSession.id); + trackAutoApproveSession(this.autoApproveSessions, planSession.id); // Execute with skill (includes format spec + self-check + retry) const sendMessage = async (text: string): Promise => { diff --git a/electron/main/services/agent-team/prompts.ts b/electron/main/services/agent-team/prompts.ts index e9a2dd48..1b888b82 100644 --- a/electron/main/services/agent-team/prompts.ts +++ b/electron/main/services/agent-team/prompts.ts @@ -148,7 +148,7 @@ export function formatSingleTaskResult(task: TaskNode, remainingCount: number): lines.push(""); if (remainingCount > 0) { - lines.push(`---\n${remainingCount} task(s) still running. You may output a JSON block to dispatch new tasks or mark complete, or just acknowledge to wait for more results.`); + lines.push(`---\n${remainingCount} task(s) are still pending or running. You may output a JSON block to dispatch new tasks or mark complete, or just acknowledge to wait for more results.`); } else { lines.push(`---\nAll tasks have finished. Output a JSON block: dispatch more tasks, or mark complete with a summary.`); } @@ -168,8 +168,8 @@ export function formatUserMessage(text: string, remainingTasks: number): string ``, `---`, remainingTasks > 0 - ? `${remainingTasks} task(s) still running. Respond with a JSON block.` - : `No tasks currently running. Respond with a JSON block.`, + ? `${remainingTasks} task(s) are still pending or running. Respond with a JSON block.` + : `No tasks are currently pending or running. Respond with a JSON block.`, ]; return lines.join("\n"); } diff --git a/electron/main/services/agent-team/skills.ts b/electron/main/services/agent-team/skills.ts index 11d0f87d..59e31be5 100644 --- a/electron/main/services/agent-team/skills.ts +++ b/electron/main/services/agent-team/skills.ts @@ -111,6 +111,7 @@ export interface RawTaskNode { prompt: string; dependsOn: string[]; engineType?: string; + worktreeId?: string; } interface DagPlanOutput { @@ -155,6 +156,9 @@ function validateDagPlan(data: unknown): { ok: true; data: DagPlanOutput } | { o if (!Array.isArray(t.dependsOn)) { errors.push(`Task '${t.id}': missing 'dependsOn' array`); } + if (t.worktreeId != null && typeof t.worktreeId !== "string") { + errors.push(`Task '${t.id}': invalid 'worktreeId'`); + } } if (errors.length > 0) { @@ -224,12 +228,13 @@ Your **final answer** MUST be a single JSON code block with the following schema { "id": "string (unique, e.g. t1, t2)", "description": "string (1-sentence summary of the task)", - "prompt": "string (detailed, self-contained instructions for the worker agent)", - "dependsOn": ["array of task IDs this task depends on, use [] if none"], - "engineType": "optional string: claude | copilot | opencode (omit to use default)" - } - ] -} + "prompt": "string (detailed, self-contained instructions for the worker agent)", + "dependsOn": ["array of task IDs this task depends on, use [] if none"], + "engineType": "optional string: claude | copilot | opencode (omit to use default)", + "worktreeId": "optional string: existing worktree name for isolated file changes" + } + ] + } \`\`\` ## Self-Check Before Outputting (MANDATORY) @@ -267,6 +272,7 @@ export interface DispatchTask { prompt: string; engineType?: string; dependsOn?: string[]; + worktreeId?: string; } export type DispatchInstruction = @@ -317,6 +323,9 @@ function validateDispatchInstruction( if (!t.prompt || typeof t.prompt !== "string") { errors.push(`Task '${t.id}': missing 'prompt'`); } + if (t.worktreeId != null && typeof t.worktreeId !== "string") { + errors.push(`Task '${t.id}': invalid 'worktreeId'`); + } } if (errors.length > 0) { @@ -352,13 +361,15 @@ You communicate your decisions via JSON code blocks. This JSON is parsed by an e "action": "dispatch", "tasks": [ { - "id": "unique_id", - "description": "1-sentence summary", - "prompt": "detailed, self-contained instructions for the worker agent", - "engineType": "optional: claude | copilot | opencode" - } - ] -} + "id": "unique_id", + "description": "1-sentence summary", + "prompt": "detailed, self-contained instructions for the worker agent", + "engineType": "optional: claude | copilot | opencode", + "dependsOn": ["optional array of already-known task IDs"], + "worktreeId": "optional string: existing worktree name for isolated file changes" + } + ] + } \`\`\` ### 2. Mark orchestration as complete: diff --git a/electron/main/services/agent-team/task-executor.ts b/electron/main/services/agent-team/task-executor.ts index ea89c04d..1115b3ed 100644 --- a/electron/main/services/agent-team/task-executor.ts +++ b/electron/main/services/agent-team/task-executor.ts @@ -5,6 +5,11 @@ import type { EngineManager } from "../../gateway/engine-manager"; import type { TaskNode, EngineType, UnifiedMessage, UnifiedPart } from "../../../../src/types/unified"; +import { + AGENT_TEAM_INACTIVITY_TIMEOUT_MS, + AGENT_TEAM_MAX_TASK_RETRIES, + AGENT_TEAM_RETRY_BACKOFF_MS, +} from "./guardrails"; /** Result of executing a single task */ export interface TaskExecutionResult { @@ -13,6 +18,35 @@ export interface TaskExecutionResult { error?: string; } +export type AutoApproveSessionTracker = Set | ((sessionId: string) => void); + +export interface TaskExecutionOptions { + upstreamContext?: string; + onSessionCreated?: (sessionId: string) => void; + shouldCancel?: () => boolean; + inactivityTimeoutMs?: number; + maxRetries?: number; + retryBackoffMs?: number; +} + +export function trackAutoApproveSession( + tracker: AutoApproveSessionTracker, + sessionId: string, +): void { + if (typeof tracker === "function") { + tracker(sessionId); + return; + } + + if (tracker.size > 200) { + const recent = [...tracker].slice(-100); + tracker.clear(); + for (const id of recent) tracker.add(id); + } + + tracker.add(sessionId); +} + /** * Extracts text content from a completed UnifiedMessage. * Concatenates all text parts from the message. @@ -24,10 +58,49 @@ export function extractTextFromMessage(message: UnifiedMessage): string { return textParts.join("\n").trim(); } +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function formatDuration(ms: number): string { + if (ms % 60_000 === 0) { + const minutes = ms / 60_000; + return `${minutes} minute${minutes === 1 ? "" : "s"}`; + } + + if (ms % 1000 === 0) { + const seconds = ms / 1000; + return `${seconds} second${seconds === 1 ? "" : "s"}`; + } + + return `${ms}ms`; +} + +function isRecoverableExecutionError(error: unknown): boolean { + const message = stringifyError(error).toLowerCase(); + return ( + message.includes("timed out after") || + message.includes("timeout") || + message.includes("network") || + message.includes("connection") || + message.includes("temporar") || + message.includes("unavailable") || + message.includes("rate limit") || + message.includes("429") || + message.includes("503") || + message.includes("econnreset") || + message.includes("epipe") + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export class TaskExecutor { constructor( private engineManager: EngineManager, - private autoApproveSessions: Set, + private autoApproveSessions: AutoApproveSessionTracker, private defaultEngineType: EngineType, ) {} @@ -41,36 +114,53 @@ export class TaskExecutor { async execute( task: TaskNode, directory: string, - upstreamContext?: string, + options: TaskExecutionOptions = {}, ): Promise { - const engineType = (task.engineType as EngineType) || this.defaultEngineType; + const maxRetries = options.maxRetries ?? AGENT_TEAM_MAX_TASK_RETRIES; + const inactivityTimeoutMs = options.inactivityTimeoutMs ?? AGENT_TEAM_INACTIVITY_TIMEOUT_MS; + const retryBackoffMs = options.retryBackoffMs ?? AGENT_TEAM_RETRY_BACKOFF_MS; - // 1. Create a new session - const session = await this.engineManager.createSession(engineType, directory); - task.sessionId = session.id; + let attempt = 0; + let lastSessionId = task.sessionId ?? ""; - // 2. Register session for auto-approve permissions - this.registerAutoApprove(session.id); + while (true) { + if (options.shouldCancel?.()) { + return { + sessionId: lastSessionId, + summary: "", + error: "Task cancelled before execution started.", + }; + } - // 3. Build prompt with upstream context - let prompt = task.prompt; - if (upstreamContext) { - prompt = `${upstreamContext}\n\n---\n\nYour task:\n${task.prompt}`; - } + try { + const result = await this.executeAttempt(task, directory, options, inactivityTimeoutMs); + return result; + } catch (error) { + lastSessionId = task.sessionId ?? lastSessionId; - // 4. Send message and wait for completion - const message = await this.engineManager.sendMessage(session.id, [ - { type: "text", text: prompt }, - ]); + if (options.shouldCancel?.()) { + return { + sessionId: lastSessionId, + summary: "", + error: "Task cancelled during execution.", + }; + } - // 5. Extract result text - const summary = extractTextFromMessage(message); + if (attempt >= maxRetries || !isRecoverableExecutionError(error)) { + return { + sessionId: lastSessionId, + summary: "", + error: stringifyError(error), + }; + } - if (message.error) { - return { sessionId: session.id, summary, error: message.error }; - } + attempt += 1; - return { sessionId: session.id, summary }; + if (retryBackoffMs > 0) { + await sleep(retryBackoffMs); + } + } + } } /** @@ -88,12 +178,146 @@ export class TaskExecutor { } private registerAutoApprove(sessionId: string): void { - // Keep the set bounded (same pattern as ScheduledTaskService) - if (this.autoApproveSessions.size > 200) { - const recent = [...this.autoApproveSessions].slice(-100); - this.autoApproveSessions.clear(); - for (const id of recent) this.autoApproveSessions.add(id); + trackAutoApproveSession(this.autoApproveSessions, sessionId); + } + + private async executeAttempt( + task: TaskNode, + directory: string, + options: TaskExecutionOptions, + inactivityTimeoutMs: number, + ): Promise { + const engineType = (task.engineType as EngineType) || this.defaultEngineType; + + const session = await this.engineManager.createSession(engineType, directory, task.worktreeId); + task.sessionId = session.id; + + this.registerAutoApprove(session.id); + options.onSessionCreated?.(session.id); + + if (options.shouldCancel?.()) { + return { + sessionId: session.id, + summary: "", + error: "Task cancelled before execution started.", + }; + } + + let prompt = task.prompt; + if (options.upstreamContext) { + prompt = `${options.upstreamContext}\n\n---\n\nYour task:\n${task.prompt}`; + } + + try { + const message = await this.waitForSessionResponse( + session.id, + this.engineManager.sendMessage(session.id, [ + { type: "text", text: prompt }, + ]), + inactivityTimeoutMs, + ); + + const summary = extractTextFromMessage(message); + + if (message.error) { + return { sessionId: session.id, summary, error: message.error }; + } + + return { sessionId: session.id, summary }; + } catch (error) { + if (!stringifyError(error).includes("of inactivity")) { + await this.cancelSessionQuietly(session.id); + } + throw error; + } + } + + private async waitForSessionResponse( + sessionId: string, + responsePromise: Promise, + inactivityTimeoutMs: number, + ): Promise { + const eventSource: Pick & Partial> = + this.engineManager; + + return await new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType | null = null; + + const finish = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + cleanup(); + callback(); + }; + + const resetTimer = () => { + if (settled) { + return; + } + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + void this.cancelSessionQuietly(sessionId).finally(() => { + finish(() => reject(new Error(`Task timed out after ${formatDuration(inactivityTimeoutMs)} of inactivity.`))); + }); + }, inactivityTimeoutMs); + }; + + const handlePartUpdated = (data: { sessionId: string }) => { + if (data.sessionId === sessionId) { + resetTimer(); + } + }; + const handleMessageUpdated = (data: { sessionId: string }) => { + if (data.sessionId === sessionId) { + resetTimer(); + } + }; + const handlePermissionAsked = (data: { permission: { sessionId: string } }) => { + if (data.permission.sessionId === sessionId) { + resetTimer(); + } + }; + const handleQuestionAsked = (data: { question: { sessionId: string } }) => { + if (data.question.sessionId === sessionId) { + resetTimer(); + } + }; + + const cleanup = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + eventSource.off?.("message.part.updated", handlePartUpdated); + eventSource.off?.("message.updated", handleMessageUpdated); + eventSource.off?.("permission.asked", handlePermissionAsked); + eventSource.off?.("question.asked", handleQuestionAsked); + }; + + eventSource.on?.("message.part.updated", handlePartUpdated); + eventSource.on?.("message.updated", handleMessageUpdated); + eventSource.on?.("permission.asked", handlePermissionAsked); + eventSource.on?.("question.asked", handleQuestionAsked); + + resetTimer(); + + responsePromise.then( + (message) => finish(() => resolve(message)), + (error) => finish(() => reject(error)), + ); + }); + } + + private async cancelSessionQuietly(sessionId: string): Promise { + try { + await this.engineManager.cancelMessage(sessionId); + } catch { + // Best effort cleanup only. } - this.autoApproveSessions.add(sessionId); } } diff --git a/src/locales/en.ts b/src/locales/en.ts index 1a1d4df8..0047db97 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -74,6 +74,22 @@ export interface LocaleDict { noModelError: string; queued: string; disconnected: string; + teamRunsTitle: string; + teamRunLabel: string; + teamRunModeLight: string; + teamRunModeHeavy: string; + teamRunPlanning: string; + teamRunRunning: string; + teamRunCompleted: string; + teamRunFailed: string; + teamRunCancelled: string; + teamRunActive: string; + teamRunCancel: string; + teamRunFinalResult: string; + teamTaskDependsOn: string; + teamTaskEngine: string; + teamTaskWorktree: string; + teamTaskOpenSession: string; }; // Settings page @@ -698,6 +714,22 @@ export const en: LocaleDict = { noModelError: "No model configured. Please set a model in Settings > Engines.", queued: "Queued", disconnected: "Disconnected", + teamRunsTitle: "Team runs", + teamRunLabel: "Team", + teamRunModeLight: "light", + teamRunModeHeavy: "heavy", + teamRunPlanning: "Planning...", + teamRunRunning: "{completed}/{total} tasks", + teamRunCompleted: "Completed", + teamRunFailed: "Failed", + teamRunCancelled: "Cancelled", + teamRunActive: "Active", + teamRunCancel: "Cancel", + teamRunFinalResult: "Final result", + teamTaskDependsOn: "Depends on: {ids}", + teamTaskEngine: "Engine: {engine}", + teamTaskWorktree: "Worktree: {name}", + teamTaskOpenSession: "Open session", }, // Settings page diff --git a/src/locales/ru.ts b/src/locales/ru.ts index bd04b708..72284c0f 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -76,6 +76,22 @@ export const ru: LocaleDict = { noModelError: "Модель не настроена. Задайте модель в Настройках > Движки.", queued: "В очереди", disconnected: "Отключено", + teamRunsTitle: "Team runs", + teamRunLabel: "Team", + teamRunModeLight: "light", + teamRunModeHeavy: "heavy", + teamRunPlanning: "Планирование...", + teamRunRunning: "{completed}/{total} задач", + teamRunCompleted: "Завершено", + teamRunFailed: "Ошибка", + teamRunCancelled: "Отменено", + teamRunActive: "Активный", + teamRunCancel: "Отменить", + teamRunFinalResult: "Итоговый результат", + teamTaskDependsOn: "Зависит от: {ids}", + teamTaskEngine: "Движок: {engine}", + teamTaskWorktree: "Worktree: {name}", + teamTaskOpenSession: "Открыть сессию", }, // Settings page diff --git a/src/locales/zh.ts b/src/locales/zh.ts index a52ad10d..489df9bb 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -75,6 +75,22 @@ export const zh: LocaleDict = { noModelError: "未配置模型,请在设置 > 引擎中设置模型。", queued: "排队中", disconnected: "已断开", + teamRunsTitle: "Team Runs", + teamRunLabel: "Team", + teamRunModeLight: "light", + teamRunModeHeavy: "heavy", + teamRunPlanning: "规划中...", + teamRunRunning: "{completed}/{total} 个任务", + teamRunCompleted: "已完成", + teamRunFailed: "失败", + teamRunCancelled: "已取消", + teamRunActive: "当前活跃", + teamRunCancel: "取消", + teamRunFinalResult: "最终结果", + teamTaskDependsOn: "依赖: {ids}", + teamTaskEngine: "引擎: {engine}", + teamTaskWorktree: "Worktree: {name}", + teamTaskOpenSession: "打开会话", }, // Settings page diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index b306d62d..8c85d58b 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -29,7 +29,23 @@ import { SessionSidebar } from "../components/SessionSidebar"; import { HideProjectModal } from "../components/HideProjectModal"; import { AddProjectModal } from "../components/AddProjectModal"; import { ScheduledTaskModal } from "../components/ScheduledTaskModal"; -import type { UnifiedMessage, UnifiedPart, UnifiedPermission, UnifiedQuestion, UnifiedSession, UnifiedProject, AgentMode, EngineType, SessionActivityStatus, EngineCommand, ScheduledTask, ScheduledTaskCreateRequest, ScheduledTaskUpdateRequest } from "../types/unified"; +import type { + UnifiedMessage, + UnifiedPart, + UnifiedPermission, + UnifiedQuestion, + UnifiedSession, + UnifiedProject, + AgentMode, + EngineType, + SessionActivityStatus, + EngineCommand, + ScheduledTask, + ScheduledTaskCreateRequest, + ScheduledTaskUpdateRequest, + TeamRun, + TaskNode, +} from "../types/unified"; import WorktreeModal from "../components/WorktreeModal"; import MergeWorktreeModal from "../components/MergeWorktreeModal"; import { DeleteWorktreeModal } from "../components/DeleteWorktreeModal"; @@ -61,11 +77,11 @@ import { createTeamRun, getActiveHeavyTeamRunForSession, getActiveTeamRunForSession, - getTeamRunForSession, + getTeamRunsForSession, + hydrateTeamRuns, cancelTeamRun, sendTeamRunMessage, } from "../stores/team"; -import { teamStore } from "../stores/team"; import { computeActiveSessions } from "../lib/active-sessions"; // Binary search helper (consistent with opencode desktop) @@ -809,14 +825,16 @@ export default function Chat() { (async () => { // Load all projects and sessions from ConversationStore (single call each) try { - const [allProjects, allSessions] = await Promise.all([ + const [allProjects, allSessions, allTeamRuns] = await Promise.all([ gateway.listAllProjects(), gateway.listAllSessions(), + gateway.listTeamRuns(), ]); if (gen !== initGeneration || disposed) return; setSessionStore("projects", allProjects); + hydrateTeamRuns(allTeamRuns); // Filter sessions to valid directories only (worktree sessions pass through via worktreeId) const validDirectories = new Set(allProjects.map(p => p.directory)); @@ -1763,10 +1781,10 @@ export default function Chat() { } }; - const currentTeamRun = createMemo(() => { + const sessionTeamRuns = createMemo(() => { const sid = sessionStore.current; - if (!sid) return undefined; - return getTeamRunForSession(sid); + if (!sid) return []; + return getTeamRunsForSession(sid); }); const activeTeamRun = createMemo(() => { @@ -1781,16 +1799,84 @@ export default function Chat() { return getActiveHeavyTeamRunForSession(sid); }); - const handleCancelTeamRun = async () => { - const run = currentTeamRun(); - if (!run) return; + const handleCancelTeamRun = async (runId: string) => { try { - await cancelTeamRun(run.id); + await cancelTeamRun(runId); } catch (err: any) { logger.error("[TeamRun] Failed to cancel:", err); } }; + const isTerminalTeamRun = (run: TeamRun) => + ["completed", "failed", "cancelled"].includes(run.status); + + const getTeamRunStatusColor = (run: TeamRun) => { + switch (run.status) { + case "completed": + return "bg-green-100 dark:bg-green-900/20 border-green-200 dark:border-green-800"; + case "failed": + return "bg-red-100 dark:bg-red-900/20 border-red-200 dark:border-red-800"; + case "cancelled": + return "bg-gray-100 dark:bg-gray-900/20 border-gray-200 dark:border-gray-700"; + default: + return "bg-amber-50 dark:bg-amber-900/15 border-amber-200/50 dark:border-amber-700/30"; + } + }; + + const getTeamRunStatusLabel = (run: TeamRun) => { + const completed = run.tasks.filter((task) => task.status === "completed").length; + switch (run.status) { + case "planning": + return t().chat.teamRunPlanning; + case "running": + return formatMessage(t().chat.teamRunRunning, { + completed, + total: run.tasks.length, + }); + case "completed": + return t().chat.teamRunCompleted; + case "failed": + return t().chat.teamRunFailed; + case "cancelled": + return t().chat.teamRunCancelled; + default: + return run.status; + } + }; + + const getTeamRunModeLabel = (run: TeamRun) => + run.mode === "heavy" ? t().chat.teamRunModeHeavy : t().chat.teamRunModeLight; + + const getTeamTaskStatusIcon = (task: TaskNode) => { + switch (task.status) { + case "completed": + return "\u2713"; + case "failed": + return "\u2717"; + case "running": + return "\u25CB"; + case "blocked": + return "\u25CB"; + case "cancelled": + return "\u2014"; + default: + return "\u00B7"; + } + }; + + const getTeamTaskStatusColor = (task: TaskNode) => { + switch (task.status) { + case "completed": + return "text-green-600 dark:text-green-400"; + case "failed": + return "text-red-600 dark:text-red-400"; + case "running": + return "text-amber-600 dark:text-amber-400"; + default: + return "text-gray-500 dark:text-gray-400"; + } + }; + const appendOptimisticUserText = (sessionId: string, text: string): string => { const nonce = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const tempMessageId = `msg-temp-${nonce}`; @@ -2337,96 +2423,138 @@ export default function Chat() {
- {/* Team Run Status Bar */} - - {(run) => { - const completedCount = () => run().tasks.filter(t => t.status === "completed").length; - const totalCount = () => run().tasks.length; - const isTerminal = () => ["completed", "failed", "cancelled"].includes(run().status); - const statusColor = () => { - switch (run().status) { - case "completed": return "bg-green-100 dark:bg-green-900/20 border-green-200 dark:border-green-800"; - case "failed": return "bg-red-100 dark:bg-red-900/20 border-red-200 dark:border-red-800"; - case "cancelled": return "bg-gray-100 dark:bg-gray-900/20 border-gray-200 dark:border-gray-700"; - default: return "bg-amber-50 dark:bg-amber-900/15 border-amber-200/50 dark:border-amber-700/30"; - } - }; - const statusLabel = () => { - switch (run().status) { - case "planning": return "Planning..."; - case "running": return `${completedCount()}/${totalCount()} tasks`; - case "completed": return "Completed"; - case "failed": return "Failed"; - case "cancelled": return "Cancelled"; - default: return run().status; - } - }; - return ( -
-
-
- - Team ({run().mode}) - - {statusLabel()} -
- - - -
- 0}> -
- - {(task) => { - const icon = () => { - switch (task.status) { - case "completed": return "\u2713"; - case "failed": return "\u2717"; - case "running": return "\u25CB"; - case "blocked": return "\u25CB"; - case "cancelled": return "\u2014"; - default: return "\u00B7"; - } - }; - const color = () => { - switch (task.status) { - case "completed": return "text-green-600 dark:text-green-400"; - case "failed": return "text-red-600 dark:text-red-400"; - case "running": return "text-amber-600 dark:text-amber-400"; - default: return "text-gray-400 dark:text-gray-500"; - } - }; - return ( -
- {icon()} - {task.id}: {task.description} - - - -
- ); - }} -
-
-
- -
- {t().prompt.teamRelayNotice} -
-
- -
- {run().finalResult} + {/* Team Runs */} + 0}> +
+
+ {t().chat.teamRunsTitle} +
+ + {(run) => { + const isActiveRun = () => run.id === activeTeamRun()?.id; + const isRelayRun = () => run.id === activeHeavyRelayRun()?.id; + + return ( +
+
+
+
+ + {t().chat.teamRunLabel} ({getTeamRunModeLabel(run)}) + + + {getTeamRunStatusLabel(run)} + + + + {t().chat.teamRunActive} + + +
+
+ {run.id} +
+
+ + + +
+ + 0}> +
+ + {(task) => ( +
+
+
+
+ {getTeamTaskStatusIcon(task)} + {task.id} + {task.description} +
+ +
+ 0}> + + {formatMessage(t().chat.teamTaskDependsOn, { + ids: task.dependsOn.join(", "), + })} + + + + + {formatMessage(t().chat.teamTaskEngine, { + engine: task.engineType!, + })} + + + + + {formatMessage(t().chat.teamTaskWorktree, { + name: task.worktreeId!, + })} + + + + + +
+ + +
+ {task.error} +
+
+ +
+ {task.result} +
+
+
+ + + + +
+
+ )} +
+
+
+ + +
+ {t().prompt.teamRelayNotice} +
+
+ + +
+
+ {t().chat.teamRunFinalResult} +
+
+ {run.finalResult} +
+
+
- -
- ); - }} + ); + }} + +
{ - if (!best) return run; - const bestActive = isActiveTeamRun(best); - const runActive = isActiveTeamRun(run); - if (runActive !== bestActive) { - return runActive ? run : best; - } - return run.time.created > best.time.created ? run : best; - }, runs[0]); + return [...runs].sort(compareTeamRuns)[0]; } /** Initialize notification handlers for team events */ @@ -75,6 +76,17 @@ export function connectTeamHandlers(): { }; } +/** Replace the known team runs with a hydrated snapshot from the backend. */ +export function hydrateTeamRuns(runs: TeamRun[]): void { + const activeRunId = teamStore.activeRunId; + const nextActiveRunId = activeRunId && runs.some((run) => run.id === activeRunId) + ? activeRunId + : null; + + setTeamStore("runs", runs); + setTeamStore("activeRunId", nextActiveRunId); +} + /** Create a new team run */ export async function createTeamRun( sessionId: string, @@ -105,6 +117,13 @@ export function getTeamRunForSession(sessionId: string): TeamRun | undefined { return pickPreferredRun(teamStore.runs.filter((r) => r.parentSessionId === sessionId)); } +/** Get all known team runs for a session, sorted by activity then recency. */ +export function getTeamRunsForSession(sessionId: string): TeamRun[] { + return teamStore.runs + .filter((run) => run.parentSessionId === sessionId) + .sort(compareTeamRuns); +} + /** Get the active team run for a given session, if any */ export function getActiveTeamRunForSession(sessionId: string): TeamRun | undefined { return pickPreferredRun( @@ -121,7 +140,7 @@ export function getActiveHeavyTeamRunForSession(sessionId: string): TeamRun | un ); } -/** Send a user follow-up message to an active team run orchestrator */ +/** Relay a user follow-up message to an active Heavy Brain orchestrator */ export async function sendTeamRunMessage(runId: string, text: string): Promise { await gateway.sendTeamMessage(runId, text); } diff --git a/src/types/unified.ts b/src/types/unified.ts index 2eca0bb4..381d96fa 100644 --- a/src/types/unified.ts +++ b/src/types/unified.ts @@ -1047,6 +1047,8 @@ export interface TeamGetRequest { } export interface TeamSendMessageRequest { + /** Active Heavy Brain run receiving the relayed follow-up */ runId: string; + /** Follow-up text forwarded to the Heavy Brain orchestrator */ text: string; } diff --git a/tests/unit/electron/gateway/ws-server.test.ts b/tests/unit/electron/gateway/ws-server.test.ts index c87afc80..c8c7a67c 100644 --- a/tests/unit/electron/gateway/ws-server.test.ts +++ b/tests/unit/electron/gateway/ws-server.test.ts @@ -46,6 +46,15 @@ const mockScheduledTaskService = vi.hoisted(() => ({ runNow: vi.fn(), })); +const mockAgentTeamService = vi.hoisted(() => ({ + on: vi.fn(), + createRun: vi.fn(async () => ({ id: "team-1" })), + cancelRun: vi.fn(async () => {}), + sendMessageToRun: vi.fn(), + listRuns: vi.fn(() => []), + getRun: vi.fn(async () => null), +})); + // --------------------------------------------------------------------------- // Module mocks // --------------------------------------------------------------------------- @@ -114,6 +123,10 @@ vi.mock("../../../../electron/main/services/scheduled-task-service", () => ({ scheduledTaskService: mockScheduledTaskService, })); +vi.mock("../../../../electron/main/services/agent-team", () => ({ + agentTeamService: mockAgentTeamService, +})); + // --------------------------------------------------------------------------- // Import SUT after mocks are established // --------------------------------------------------------------------------- @@ -290,6 +303,17 @@ describe("GatewayServer", () => { expect(subscribedEvents).toContain("tasks.changed"); }); + it("subscribes to agent team service events", () => { + const engineManager = createMockEngineManager(); + new GatewayServer(engineManager as any); + + const subscribedEvents = mockAgentTeamService.on.mock.calls.map( + (call: any[]) => call[0], + ); + expect(subscribedEvents).toContain("team.run.updated"); + expect(subscribedEvents).toContain("team.task.updated"); + }); + it("registers file change handler via onFileChange", () => { const engineManager = createMockEngineManager(); new GatewayServer(engineManager as any); @@ -1329,6 +1353,96 @@ describe("GatewayServer", () => { }); }); + // ========================================================================= + // routeRequest - agent team + // ========================================================================= + + describe("routeRequest - agent team", () => { + it("TEAM_CREATE delegates to agentTeamService.createRun", async () => { + mockAgentTeamService.createRun.mockResolvedValueOnce({ id: "team-1", status: "planning" }); + const { connect, sendMessage } = createTestHarness(); + const ws = connect(); + + const payload = { + sessionId: "sess-1", + prompt: "Investigate issue", + mode: "heavy", + directory: "/repo", + engineType: "claude", + }; + + await sendMessage(ws, { + type: GatewayRequestType.TEAM_CREATE, + requestId: "r1", + payload, + }); + + expect(mockAgentTeamService.createRun).toHaveBeenCalledWith(payload); + const response = JSON.parse(ws.send.mock.calls[0][0]); + expect(response.payload).toEqual({ id: "team-1", status: "planning" }); + }); + + it("TEAM_CANCEL delegates to agentTeamService.cancelRun", async () => { + const { connect, sendMessage } = createTestHarness(); + const ws = connect(); + + await sendMessage(ws, { + type: GatewayRequestType.TEAM_CANCEL, + requestId: "r1", + payload: { runId: "team-1" }, + }); + + expect(mockAgentTeamService.cancelRun).toHaveBeenCalledWith("team-1"); + }); + + it("TEAM_SEND_MESSAGE delegates to agentTeamService.sendMessageToRun", async () => { + const { connect, sendMessage } = createTestHarness(); + const ws = connect(); + + await sendMessage(ws, { + type: GatewayRequestType.TEAM_SEND_MESSAGE, + requestId: "r1", + payload: { runId: "team-1", text: "Need a tighter plan" }, + }); + + expect(mockAgentTeamService.sendMessageToRun).toHaveBeenCalledWith("team-1", "Need a tighter plan"); + const response = JSON.parse(ws.send.mock.calls[0][0]); + expect(response.payload).toBeUndefined(); + }); + + it("TEAM_LIST delegates to agentTeamService.listRuns", async () => { + mockAgentTeamService.listRuns.mockReturnValueOnce([{ id: "team-1" }]); + const { connect, sendMessage } = createTestHarness(); + const ws = connect(); + + await sendMessage(ws, { + type: GatewayRequestType.TEAM_LIST, + requestId: "r1", + payload: {}, + }); + + expect(mockAgentTeamService.listRuns).toHaveBeenCalled(); + const response = JSON.parse(ws.send.mock.calls[0][0]); + expect(response.payload).toEqual([{ id: "team-1" }]); + }); + + it("TEAM_GET delegates to agentTeamService.getRun", async () => { + mockAgentTeamService.getRun.mockResolvedValueOnce({ id: "team-1", status: "running" }); + const { connect, sendMessage } = createTestHarness(); + const ws = connect(); + + await sendMessage(ws, { + type: GatewayRequestType.TEAM_GET, + requestId: "r1", + payload: { runId: "team-1" }, + }); + + expect(mockAgentTeamService.getRun).toHaveBeenCalledWith("team-1"); + const response = JSON.parse(ws.send.mock.calls[0][0]); + expect(response.payload).toEqual({ id: "team-1", status: "running" }); + }); + }); + // ========================================================================= // routeRequest - unknown type // ========================================================================= @@ -1562,6 +1676,41 @@ describe("GatewayServer", () => { }), ); }); + + it("broadcasts team run and task events", () => { + const { connect } = createTestHarness(); + const ws = connect(); + + const teamRunUpdatedCall = mockAgentTeamService.on.mock.calls.find( + (call: any[]) => call[0] === "team.run.updated", + ); + const teamTaskUpdatedCall = mockAgentTeamService.on.mock.calls.find( + (call: any[]) => call[0] === "team.task.updated", + ); + expect(teamRunUpdatedCall).toBeDefined(); + expect(teamTaskUpdatedCall).toBeDefined(); + + const teamRunUpdatedHandler = teamRunUpdatedCall![1]; + const teamTaskUpdatedHandler = teamTaskUpdatedCall![1]; + + teamRunUpdatedHandler({ run: { id: "team-1" } }); + teamTaskUpdatedHandler({ runId: "team-1", task: { id: "task-1" } }); + + expect(ws.send).toHaveBeenNthCalledWith( + 1, + JSON.stringify({ + type: GatewayNotificationType.TEAM_RUN_UPDATED, + payload: { run: { id: "team-1" } }, + }), + ); + expect(ws.send).toHaveBeenNthCalledWith( + 2, + JSON.stringify({ + type: GatewayNotificationType.TEAM_TASK_UPDATED, + payload: { runId: "team-1", task: { id: "task-1" } }, + }), + ); + }); }); // ========================================================================= diff --git a/tests/unit/electron/services/agent-team/dag-executor.test.ts b/tests/unit/electron/services/agent-team/dag-executor.test.ts index 4b55ef83..b80f20d8 100644 --- a/tests/unit/electron/services/agent-team/dag-executor.test.ts +++ b/tests/unit/electron/services/agent-team/dag-executor.test.ts @@ -149,6 +149,56 @@ describe("DAGExecutor", () => { expect(tasks.every((t) => t.status === "completed")).toBe(true); }); + + it("limits concurrently running ready tasks", async () => { + const deferreds = new Map void>(); + let thirdTaskStarted!: () => void; + const thirdTaskStartedPromise = new Promise((resolve) => { + thirdTaskStarted = resolve; + }); + let active = 0; + let maxActive = 0; + + mockTaskExecutor.execute = vi.fn((task: TaskNode) => { + active += 1; + maxActive = Math.max(maxActive, active); + if (task.id === "t3") { + thirdTaskStarted(); + } + + return new Promise((resolve) => { + deferreds.set(task.id, () => { + active -= 1; + resolve({ sessionId: `s_${task.id}`, summary: `Result of ${task.id}` }); + }); + }); + }) as any; + + const tasks = [ + makeTask({ id: "t1" }), + makeTask({ id: "t2" }), + makeTask({ id: "t3" }), + ]; + const run = makeRun(tasks); + + const dagExecutor = new DAGExecutor(mockTaskExecutor, "/test", 2); + const executionPromise = dagExecutor.executeReadyTasks(run); + await Promise.resolve(); + + expect(mockTaskExecutor.execute).toHaveBeenCalledTimes(2); + + deferreds.get("t1")?.(); + deferreds.get("t2")?.(); + await thirdTaskStartedPromise; + + expect(mockTaskExecutor.execute).toHaveBeenCalledTimes(3); + + deferreds.get("t3")?.(); + await executionPromise; + + expect(maxActive).toBe(2); + expect(tasks.every((task) => task.status === "completed")).toBe(true); + }); }); describe("DAGExecutor static helpers", () => { diff --git a/tests/unit/electron/services/agent-team/heavy-brain.test.ts b/tests/unit/electron/services/agent-team/heavy-brain.test.ts new file mode 100644 index 00000000..311bda8f --- /dev/null +++ b/tests/unit/electron/services/agent-team/heavy-brain.test.ts @@ -0,0 +1,527 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../../../../../electron/main/services/agent-team/logger", () => ({ + agentTeamLog: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { HeavyBrainOrchestrator } from "../../../../../electron/main/services/agent-team/heavy-brain"; +import type { TeamRun, UnifiedMessage } from "../../../../../src/types/unified"; + +function makeTextMessage(text: string, overrides: Partial = {}): UnifiedMessage { + const messageId = `msg-${Math.random().toString(36).slice(2, 8)}`; + return { + id: messageId, + sessionId: "session", + role: "assistant", + time: { created: Date.now() }, + parts: [{ + id: `${messageId}-part`, + messageId, + sessionId: "session", + type: "text", + text, + }] as any, + ...overrides, + }; +} + +function makeJsonMessage(payload: unknown, overrides: Partial = {}): UnifiedMessage { + return makeTextMessage(`\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``, overrides); +} + +function makeRun(overrides: Partial = {}): TeamRun { + return { + id: "team-run", + parentSessionId: "parent-session", + directory: "/repo", + originalPrompt: "Do the work", + mode: "heavy", + status: "planning", + tasks: [], + time: { created: 1_000 }, + ...overrides, + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createEngineManagerMock( + sessionIds: string[], + sendMessage: (sessionId: string, content: Array<{ type: string; text?: string }>) => Promise, +) { + return { + getDefaultEngineType: vi.fn(() => "opencode"), + listEngines: vi.fn(() => [{ + type: "opencode", + name: "OpenCode", + status: "running", + }]), + createSession: vi.fn(async () => ({ id: sessionIds.shift() ?? "session-fallback" })), + sendMessage: vi.fn(sendMessage), + cancelMessage: vi.fn(async () => {}), + } as any; +} + +describe("HeavyBrainOrchestrator", () => { + it("reports upstream results before starting dependent tasks in the same dispatch batch", async () => { + const events: string[] = []; + const sendMessage = vi.fn(async (sessionId: string, content: Array<{ text?: string }>) => { + const text = content[0]?.text ?? ""; + + if (sessionId === "orch-session") { + if (text.includes("## Task Completed: A")) { + events.push("orchestrator-saw-a"); + return makeJsonMessage({ action: "continueWaiting" }); + } + if (text.includes("## Task Completed: B")) { + events.push("orchestrator-saw-b"); + return makeJsonMessage({ action: "complete", result: "done" }); + } + + events.push("orchestrator-initial"); + return makeJsonMessage({ + action: "dispatch", + tasks: [ + { id: "A", description: "Task A", prompt: "Run A", dependsOn: [] }, + { id: "B", description: "Task B", prompt: "Run B", dependsOn: ["A"] }, + ], + }); + } + + if (sessionId === "worker-a") { + events.push("worker-a-started"); + expect(text).toContain("Run A"); + return makeTextMessage("A result"); + } + + if (sessionId === "worker-b") { + events.push("worker-b-started"); + expect(text).toContain("Run B"); + expect(text).toContain("A result"); + return makeTextMessage("B result"); + } + + throw new Error(`Unexpected session ${sessionId}`); + }); + + const engineManager = createEngineManagerMock( + ["orch-session", "worker-a", "worker-b"], + sendMessage, + ); + const orchestrator = new HeavyBrainOrchestrator(engineManager, new Set()); + const teamRun = makeRun(); + const updates: string[] = []; + + await orchestrator.run(teamRun, "opencode", (task) => { + updates.push(`${task.id}:${task.status}`); + }); + + expect(teamRun.status).toBe("completed"); + expect(teamRun.tasks.map((task) => `${task.id}:${task.status}`)).toEqual([ + "A:completed", + "B:completed", + ]); + expect(updates).toContain("B:pending"); + expect(updates).toContain("B:running"); + expect(events.indexOf("orchestrator-saw-a")).toBeLessThan(events.indexOf("worker-b-started")); + }); + + it("allows later dispatches to depend on already completed tasks", async () => { + const events: string[] = []; + const sendMessage = vi.fn(async (sessionId: string, content: Array<{ text?: string }>) => { + const text = content[0]?.text ?? ""; + + if (sessionId === "orch-session") { + if (text.includes("## Task Completed: A")) { + events.push("orchestrator-dispatched-b"); + return makeJsonMessage({ + action: "dispatch", + tasks: [ + { id: "B", description: "Task B", prompt: "Run B", dependsOn: ["A"] }, + ], + }); + } + if (text.includes("## Task Completed: B")) { + events.push("orchestrator-completed"); + return makeJsonMessage({ action: "complete", result: "done" }); + } + + return makeJsonMessage({ + action: "dispatch", + tasks: [ + { id: "A", description: "Task A", prompt: "Run A", dependsOn: [] }, + ], + }); + } + + if (sessionId === "worker-a") { + events.push("worker-a-started"); + return makeTextMessage("A result"); + } + + if (sessionId === "worker-b") { + events.push("worker-b-started"); + expect(text).toContain("Run B"); + expect(text).toContain("A result"); + return makeTextMessage("B result"); + } + + throw new Error(`Unexpected session ${sessionId}`); + }); + + const engineManager = createEngineManagerMock( + ["orch-session", "worker-a", "worker-b"], + sendMessage, + ); + const orchestrator = new HeavyBrainOrchestrator(engineManager, new Set()); + const teamRun = makeRun(); + + await orchestrator.run(teamRun, "opencode", () => {}); + + expect(teamRun.status).toBe("completed"); + expect(teamRun.tasks.map((task) => `${task.id}:${task.status}`)).toEqual([ + "A:completed", + "B:completed", + ]); + expect(events.indexOf("orchestrator-dispatched-b")).toBeLessThan(events.indexOf("worker-b-started")); + }); + + it("fails the run when a later dispatch reuses an existing task id", async () => { + const sendMessage = vi.fn(async (sessionId: string, content: Array<{ text?: string }>) => { + const text = content[0]?.text ?? ""; + + if (sessionId === "orch-session") { + if (text.includes("## Task Completed: A")) { + return makeJsonMessage({ + action: "dispatch", + tasks: [ + { id: "A", description: "Task A again", prompt: "Run A again", dependsOn: [] }, + ], + }); + } + + return makeJsonMessage({ + action: "dispatch", + tasks: [ + { id: "A", description: "Task A", prompt: "Run A", dependsOn: [] }, + ], + }); + } + + if (sessionId === "worker-a") { + return makeTextMessage("A result"); + } + + throw new Error(`Unexpected session ${sessionId}`); + }); + + const engineManager = createEngineManagerMock(["orch-session", "worker-a"], sendMessage); + const orchestrator = new HeavyBrainOrchestrator(engineManager, new Set()); + const teamRun = makeRun(); + + await orchestrator.run(teamRun, "opencode", () => {}); + + expect(teamRun.status).toBe("failed"); + expect(teamRun.finalResult).toContain("Duplicate task id 'A'"); + expect(teamRun.tasks).toHaveLength(1); + expect(teamRun.tasks[0].status).toBe("completed"); + expect(engineManager.createSession).toHaveBeenCalledTimes(2); + }); + + it("blocks downstream tasks after an upstream failure", async () => { + const events: string[] = []; + const sendMessage = vi.fn(async (sessionId: string, content: Array<{ text?: string }>) => { + const text = content[0]?.text ?? ""; + + if (sessionId === "orch-session") { + if (text.includes("## Task Failed: A")) { + events.push("orchestrator-saw-a-failure"); + return makeJsonMessage({ action: "continueWaiting" }); + } + if (text.includes("## Task Execution Results")) { + events.push("orchestrator-saw-summary"); + expect(text).toContain('[BLOCKED]'); + return makeJsonMessage({ action: "complete", result: "handled failure" }); + } + + return makeJsonMessage({ + action: "dispatch", + tasks: [ + { id: "A", description: "Task A", prompt: "Run A", dependsOn: [] }, + { id: "B", description: "Task B", prompt: "Run B", dependsOn: ["A"] }, + ], + }); + } + + if (sessionId === "worker-a") { + events.push("worker-a-started"); + return makeTextMessage("A failed", { error: "boom" }); + } + + if (sessionId === "worker-b") { + throw new Error("Downstream blocked task should not start"); + } + + throw new Error(`Unexpected session ${sessionId}`); + }); + + const engineManager = createEngineManagerMock( + ["orch-session", "worker-a"], + sendMessage, + ); + const orchestrator = new HeavyBrainOrchestrator(engineManager, new Set()); + const teamRun = makeRun(); + + await orchestrator.run(teamRun, "opencode", () => {}); + + expect(teamRun.status).toBe("completed"); + expect(teamRun.finalResult).toBe("handled failure"); + expect(teamRun.tasks.map((task) => `${task.id}:${task.status}`)).toEqual([ + "A:failed", + "B:blocked", + ]); + expect(events).not.toContain("worker-b-started"); + }); + + it("cancels running workers and pending tasks when the orchestrator completes early", async () => { + const workerB = createDeferred(); + const sendMessage = vi.fn(async (sessionId: string, content: Array<{ text?: string }>) => { + const text = content[0]?.text ?? ""; + + if (sessionId === "orch-session") { + if (text.includes("## Task Completed: A")) { + return makeJsonMessage({ action: "complete", result: "done early" }); + } + + return makeJsonMessage({ + action: "dispatch", + tasks: [ + { id: "A", description: "Task A", prompt: "Run A", dependsOn: [] }, + { id: "B", description: "Task B", prompt: "Run B", dependsOn: [] }, + { id: "C", description: "Task C", prompt: "Run C", dependsOn: ["A"] }, + ], + }); + } + + if (sessionId === "worker-a") { + return makeTextMessage("A result"); + } + + if (sessionId === "worker-b") { + return workerB.promise; + } + + throw new Error(`Unexpected session ${sessionId}`); + }); + + const engineManager = createEngineManagerMock( + ["orch-session", "worker-a", "worker-b"], + sendMessage, + ); + const orchestrator = new HeavyBrainOrchestrator(engineManager, new Set()); + const teamRun = makeRun(); + + await orchestrator.run(teamRun, "opencode", () => {}); + + expect(teamRun.status).toBe("completed"); + expect(teamRun.finalResult).toBe("done early"); + expect(teamRun.tasks.map((task) => `${task.id}:${task.status}`)).toEqual([ + "A:completed", + "B:cancelled", + "C:cancelled", + ]); + expect(engineManager.cancelMessage).toHaveBeenCalledWith("worker-b"); + + workerB.resolve(makeTextMessage("late B result")); + await Promise.resolve(); + await Promise.resolve(); + + expect(teamRun.tasks.find((task) => task.id === "B")?.status).toBe("cancelled"); + }); + + it("cancels active sessions on user cancel and ignores late worker results", async () => { + const workerStarted = createDeferred(); + const workerResult = createDeferred(); + const sendMessage = vi.fn(async (sessionId: string) => { + if (sessionId === "orch-session") { + return makeJsonMessage({ + action: "dispatch", + tasks: [ + { id: "A", description: "Task A", prompt: "Run A", dependsOn: [] }, + ], + }); + } + + if (sessionId === "worker-a") { + workerStarted.resolve(); + return workerResult.promise; + } + + throw new Error(`Unexpected session ${sessionId}`); + }); + + const engineManager = createEngineManagerMock( + ["orch-session", "worker-a"], + sendMessage, + ); + const orchestrator = new HeavyBrainOrchestrator(engineManager, new Set()); + const teamRun = makeRun(); + + const runPromise = orchestrator.run(teamRun, "opencode", () => {}); + await workerStarted.promise; + + await orchestrator.cancel(); + await runPromise; + + expect(teamRun.status).toBe("cancelled"); + expect(teamRun.finalResult).toBe("Orchestration was cancelled."); + expect(teamRun.tasks.map((task) => `${task.id}:${task.status}`)).toEqual([ + "A:cancelled", + ]); + expect(engineManager.cancelMessage).toHaveBeenCalledWith("orch-session"); + expect(engineManager.cancelMessage).toHaveBeenCalledWith("worker-a"); + + workerResult.resolve(makeTextMessage("late result")); + await Promise.resolve(); + await Promise.resolve(); + + expect(teamRun.tasks[0].status).toBe("cancelled"); + }); + + it("respects the max concurrent task limit when starting ready workers", async () => { + const workerA = createDeferred(); + const workerB = createDeferred(); + const workerC = createDeferred(); + const workerAStarted = createDeferred(); + const workerBStarted = createDeferred(); + const workerCStarted = createDeferred(); + const started: string[] = []; + + let orchestrationStep = 0; + const sendMessage = vi.fn(async (sessionId: string, content: Array<{ text?: string }>) => { + const text = content[0]?.text ?? ""; + + if (sessionId === "orch-session") { + if (text.includes("## Task Completed: A")) { + orchestrationStep += 1; + return makeJsonMessage({ action: "continueWaiting" }); + } + if (text.includes("## Task Completed: B")) { + orchestrationStep += 1; + return makeJsonMessage({ action: "continueWaiting" }); + } + if (text.includes("## Task Completed: C")) { + orchestrationStep += 1; + return makeJsonMessage({ action: "complete", result: "done" }); + } + + return makeJsonMessage({ + action: "dispatch", + tasks: [ + { id: "A", description: "Task A", prompt: "Run A", dependsOn: [] }, + { id: "B", description: "Task B", prompt: "Run B", dependsOn: [] }, + { id: "C", description: "Task C", prompt: "Run C", dependsOn: [] }, + ], + }); + } + + if (sessionId === "worker-a") { + started.push("A"); + workerAStarted.resolve(); + return workerA.promise; + } + + if (sessionId === "worker-b") { + started.push("B"); + workerBStarted.resolve(); + return workerB.promise; + } + + if (sessionId === "worker-c") { + started.push("C"); + workerCStarted.resolve(); + return workerC.promise; + } + + throw new Error(`Unexpected session ${sessionId}`); + }); + + const engineManager = createEngineManagerMock( + ["orch-session", "worker-a", "worker-b", "worker-c"], + sendMessage, + ); + const orchestrator = new HeavyBrainOrchestrator(engineManager, new Set(), 1); + const teamRun = makeRun(); + + const runPromise = orchestrator.run(teamRun, "opencode", () => {}); + await workerAStarted.promise; + + expect(started).toEqual(["A"]); + expect(engineManager.createSession).toHaveBeenCalledTimes(2); + + workerA.resolve(makeTextMessage("A result")); + await workerBStarted.promise; + + expect(started).toEqual(["A", "B"]); + expect(engineManager.createSession).toHaveBeenCalledTimes(3); + + workerB.resolve(makeTextMessage("B result")); + await workerCStarted.promise; + + expect(started).toEqual(["A", "B", "C"]); + expect(engineManager.createSession).toHaveBeenCalledTimes(4); + + workerC.resolve(makeTextMessage("C result")); + await runPromise; + + expect(orchestrationStep).toBe(3); + expect(teamRun.status).toBe("completed"); + }); + + it("passes dispatch worktreeId through to worker sessions", async () => { + const sendMessage = vi.fn(async (sessionId: string) => { + if (sessionId === "orch-session") { + return makeJsonMessage({ + action: "dispatch", + tasks: [ + { + id: "A", + description: "Task A", + prompt: "Run A", + dependsOn: [], + worktreeId: "feature-branch", + }, + ], + }); + } + + if (sessionId === "worker-a") { + return makeTextMessage("A result"); + } + + throw new Error(`Unexpected session ${sessionId}`); + }); + + const engineManager = createEngineManagerMock(["orch-session", "worker-a"], sendMessage); + const orchestrator = new HeavyBrainOrchestrator(engineManager, new Set()); + const teamRun = makeRun(); + + await orchestrator.run(teamRun, "opencode", () => {}); + + expect(teamRun.tasks[0].worktreeId).toBe("feature-branch"); + expect(engineManager.createSession).toHaveBeenNthCalledWith(2, "opencode", "/repo", "feature-branch"); + }); +}); diff --git a/tests/unit/electron/services/agent-team/index.test.ts b/tests/unit/electron/services/agent-team/index.test.ts new file mode 100644 index 00000000..de08332e --- /dev/null +++ b/tests/unit/electron/services/agent-team/index.test.ts @@ -0,0 +1,293 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { EventEmitter } from "events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(), + }, +})); + +vi.mock("../../../../../electron/main/services/agent-team/logger", () => ({ + agentTeamLog: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { app } from "electron"; +import { AgentTeamService } from "../../../../../electron/main/services/agent-team"; +import type { EngineType, TaskNode, TeamRun, UnifiedMessage } from "../../../../../src/types/unified"; + +function makeTextMessage(text: string, overrides: Partial = {}): UnifiedMessage { + const messageId = `msg-${Math.random().toString(36).slice(2, 8)}`; + return { + id: messageId, + sessionId: "session", + role: "assistant", + time: { created: Date.now() }, + parts: [{ + id: `${messageId}-part`, + messageId, + sessionId: "session", + type: "text", + text, + }] as any, + ...overrides, + }; +} + +function makeJsonMessage(payload: unknown, overrides: Partial = {}): UnifiedMessage { + return makeTextMessage(`\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``, overrides); +} + +function makeTask(overrides: Partial = {}): TaskNode { + return { + id: "task-a", + description: "Task A", + prompt: "Run A", + dependsOn: [], + status: "pending", + ...overrides, + }; +} + +function makeRun(overrides: Partial = {}): TeamRun { + return { + id: "team-run", + parentSessionId: "parent-session", + directory: "/repo", + originalPrompt: "Do the work", + mode: "light", + status: "planning", + tasks: [], + time: { created: 1_000 }, + ...overrides, + }; +} + +function createEngineManagerMock(defaultEngineType: EngineType = "opencode") { + const emitter = new EventEmitter(); + const sessionIds = ["planner-session", "worker-session", "extra-session"]; + + return { + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + emitter.on(event, handler); + }), + off: vi.fn(), + replyPermission: vi.fn(), + getDefaultEngineType: vi.fn(() => defaultEngineType), + listEngines: vi.fn(() => [ + { type: "opencode", name: "OpenCode", status: "running" }, + { type: "claude", name: "Claude", status: "running" }, + { type: "copilot", name: "Copilot", status: "running" }, + ]), + createSession: vi.fn(async () => ({ id: sessionIds.shift() ?? "session-fallback" })), + sendMessage: vi.fn(async (sessionId: string) => { + if (sessionId === "planner-session") { + return makeJsonMessage({ + tasks: [ + { id: "A", description: "Task A", prompt: "Run A", dependsOn: [] }, + ], + }); + } + + if (sessionId === "worker-session") { + return makeTextMessage("A result"); + } + + throw new Error(`Unexpected session ${sessionId}`); + }), + cancelMessage: vi.fn(async () => {}), + } as any; +} + +let tmpDir: string; +const services: AgentTeamService[] = []; + +function createService(): AgentTeamService { + const service = new AgentTeamService(); + services.push(service); + return service; +} + +describe("AgentTeamService", () => { + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-team-service-")); + vi.mocked(app.getPath).mockReturnValue(tmpDir); + vi.clearAllMocks(); + }); + + afterEach(async () => { + for (const service of services.splice(0)) { + await service.shutdown(); + } + + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + describe("light planner engine", () => { + it.each([ + { requested: "claude" as EngineType, expected: "claude" as EngineType }, + { requested: undefined, expected: "opencode" as EngineType }, + ])("uses $expected for the planning session", async ({ requested, expected }) => { + const engineManager = createEngineManagerMock("opencode"); + const service = createService(); + const run = makeRun(); + + service.init(engineManager); + + await (service as any).executeRun(run, requested); + + expect(engineManager.createSession).toHaveBeenNthCalledWith( + 1, + expected, + "/repo", + undefined, + expect.objectContaining({ + systemPrompt: expect.any(String), + }), + ); + expect(run.status).toBe("completed"); + expect(run.tasks.map((task) => `${task.id}:${task.status}`)).toEqual([ + "A:completed", + ]); + }); + }); + + describe("relay contract", () => { + it("relays messages to active Heavy Brain runs", () => { + const service = createService(); + const run = makeRun({ + id: "heavy-run", + mode: "heavy", + status: "running", + }); + const channel = { send: vi.fn() }; + + (service as any).runs.set(run.id, run); + (service as any).activeRelayChannels.set(run.id, channel); + + service.sendMessageToRun(run.id, "Need a tighter plan"); + + expect(channel.send).toHaveBeenCalledWith("Need a tighter plan"); + }); + + it("rejects relay messages for Light Brain runs with a mode-specific error", () => { + const service = createService(); + const run = makeRun({ + id: "light-run", + mode: "light", + status: "running", + }); + + (service as any).runs.set(run.id, run); + + expect(() => service.sendMessageToRun(run.id, "Can you revise the plan?")).toThrow( + "Relay messaging is only supported for active Heavy Brain runs.", + ); + }); + }); + + describe("lifecycle hardening", () => { + it("loads persisted runs and marks in-progress work as interrupted", () => { + const filePath = path.join(tmpDir, "agent-team-runs.json"); + const persistedRunningRun = makeRun({ + id: "persisted-running", + mode: "heavy", + status: "running", + orchestratorSessionId: "orch-session", + tasks: [ + makeTask({ + id: "A", + status: "completed", + result: "done", + time: { started: 10, completed: 20 }, + }), + makeTask({ + id: "B", + description: "Task B", + prompt: "Run B", + dependsOn: ["A"], + status: "running", + time: { started: 30 }, + }), + makeTask({ + id: "C", + description: "Task C", + prompt: "Run C", + dependsOn: ["B"], + status: "pending", + }), + ], + }); + const persistedCompletedRun = makeRun({ + id: "persisted-complete", + status: "completed", + finalResult: "done", + time: { created: 500, completed: 700 }, + }); + + fs.writeFileSync(filePath, JSON.stringify({ + version: 1, + runs: [persistedRunningRun, persistedCompletedRun], + }, null, 2), "utf-8"); + + const service = createService(); + service.init(createEngineManagerMock()); + + const recoveredRun = service.getRun("persisted-running"); + expect(recoveredRun?.status).toBe("failed"); + expect(recoveredRun?.finalResult).toContain("interrupted because CodeMux restarted"); + expect(recoveredRun?.time.completed).toBeDefined(); + expect(recoveredRun?.tasks.map((task) => `${task.id}:${task.status}`)).toEqual([ + "A:completed", + "B:cancelled", + "C:cancelled", + ]); + + const saved = JSON.parse(fs.readFileSync(filePath, "utf-8")); + const savedRecovered = saved.runs.find((run: TeamRun) => run.id === "persisted-running"); + expect(savedRecovered.status).toBe("failed"); + expect(savedRecovered.tasks.map((task: TaskNode) => task.status)).toEqual([ + "completed", + "cancelled", + "cancelled", + ]); + }); + + it("cleans up run-scoped auto-approve sessions and persists run snapshots", async () => { + vi.useFakeTimers(); + + const service = createService(); + const engineManager = createEngineManagerMock(); + const run = makeRun({ id: "light-run", mode: "light" }); + const filePath = path.join(tmpDir, "agent-team-runs.json"); + + service.init(engineManager); + (service as any).runs.set(run.id, run); + + await (service as any).executeRun(run, "opencode"); + await vi.runAllTimersAsync(); + + expect(run.status).toBe("completed"); + expect((service as any).autoApproveSessions.size).toBe(0); + expect((service as any).autoApproveSessionsByRun.size).toBe(0); + expect((service as any).activeOrchestrators.size).toBe(0); + expect((service as any).activeRelayChannels.size).toBe(0); + expect(fs.existsSync(filePath)).toBe(true); + + const saved = JSON.parse(fs.readFileSync(filePath, "utf-8")); + expect(saved.runs.some((savedRun: TeamRun) => savedRun.id === "light-run")).toBe(true); + }); + }); +}); diff --git a/tests/unit/electron/services/agent-team/light-brain.test.ts b/tests/unit/electron/services/agent-team/light-brain.test.ts new file mode 100644 index 00000000..a731c2e8 --- /dev/null +++ b/tests/unit/electron/services/agent-team/light-brain.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../../../../../electron/main/services/agent-team/logger", () => ({ + agentTeamLog: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { LightBrainOrchestrator } from "../../../../../electron/main/services/agent-team/light-brain"; +import type { TeamRun, UnifiedMessage } from "../../../../../src/types/unified"; + +function makeTextMessage(text: string, overrides: Partial = {}): UnifiedMessage { + const messageId = `msg-${Math.random().toString(36).slice(2, 8)}`; + return { + id: messageId, + sessionId: "session", + role: "assistant", + time: { created: Date.now() }, + parts: [{ + id: `${messageId}-part`, + messageId, + sessionId: "session", + type: "text", + text, + }] as any, + ...overrides, + }; +} + +function makeRun(overrides: Partial = {}): TeamRun { + return { + id: "team-run", + parentSessionId: "parent-session", + directory: "/repo", + originalPrompt: "Do the work", + mode: "light", + status: "planning", + tasks: [], + time: { created: 1_000 }, + ...overrides, + }; +} + +describe("LightBrainOrchestrator", () => { + it("passes planner-provided worktreeId to worker sessions", async () => { + const engineManager = { + getDefaultEngineType: vi.fn(() => "opencode"), + listEngines: vi.fn(() => [{ + type: "opencode", + name: "OpenCode", + status: "running", + }]), + createSession: vi.fn() + .mockResolvedValueOnce({ id: "planner-session" }) + .mockResolvedValueOnce({ id: "worker-session" }), + sendMessage: vi.fn(async (sessionId: string) => { + if (sessionId === "planner-session") { + return makeTextMessage(`\`\`\`json +{ + "tasks": [ + { + "id": "t1", + "description": "Edit isolated files", + "prompt": "Apply the requested change", + "dependsOn": [], + "worktreeId": "feature-branch" + } + ] +} +\`\`\``); + } + + if (sessionId === "worker-session") { + return makeTextMessage("worker done"); + } + + throw new Error(`Unexpected session ${sessionId}`); + }), + cancelMessage: vi.fn(async () => {}), + } as any; + + const orchestrator = new LightBrainOrchestrator(engineManager, new Set()); + const teamRun = makeRun(); + + await orchestrator.run(teamRun, () => {}, "opencode"); + + expect(teamRun.status).toBe("completed"); + expect(teamRun.tasks[0].worktreeId).toBe("feature-branch"); + expect(engineManager.createSession).toHaveBeenNthCalledWith(2, "opencode", "/repo", "feature-branch"); + }); +}); diff --git a/tests/unit/electron/services/agent-team/skills.test.ts b/tests/unit/electron/services/agent-team/skills.test.ts index debad8a0..7cf14e8d 100644 --- a/tests/unit/electron/services/agent-team/skills.test.ts +++ b/tests/unit/electron/services/agent-team/skills.test.ts @@ -182,6 +182,28 @@ describe("dagPlanningSkill.parse", () => { expect(result.ok).toBe(true); if (result.ok) expect(result.data.tasks[0].engineType).toBe("claude"); }); + + it("accepts optional worktreeId field", () => { + const text = `\`\`\`json +{"tasks": [ + {"id": "t1", "description": "A", "prompt": "Do A", "dependsOn": [], "worktreeId": "feature-branch"} +]} +\`\`\``; + const result = dagPlanningSkill.parse(text); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.tasks[0].worktreeId).toBe("feature-branch"); + }); + + it("rejects invalid worktreeId field", () => { + const text = `\`\`\`json +{"tasks": [ + {"id": "t1", "description": "A", "prompt": "Do A", "dependsOn": [], "worktreeId": 123} +]} +\`\`\``; + const result = dagPlanningSkill.parse(text); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("worktreeId"); + }); }); // ============================================================================= @@ -245,6 +267,29 @@ describe("dispatchSkill.parse", () => { expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain("description"); }); + + it("accepts optional dispatch worktreeId field", () => { + const text = `\`\`\`json +{ + "action": "dispatch", + "tasks": [ + {"id": "t1", "description": "Analyze", "prompt": "Analyze the code", "worktreeId": "feature-branch"} + ] +} +\`\`\``; + const result = dispatchSkill.parse(text); + expect(result.ok).toBe(true); + if (result.ok && result.data.action === "dispatch") { + expect(result.data.tasks[0].worktreeId).toBe("feature-branch"); + } + }); + + it("rejects invalid dispatch worktreeId field", () => { + const text = '```json\n{"action": "dispatch", "tasks": [{"id": "t1", "description": "Analyze", "prompt": "Analyze the code", "worktreeId": 42}]}\n```'; + const result = dispatchSkill.parse(text); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("worktreeId"); + }); }); // ============================================================================= diff --git a/tests/unit/electron/services/agent-team/task-executor.test.ts b/tests/unit/electron/services/agent-team/task-executor.test.ts new file mode 100644 index 00000000..33cda2f1 --- /dev/null +++ b/tests/unit/electron/services/agent-team/task-executor.test.ts @@ -0,0 +1,135 @@ +import { EventEmitter } from "events"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { TaskExecutor } from "../../../../../electron/main/services/agent-team/task-executor"; +import type { TaskNode, UnifiedMessage } from "../../../../../src/types/unified"; + +function makeTask(overrides: Partial & { id: string }): TaskNode { + return { + id: overrides.id, + description: `Task ${overrides.id}`, + prompt: `Do ${overrides.id}`, + dependsOn: [], + status: "pending", + ...overrides, + }; +} + +function makeTextMessage(text: string, overrides: Partial = {}): UnifiedMessage { + const messageId = `msg-${Math.random().toString(36).slice(2, 8)}`; + return { + id: messageId, + sessionId: "session", + role: "assistant", + time: { created: Date.now() }, + parts: [{ + id: `${messageId}-part`, + messageId, + sessionId: "session", + type: "text", + text, + }] as any, + ...overrides, + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +class EngineManagerMock extends EventEmitter { + createSession = vi.fn(async () => ({ id: "session-1" })); + sendMessage = vi.fn(async () => makeTextMessage("done")); + cancelMessage = vi.fn(async () => {}); +} + +describe("TaskExecutor", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("resets inactivity timeout when the worker session keeps emitting activity", async () => { + vi.useFakeTimers(); + + const deferred = createDeferred(); + const engineManager = new EngineManagerMock(); + engineManager.createSession = vi.fn(async () => ({ id: "worker-1" })); + engineManager.sendMessage = vi.fn(async () => deferred.promise); + + const executor = new TaskExecutor(engineManager as any, new Set(), "opencode"); + const executionPromise = executor.execute( + makeTask({ id: "t1" }), + "/repo", + { inactivityTimeoutMs: 1000, maxRetries: 0, retryBackoffMs: 0 }, + ); + + await Promise.resolve(); + + await vi.advanceTimersByTimeAsync(900); + engineManager.emit("message.part.updated", { + sessionId: "worker-1", + messageId: "msg-1", + part: { id: "part-1", type: "text", text: "partial" }, + }); + + await vi.advanceTimersByTimeAsync(900); + engineManager.emit("permission.asked", { + permission: { id: "perm-1", sessionId: "worker-1" }, + }); + + await vi.advanceTimersByTimeAsync(900); + engineManager.emit("question.asked", { + question: { id: "question-1", sessionId: "worker-1" }, + }); + + await vi.advanceTimersByTimeAsync(1100); + const result = await executionPromise; + + expect(result.error).toBe("Task timed out after 1 second of inactivity."); + expect(engineManager.cancelMessage).toHaveBeenCalledWith("worker-1"); + }); + + it("retries once after a recoverable worker failure", async () => { + const engineManager = new EngineManagerMock(); + engineManager.createSession = vi.fn() + .mockResolvedValueOnce({ id: "worker-1" }) + .mockResolvedValueOnce({ id: "worker-2" }); + engineManager.sendMessage = vi.fn() + .mockRejectedValueOnce(new Error("network timeout")) + .mockResolvedValueOnce(makeTextMessage("Recovered result")); + + const executor = new TaskExecutor(engineManager as any, new Set(), "opencode"); + const task = makeTask({ id: "t1" }); + const result = await executor.execute(task, "/repo", { + maxRetries: 1, + retryBackoffMs: 0, + inactivityTimeoutMs: 1000, + }); + + expect(result.error).toBeUndefined(); + expect(result.summary).toBe("Recovered result"); + expect(result.sessionId).toBe("worker-2"); + expect(task.sessionId).toBe("worker-2"); + expect(engineManager.createSession).toHaveBeenCalledTimes(2); + expect(engineManager.sendMessage).toHaveBeenCalledTimes(2); + expect(engineManager.cancelMessage).toHaveBeenCalledWith("worker-1"); + }); + + it("passes worktreeId through to session creation", async () => { + const engineManager = new EngineManagerMock(); + const executor = new TaskExecutor(engineManager as any, new Set(), "opencode"); + + await executor.execute( + makeTask({ id: "t1", worktreeId: "feature-branch" }), + "/repo", + { maxRetries: 0, retryBackoffMs: 0, inactivityTimeoutMs: 1000 }, + ); + + expect(engineManager.createSession).toHaveBeenCalledWith("opencode", "/repo", "feature-branch"); + }); +}); diff --git a/tests/unit/src/lib/gateway-api.test.ts b/tests/unit/src/lib/gateway-api.test.ts index 38003079..10082877 100644 --- a/tests/unit/src/lib/gateway-api.test.ts +++ b/tests/unit/src/lib/gateway-api.test.ts @@ -43,6 +43,11 @@ const mockGatewayClient = { updateScheduledTask: vi.fn().mockResolvedValue({ id: 't1' }), deleteScheduledTask: vi.fn().mockResolvedValue({ success: true }), runScheduledTaskNow: vi.fn().mockResolvedValue({ success: true }), + createTeamRun: vi.fn().mockResolvedValue({ id: 'team-1' }), + cancelTeamRun: vi.fn().mockResolvedValue(undefined), + sendTeamMessage: vi.fn().mockResolvedValue(undefined), + listTeamRuns: vi.fn().mockResolvedValue([]), + getTeamRun: vi.fn().mockResolvedValue(null), request: vi.fn().mockResolvedValue({}), on: vi.fn(), off: vi.fn(), @@ -418,4 +423,65 @@ describe('GatewayAPI', () => { await gateway.listBranches('/dir'); expect(mockGatewayClient.request).toHaveBeenCalledWith('worktree.listBranches', { directory: '/dir' }); }); + + // --- Agent Team --- + + it('createTeamRun delegates', async () => { + const req = { + sessionId: 'sess-1', + prompt: 'Investigate issue', + mode: 'heavy', + directory: '/repo', + engineType: 'claude', + } as any; + + await gateway.createTeamRun(req); + + expect(mockGatewayClient.createTeamRun).toHaveBeenCalledWith(req); + }); + + it('cancelTeamRun delegates', async () => { + await gateway.cancelTeamRun('team-1'); + expect(mockGatewayClient.cancelTeamRun).toHaveBeenCalledWith('team-1'); + }); + + it('sendTeamMessage delegates', async () => { + await gateway.sendTeamMessage('team-1', 'Need a tighter plan'); + expect(mockGatewayClient.sendTeamMessage).toHaveBeenCalledWith('team-1', 'Need a tighter plan'); + }); + + it('listTeamRuns delegates', async () => { + await gateway.listTeamRuns(); + expect(mockGatewayClient.listTeamRuns).toHaveBeenCalled(); + }); + + it('getTeamRun delegates', async () => { + await gateway.getTeamRun('team-1'); + expect(mockGatewayClient.getTeamRun).toHaveBeenCalledWith('team-1'); + }); + + it('binds team notifications to the provided handlers', async () => { + const onTeamRunUpdated = vi.fn(); + const onTeamTaskUpdated = vi.fn(); + const run = { id: 'team-1' } as any; + const task = { id: 'task-1' } as any; + + await gateway.init({ onTeamRunUpdated, onTeamTaskUpdated }); + + const teamRunHandler = mockGatewayClient.on.mock.calls.find( + ([event]) => event === 'team.run.updated', + )?.[1]; + const teamTaskHandler = mockGatewayClient.on.mock.calls.find( + ([event]) => event === 'team.task.updated', + )?.[1]; + + expect(teamRunHandler).toBeTypeOf('function'); + expect(teamTaskHandler).toBeTypeOf('function'); + + teamRunHandler?.({ run }); + teamTaskHandler?.({ runId: 'team-1', task }); + + expect(onTeamRunUpdated).toHaveBeenCalledWith(run); + expect(onTeamTaskUpdated).toHaveBeenCalledWith('team-1', task); + }); }); diff --git a/tests/unit/src/stores/team.test.ts b/tests/unit/src/stores/team.test.ts index c3124d09..71344409 100644 --- a/tests/unit/src/stores/team.test.ts +++ b/tests/unit/src/stores/team.test.ts @@ -54,6 +54,8 @@ import { getActiveHeavyTeamRunForSession, getActiveTeamRunForSession, getTeamRunForSession, + getTeamRunsForSession, + hydrateTeamRuns, sendTeamRunMessage, teamStore, } from "../../../../src/stores/team"; @@ -133,6 +135,41 @@ describe("team store selectors", () => { expect(getActiveHeavyTeamRunForSession("session-1")?.id).toBe("team-heavy-active"); }); + + it("returns all runs sorted by activity first, then newest first", () => { + const handlers = connectTeamHandlers(); + handlers.onTeamRunUpdated( + makeRun({ id: "team-complete-new", status: "completed", time: { created: 30 } }), + ); + handlers.onTeamRunUpdated( + makeRun({ id: "team-active", status: "running", time: { created: 10 } }), + ); + handlers.onTeamRunUpdated( + makeRun({ id: "team-complete-old", status: "completed", time: { created: 20 } }), + ); + + expect(getTeamRunsForSession("session-1").map((run) => run.id)).toEqual([ + "team-active", + "team-complete-new", + "team-complete-old", + ]); + }); + }); + + describe("hydrateTeamRuns", () => { + it("replaces the known runs with a backend snapshot", () => { + hydrateTeamRuns([ + makeRun({ id: "team-old", status: "completed" }), + makeRun({ id: "team-active", status: "running", mode: "heavy" }), + ]); + + hydrateTeamRuns([ + makeRun({ id: "team-restored", status: "failed", mode: "light" }), + ]); + + expect(teamStore.runs.map((run) => run.id)).toEqual(["team-restored"]); + expect(teamStore.activeRunId).toBeNull(); + }); }); describe("sendTeamRunMessage", () => { From 49441b259849143602bf48b875bac2ca3fd9474c Mon Sep 17 00:00:00 2001 From: FridayLiu Date: Sun, 19 Apr 2026 10:05:21 +0000 Subject: [PATCH 4/6] Add headless server restart command Add a managed server restart flow that preserves the existing quick tunnel when possible, expose it via package scripts, document the behavior, and cover the shell workflow with unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 6 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- package.json | 1 + scripts/server-dev.sh | 78 +++++- scripts/server-init.sh | 1 + tests/unit/shared/server-dev-script.test.ts | 268 ++++++++++++++++++++ 9 files changed, 352 insertions(+), 10 deletions(-) create mode 100644 tests/unit/shared/server-dev-script.test.ts diff --git a/README.ja.md b/README.ja.md index e0becca8..5c212188 100644 --- a/README.ja.md +++ b/README.ja.md @@ -100,7 +100,7 @@ CodeMux はチャットにとどまりません — 開発ワークフローを - **LAN**: IPアドレスの自動検出 + QRコードで、数秒で準備完了 - **パブリックインターネット**: ワンクリックで [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) — ポート転送、VPN、ファイアウォール変更は一切不要。**クイックトンネル**(ランダムな一時URL、設定不要)と**ネームドトンネル**(`~/.cloudflared/` 認証情報による永続カスタムドメイン)の両方をサポート -- **セキュリティ内蔵**: デバイス認証、JWT トークン、Cloudflare 経由のHTTPS; クイックトンネルURLは再起動ごとにローテーション、ネームドトンネルはカスタムホスト名を維持 +- **セキュリティ内蔵**: デバイス認証、JWT トークン、Cloudflare 経由のHTTPS; クイックトンネルURLはトンネル自体を作り直したときにローテーションし、ネームドトンネルはカスタムホスト名を維持 #### IM ボットチャネル diff --git a/README.ko.md b/README.ko.md index 166f1d33..4be3971c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -100,7 +100,7 @@ CodeMux는 채팅을 넘어 — 개발 워크플로를 인터페이스에서 직 - **LAN**: 자동 감지된 IP + QR 코드, 수초 내 준비 완료 - **공용 인터넷**: 원클릭 [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) — 포트 포워딩, VPN, 방화벽 변경 불필요. **퀵 터널**(랜덤 임시 URL, 제로 설정)과 **네임드 터널**(`~/.cloudflared/` 인증 정보를 통한 영구 커스텀 도메인) 모두 지원 -- **내장 보안**: 기기 인증, JWT 토큰, Cloudflare를 통한 HTTPS; 퀵 터널 URL은 재시작마다 변경, 네임드 터널은 커스텀 호스트명 유지 +- **내장 보안**: 기기 인증, JWT 토큰, Cloudflare를 통한 HTTPS; 퀵 터널 URL은 터널 자체를 다시 만들 때 변경되며, 네임드 터널은 커스텀 호스트명을 유지합니다 #### IM 봇 채널 diff --git a/README.md b/README.md index 61029397..a273856e 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Access your coding agents from any device — phone, tablet, or another machine - **LAN**: Auto-detected IP + QR code, ready in seconds - **Public Internet**: One-click [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) — no port forwarding, no VPN, no firewall changes. Supports both **quick tunnels** (random ephemeral URL, zero config) and **named tunnels** (persistent custom domain via `~/.cloudflared/` credentials) -- **Security built-in**: Device authorization, JWT tokens, HTTPS via Cloudflare; quick tunnel URLs rotate on every restart, named tunnels preserve your custom hostname +- **Security built-in**: Device authorization, JWT tokens, HTTPS via Cloudflare; quick tunnel URLs rotate whenever the tunnel itself is recreated, while named tunnels preserve your custom hostname #### IM Bot Channels @@ -201,6 +201,7 @@ bun run server:dev # Run the same headless dev stack in the background bun run server:up bun run server:status +bun run server:restart bun run server:down # Run the headless dev stack and start a Cloudflare quick tunnel @@ -215,6 +216,8 @@ bun run server:access-requests `bun run start` is still the lightest option for a web-only standalone server. The desktop app's "Public Access" toggle manages Cloudflare inside the packaged app; on a headless dev server, `bun run server:tunnel` provides the equivalent quick-tunnel workflow from the shell. +If you want to restart CodeMux itself without rotating the current quick-tunnel URL, use `bun run server:restart`. It restarts the managed app process and keeps the existing `cloudflared` process alive whenever possible, so remote browsers can usually stay on the same public origin. + `bun run server:tunnel` now prints the access code after startup. When a remote browser submits that code, you can stay entirely in SSH and run `bun run server:access-requests` to review and interactively approve or deny pending requests. If you started CodeMux with `bun run server:dev`, open a second SSH session and run `bun run server:access-code` / `bun run server:access-requests`. @@ -301,6 +304,7 @@ bun run dev # Electron + Vite HMR bun run server:dev # Foreground headless Electron dev bun run server:up # Background headless Electron dev bun run server:tunnel # Background headless Electron dev + quick tunnel +bun run server:restart # Restart app only; preserve managed quick tunnel when possible bun run server:access-code # Print the current 6-digit access code bun run server:access-requests # Interactively review pending remote access requests bun run server:down # Stop background headless Electron dev diff --git a/README.ru.md b/README.ru.md index 8e8db12c..05b33203 100644 --- a/README.ru.md +++ b/README.ru.md @@ -100,7 +100,7 @@ CodeMux выходит за рамки чата — предоставляет - **LAN**: Автоматически определённый IP + QR-код, готово за секунды - **Публичный интернет**: Одним кликом [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) — без проброса портов, VPN и изменений файрвола. Поддерживаются как **быстрые туннели** (случайный временный URL, без настройки), так и **именованные туннели** (постоянный пользовательский домен через учётные данные `~/.cloudflared/`) -- **Встроенная безопасность**: Авторизация устройств, JWT-токены, HTTPS через Cloudflare; URL быстрых туннелей меняются при каждом перезапуске, именованные туннели сохраняют ваш домен +- **Встроенная безопасность**: Авторизация устройств, JWT-токены, HTTPS через Cloudflare; URL быстрых туннелей меняются при пересоздании самого туннеля, а именованные туннели сохраняют ваш домен #### Каналы IM-ботов diff --git a/README.zh-CN.md b/README.zh-CN.md index 480f77a0..c5b82736 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -100,7 +100,7 @@ CodeMux 不只是聊天 —— 它提供集成工具,让你直接在界面中 - **局域网**:自动检测 IP + 二维码,几秒内即可就绪 - **公网**:一键 [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) —— 无需端口转发、无需 VPN、无需防火墙更改。支持**快速隧道**(随机临时 URL,零配置)和**命名隧道**(通过 `~/.cloudflared/` 凭证持久化自定义域名) -- **内置安全机制**:设备授权、JWT 令牌、通过 Cloudflare 的 HTTPS;快速隧道 URL 每次重启时轮换,命名隧道保留你的自定义主机名 +- **内置安全机制**:设备授权、JWT 令牌、通过 Cloudflare 的 HTTPS;快速隧道 URL 会在隧道本身被重建时轮换,命名隧道保留你的自定义主机名 #### IM 机器人渠道 diff --git a/package.json b/package.json index 11c3f034..2eade002 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "server:tunnel": "bash ./scripts/server-dev.sh start --replace --tunnel", "server:down": "bash ./scripts/server-dev.sh stop", "server:status": "bash ./scripts/server-dev.sh status", + "server:restart": "bash ./scripts/server-dev.sh restart", "server:access-code": "bun scripts/server-auth.ts access-code", "server:access-requests": "bun scripts/server-auth.ts access-requests", "update:cloudflared": "bun scripts/update-cloudflared.ts", diff --git a/scripts/server-dev.sh b/scripts/server-dev.sh index f71f29c7..f92d5e3c 100755 --- a/scripts/server-dev.sh +++ b/scripts/server-dev.sh @@ -12,6 +12,7 @@ LOCAL_URL_FILE="$STATE_DIR/local-url" TUNNEL_URL_FILE="$STATE_DIR/tunnel-url" XVFB_SCREEN="${CODEMUX_XVFB_SCREEN:-1280x720x24}" DEFAULT_TIMEOUT="${CODEMUX_SERVER_START_TIMEOUT:-90}" +STARTED_LOCAL_URL="" export PATH="$HOME/.bun/bin:$HOME/.opencode/bin:$PATH" @@ -20,12 +21,14 @@ usage() { Usage: ./scripts/server-dev.sh start [--foreground] [--replace] [--tunnel] ./scripts/server-dev.sh stop + ./scripts/server-dev.sh restart ./scripts/server-dev.sh status ./scripts/server-dev.sh logs [app|tunnel] Examples: ./scripts/server-dev.sh start --foreground ./scripts/server-dev.sh start --replace --tunnel + ./scripts/server-dev.sh restart ./scripts/server-dev.sh status EOF } @@ -279,11 +282,10 @@ start_foreground() { dbus-run-session -- xvfb-run --auto-servernum --server-args="-screen 0 $XVFB_SCREEN" bun run dev } -start_background() { - local with_tunnel="$1" - +start_managed_app_background() { mkdir -p "$STATE_DIR" - rm -f "$LOCAL_URL_FILE" "$TUNNEL_URL_FILE" + STARTED_LOCAL_URL="" + rm -f "$LOCAL_URL_FILE" : > "$APP_LOG" setsid bash -lc "export PATH=\"$HOME/.bun/bin:$HOME/.opencode/bin:\$PATH\"; export CODEMUX_DISABLE_COPILOT_DBUS=1; export CODEMUX_SERVER_MODE=1; cd \"$REPO_DIR\"; exec dbus-run-session -- xvfb-run --auto-servernum --server-args='-screen 0 $XVFB_SCREEN' bun run dev" > "$APP_LOG" 2>&1 & @@ -294,16 +296,29 @@ start_background() { info "Started CodeMux headless dev (PID $app_pid); waiting for the renderer URL..." local local_url if ! local_url=$(wait_for_local_url "$app_pid"); then + rm -f "$APP_PID_FILE" "$LOCAL_URL_FILE" warn "CodeMux exited before the renderer URL was detected. Recent logs:" tail -n 40 "$APP_LOG" || true - exit 1 + return 1 fi + STARTED_LOCAL_URL="$local_url" success "CodeMux dev is ready: $local_url" printf ' App log: %s ' "$APP_LOG" print_auth_status +} + +start_background() { + local with_tunnel="$1" + + rm -f "$TUNNEL_URL_FILE" + + if ! start_managed_app_background; then + exit 1 + fi + local local_url="$STARTED_LOCAL_URL" if [ "$with_tunnel" -eq 1 ]; then : > "$TUNNEL_LOG" setsid bash -lc "exec cloudflared tunnel --url '$local_url'" > "$TUNNEL_LOG" 2>&1 & @@ -325,6 +340,54 @@ start_background() { fi } +restart_app() { + cleanup_stale_pid_file "$APP_PID_FILE" + cleanup_stale_pid_file "$TUNNEL_PID_FILE" + + local app_pid tunnel_pid previous_local_url tunnel_url repo_pids + app_pid=$(read_pid_file "$APP_PID_FILE" 2>/dev/null || true) + tunnel_pid=$(read_pid_file "$TUNNEL_PID_FILE" 2>/dev/null || true) + previous_local_url=$(read_local_url 2>/dev/null || true) + tunnel_url=$(read_tunnel_url 2>/dev/null || true) + repo_pids=$(find_repo_processes || true) + + if [ -z "$app_pid" ]; then + if [ -n "$repo_pids" ]; then + fail "Found CodeMux dev processes without managed state. Run bun run server:down first." + fi + warn "No managed CodeMux app process was running. Starting a fresh instance instead." + else + info "Restarting CodeMux headless dev (PID $app_pid)..." + kill_process_group_from_file "$APP_PID_FILE" + fi + + rm -f "$LOCAL_URL_FILE" + + if ! start_managed_app_background; then + if [ -n "$tunnel_pid" ] && is_process_group_running "$tunnel_pid"; then + warn "Managed Cloudflare tunnel is still running." + [ -n "$tunnel_url" ] && printf ' Tunnel URL: %s\n' "$tunnel_url" + printf ' Tunnel log: %s\n' "$TUNNEL_LOG" + fi + exit 1 + fi + + local restarted_local_url="$STARTED_LOCAL_URL" + if [ -n "$tunnel_pid" ] && is_process_group_running "$tunnel_pid"; then + success "Preserved managed Cloudflare tunnel." + [ -n "$tunnel_url" ] && printf ' Tunnel URL: %s\n' "$tunnel_url" + printf ' Tunnel log: %s\n' "$TUNNEL_LOG" + if [ -n "$previous_local_url" ] && [ "$previous_local_url" != "$restarted_local_url" ]; then + warn "App restarted on $restarted_local_url, but the preserved tunnel still targets $previous_local_url." + printf ' Recreate the tunnel if remote access stops working: bun run server:down && bun run server:tunnel\n' + else + printf ' Public URL should stay the same while the existing tunnel process remains healthy.\n' + fi + else + warn "No managed Cloudflare tunnel was running. Restart completed without public tunnel." + fi +} + stop_all() { cleanup_stale_pid_file "$APP_PID_FILE" cleanup_stale_pid_file "$TUNNEL_PID_FILE" @@ -481,6 +544,11 @@ main() { [ "$#" -eq 0 ] || fail "stop does not accept extra arguments" stop_all ;; + restart) + [ "$#" -eq 0 ] || fail "restart does not accept extra arguments" + ensure_repo_ready + restart_app + ;; status) [ "$#" -eq 0 ] || fail "status does not accept extra arguments" show_status diff --git a/scripts/server-init.sh b/scripts/server-init.sh index 1c794b11..6361c144 100755 --- a/scripts/server-init.sh +++ b/scripts/server-init.sh @@ -153,6 +153,7 @@ Next steps: bun run server:dev # foreground headless Electron dev bun run server:up # background headless Electron dev bun run server:tunnel # background headless Electron dev + quick tunnel + bun run server:restart # restart app only, preserving managed tunnel when possible bun run start # web-only standalone server EOF } diff --git a/tests/unit/shared/server-dev-script.test.ts b/tests/unit/shared/server-dev-script.test.ts new file mode 100644 index 00000000..71e2df17 --- /dev/null +++ b/tests/unit/shared/server-dev-script.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect } from "vitest"; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { execFile } from "node:child_process"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const SOURCE_SCRIPT = path.join(process.cwd(), "scripts", "server-dev.sh"); + +const FAKE_BUN = `#!/usr/bin/env bash +set -euo pipefail + +if [ "\${1:-}" = "run" ] && [ "\${2:-}" = "dev" ]; then + echo "http://localhost:8233/" + trap 'exit 0' TERM INT + while true; do + sleep 1 + done +elif [ "\${1:-}" = "scripts/server-auth.ts" ] && [ "\${2:-}" = "access-code" ]; then + if [ "\${3:-}" = "--plain" ]; then + echo "123456" + else + echo "[ok] Access code: 123456" + fi +elif [ "\${1:-}" = "scripts/server-auth.ts" ] && [ "\${2:-}" = "access-requests" ]; then + if [ "\${3:-}" = "--count" ]; then + echo "0" + else + echo "[!] No pending requests." + fi +else + echo "Unexpected bun args: $*" >&2 + exit 1 +fi +`; + +const FAKE_DBUS_RUN_SESSION = `#!/usr/bin/env bash +set -euo pipefail + +if [ "\${1:-}" = "--" ]; then + shift +fi + +exec "$@" +`; + +const FAKE_XVFB_RUN = `#!/usr/bin/env bash +set -euo pipefail + +while [ "$#" -gt 0 ]; do + case "$1" in + --auto-servernum|--listen-tcp) + shift + ;; + --server-args=*|--server-num=*|-s|-n) + shift + ;; + --) + shift + break + ;; + *) + break + ;; + esac +done + +exec "$@" +`; + +const FAKE_CLOUDFLARED = `#!/usr/bin/env bash +set -euo pipefail + +if [ "\${1:-}" = "tunnel" ] && [ "\${2:-}" = "--url" ]; then + echo "2024-01-01 INFO | https://same-tunnel.trycloudflare.com connected" + trap 'exit 0' TERM INT + while true; do + sleep 1 + done +fi + +echo "Unexpected cloudflared args: $*" >&2 +exit 1 +`; + +const FAKE_CURL = `#!/usr/bin/env bash +exit 1 +`; + +interface TestRepo { + env: NodeJS.ProcessEnv; + root: string; + scriptPath: string; + stateDir: string; +} + +async function writeExecutable(filePath: string, content: string): Promise { + await writeFile(filePath, content, "utf8"); + await chmod(filePath, 0o755); +} + +async function createTestRepo(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "codemux-server-dev-")); + const home = path.join(root, "home"); + const bunBin = path.join(home, ".bun", "bin"); + const stateDir = path.join(root, "state", "codemux-server"); + const scriptPath = path.join(root, "scripts", "server-dev.sh"); + + await mkdir(path.join(root, "scripts"), { recursive: true }); + await mkdir(path.join(root, "node_modules"), { recursive: true }); + await mkdir(bunBin, { recursive: true }); + await mkdir(stateDir, { recursive: true }); + + await copyFile(SOURCE_SCRIPT, scriptPath); + await chmod(scriptPath, 0o755); + + await writeExecutable(path.join(bunBin, "bun"), FAKE_BUN); + await writeExecutable(path.join(bunBin, "dbus-run-session"), FAKE_DBUS_RUN_SESSION); + await writeExecutable(path.join(bunBin, "xvfb-run"), FAKE_XVFB_RUN); + await writeExecutable(path.join(bunBin, "cloudflared"), FAKE_CLOUDFLARED); + await writeExecutable(path.join(bunBin, "curl"), FAKE_CURL); + + return { + env: { + ...process.env, + HOME: home, + PATH: `${bunBin}:${process.env.PATH ?? ""}`, + XDG_STATE_HOME: path.join(root, "state"), + CODEMUX_SERVER_START_TIMEOUT: "10", + }, + root, + scriptPath, + stateDir, + }; +} + +async function runServerScript(repo: TestRepo, ...args: string[]) { + return execFileAsync("bash", [repo.scriptPath, ...args], { + cwd: repo.root, + env: repo.env, + timeout: 20_000, + }); +} + +async function readStateFile(repo: TestRepo, fileName: string): Promise { + return (await readFile(path.join(repo.stateDir, fileName), "utf8")).trim(); +} + +function isPidRunning(pidText: string): boolean { + const pid = Number.parseInt(pidText, 10); + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function killPidOrGroup(pidText: string | null): void { + if (!pidText) { + return; + } + + const pid = Number.parseInt(pidText, 10); + if (!Number.isInteger(pid) || pid <= 0) { + return; + } + + for (const signal of ["SIGTERM", "SIGKILL"] as const) { + try { + process.kill(-pid, signal); + return; + } catch { + // Fall through to the process itself. + } + + try { + process.kill(pid, signal); + return; + } catch { + // Process may already be gone. + } + } +} + +async function cleanupRepo(repo: TestRepo): Promise { + try { + await runServerScript(repo, "stop"); + } catch { + // Best-effort cleanup below handles leftover processes. + } + + const pidFiles = ["dev.pid", "tunnel.pid"] as const; + for (const fileName of pidFiles) { + const filePath = path.join(repo.stateDir, fileName); + if (existsSync(filePath)) { + killPidOrGroup(await readStateFile(repo, fileName)); + } + } + + await rm(repo.root, { recursive: true, force: true }); +} + +describe("scripts/server-dev.sh", () => { + it( + "restarts the managed app without rotating the managed quick tunnel", + async () => { + const repo = await createTestRepo(); + try { + const startResult = await runServerScript(repo, "start", "--replace", "--tunnel"); + expect(startResult.stdout).toContain("Tunnel is ready: https://same-tunnel.trycloudflare.com"); + + const initialAppPid = await readStateFile(repo, "dev.pid"); + const initialTunnelPid = await readStateFile(repo, "tunnel.pid"); + const initialTunnelUrl = await readStateFile(repo, "tunnel-url"); + + expect(isPidRunning(initialAppPid)).toBe(true); + expect(isPidRunning(initialTunnelPid)).toBe(true); + expect(initialTunnelUrl).toBe("https://same-tunnel.trycloudflare.com"); + + const restartResult = await runServerScript(repo, "restart"); + const restartedAppPid = await readStateFile(repo, "dev.pid"); + const restartedTunnelPid = await readStateFile(repo, "tunnel.pid"); + const restartedTunnelUrl = await readStateFile(repo, "tunnel-url"); + + expect(restartResult.stdout).toContain("Preserved managed Cloudflare tunnel."); + expect(restartResult.stdout).toContain("Public URL should stay the same"); + expect(restartedAppPid).not.toBe(initialAppPid); + expect(restartedTunnelPid).toBe(initialTunnelPid); + expect(restartedTunnelUrl).toBe(initialTunnelUrl); + expect(isPidRunning(restartedAppPid)).toBe(true); + expect(isPidRunning(restartedTunnelPid)).toBe(true); + } finally { + await cleanupRepo(repo); + } + }, + 20_000, + ); + + it( + "restarts cleanly when no managed tunnel is running", + async () => { + const repo = await createTestRepo(); + try { + const startResult = await runServerScript(repo, "start", "--replace"); + expect(startResult.stdout).toContain("CodeMux dev is ready: http://localhost:8233"); + + const initialAppPid = await readStateFile(repo, "dev.pid"); + expect(isPidRunning(initialAppPid)).toBe(true); + + const restartResult = await runServerScript(repo, "restart"); + const restartedAppPid = await readStateFile(repo, "dev.pid"); + + expect(restartResult.stdout).toContain("No managed Cloudflare tunnel was running."); + expect(restartedAppPid).not.toBe(initialAppPid); + expect(isPidRunning(restartedAppPid)).toBe(true); + } finally { + await cleanupRepo(repo); + } + }, + 20_000, + ); +}); From ad6016bb775c0cb8342436a3090f30a39fa079ea Mon Sep 17 00:00:00 2001 From: FridayLiu Date: Sun, 19 Apr 2026 13:23:24 +0000 Subject: [PATCH 5/6] Fix team run UI follow-ups Deduplicate team runs by id in the team store, cover the create-plus-notification race with tests, remove the duplicate relay notice, and move long final results out of the status card into a separate result panel. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/pages/Chat.tsx | 187 ++++++++++++++--------------- src/stores/team.ts | 75 +++++++----- tests/unit/src/stores/team.test.ts | 34 ++++++ 3 files changed, 171 insertions(+), 125 deletions(-) diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index 8c85d58b..6a17932f 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -2432,120 +2432,115 @@ export default function Chat() { {(run) => { const isActiveRun = () => run.id === activeTeamRun()?.id; - const isRelayRun = () => run.id === activeHeavyRelayRun()?.id; return ( -
-
-
-
- - {t().chat.teamRunLabel} ({getTeamRunModeLabel(run)}) - - - {getTeamRunStatusLabel(run)} - - - - {t().chat.teamRunActive} +
+
+
+
+
+ + {t().chat.teamRunLabel} ({getTeamRunModeLabel(run)}) - -
-
- {run.id} + + {getTeamRunStatusLabel(run)} + + + + {t().chat.teamRunActive} + + +
+
+ {run.id} +
+ + +
- - - -
- 0}> -
- - {(task) => ( -
-
-
-
- {getTeamTaskStatusIcon(task)} - {task.id} - {task.description} -
+ 0}> +
+ + {(task) => ( +
+
+
+
+ {getTeamTaskStatusIcon(task)} + {task.id} + {task.description} +
-
- 0}> - - {formatMessage(t().chat.teamTaskDependsOn, { - ids: task.dependsOn.join(", "), - })} - - - - - {formatMessage(t().chat.teamTaskEngine, { - engine: task.engineType!, - })} - - - - - {formatMessage(t().chat.teamTaskWorktree, { - name: task.worktreeId!, - })} - +
+ 0}> + + {formatMessage(t().chat.teamTaskDependsOn, { + ids: task.dependsOn.join(", "), + })} + + + + + {formatMessage(t().chat.teamTaskEngine, { + engine: task.engineType!, + })} + + + + + {formatMessage(t().chat.teamTaskWorktree, { + name: task.worktreeId!, + })} + + + + + +
+ + +
+ {task.error} +
- - + +
+ {task.result} +
- -
- {task.error} -
-
- -
- {task.result} -
+ +
- - - -
-
- )} -
-
-
- - -
- {t().prompt.teamRelayNotice} -
-
+ )} + +
+ +
-
-
+
+
{t().chat.teamRunFinalResult}
-
+
{run.finalResult}
diff --git a/src/stores/team.ts b/src/stores/team.ts index eabfd461..6c42bd9d 100644 --- a/src/stores/team.ts +++ b/src/stores/team.ts @@ -38,6 +38,31 @@ function pickPreferredRun(runs: TeamRun[]): TeamRun | undefined { return [...runs].sort(compareTeamRuns)[0]; } +function dedupeTeamRuns(runs: TeamRun[]): TeamRun[] { + const deduped = new Map(); + for (const run of runs) { + deduped.set(run.id, run); + } + return [...deduped.values()]; +} + +function upsertTeamRun(runs: TeamRun[], run: TeamRun): TeamRun[] { + return [...runs.filter((existing) => existing.id !== run.id), run]; +} + +function getLatestTeamRun(runs: TeamRun[], runId: string): TeamRun | undefined { + for (let index = runs.length - 1; index >= 0; index -= 1) { + if (runs[index].id === runId) { + return runs[index]; + } + } + return undefined; +} + +function getSessionRuns(sessionId: string): TeamRun[] { + return dedupeTeamRuns(teamStore.runs.filter((run) => run.parentSessionId === sessionId)); +} + /** Initialize notification handlers for team events */ export function initTeamStore(): void { // These handlers are set during gateway-api initialization @@ -51,27 +76,19 @@ export function connectTeamHandlers(): { } { return { onTeamRunUpdated: (run: TeamRun) => { - setTeamStore("runs", (runs) => { - const idx = runs.findIndex((r) => r.id === run.id); - if (idx >= 0) { - const updated = [...runs]; - updated[idx] = run; - return updated; - } - return [...runs, run]; - }); + setTeamStore("runs", (runs) => upsertTeamRun(runs, run)); }, - onTeamTaskUpdated: (runId: string, task: TaskNode) => { - setTeamStore("runs", (runs) => - runs.map((run) => { - if (run.id !== runId) return run; - return { - ...run, - tasks: run.tasks.map((t) => (t.id === task.id ? task : t)), - }; - }), - ); + onTeamTaskUpdated: (runId: string, task: TaskNode) => { + setTeamStore("runs", (runs) => { + const run = getLatestTeamRun(runs, runId); + if (!run) return runs; + + return upsertTeamRun(runs, { + ...run, + tasks: run.tasks.map((t) => (t.id === task.id ? task : t)), + }); + }); }, }; } @@ -79,11 +96,12 @@ export function connectTeamHandlers(): { /** Replace the known team runs with a hydrated snapshot from the backend. */ export function hydrateTeamRuns(runs: TeamRun[]): void { const activeRunId = teamStore.activeRunId; - const nextActiveRunId = activeRunId && runs.some((run) => run.id === activeRunId) + const dedupedRuns = dedupeTeamRuns(runs); + const nextActiveRunId = activeRunId && dedupedRuns.some((run) => run.id === activeRunId) ? activeRunId : null; - setTeamStore("runs", runs); + setTeamStore("runs", dedupedRuns); setTeamStore("activeRunId", nextActiveRunId); } @@ -102,7 +120,7 @@ export async function createTeamRun( directory, engineType, }); - setTeamStore("runs", (runs) => [...runs, run]); + setTeamStore("runs", (runs) => upsertTeamRun(runs, run)); setTeamStore("activeRunId", run.id); return run; } @@ -114,28 +132,27 @@ export async function cancelTeamRun(runId: string): Promise { /** Get the active team run for a given session */ export function getTeamRunForSession(sessionId: string): TeamRun | undefined { - return pickPreferredRun(teamStore.runs.filter((r) => r.parentSessionId === sessionId)); + return pickPreferredRun(getSessionRuns(sessionId)); } /** Get all known team runs for a session, sorted by activity then recency. */ export function getTeamRunsForSession(sessionId: string): TeamRun[] { - return teamStore.runs - .filter((run) => run.parentSessionId === sessionId) + return getSessionRuns(sessionId) .sort(compareTeamRuns); } /** Get the active team run for a given session, if any */ export function getActiveTeamRunForSession(sessionId: string): TeamRun | undefined { return pickPreferredRun( - teamStore.runs.filter((r) => r.parentSessionId === sessionId && isActiveTeamRun(r)), + getSessionRuns(sessionId).filter((r) => isActiveTeamRun(r)), ); } /** Get the active Heavy Brain run for a given session, if any */ export function getActiveHeavyTeamRunForSession(sessionId: string): TeamRun | undefined { return pickPreferredRun( - teamStore.runs.filter( - (r) => r.parentSessionId === sessionId && r.mode === "heavy" && isActiveTeamRun(r), + getSessionRuns(sessionId).filter( + (r) => r.mode === "heavy" && isActiveTeamRun(r), ), ); } @@ -147,5 +164,5 @@ export async function sendTeamRunMessage(runId: string, text: string): Promise r.id === runId); + return getLatestTeamRun(teamStore.runs, runId); } diff --git a/tests/unit/src/stores/team.test.ts b/tests/unit/src/stores/team.test.ts index 71344409..c2f1b397 100644 --- a/tests/unit/src/stores/team.test.ts +++ b/tests/unit/src/stores/team.test.ts @@ -51,8 +51,10 @@ vi.mock("solid-js/store", () => ({ import type { TeamRun } from "../../../../src/types/unified"; import { connectTeamHandlers, + createTeamRun, getActiveHeavyTeamRunForSession, getActiveTeamRunForSession, + getTeamRun, getTeamRunForSession, getTeamRunsForSession, hydrateTeamRuns, @@ -154,6 +156,24 @@ describe("team store selectors", () => { "team-complete-old", ]); }); + + it("deduplicates runs with the same id and keeps the latest version", () => { + hydrateTeamRuns([ + makeRun({ id: "team-dup", status: "planning", time: { created: 10 } }), + makeRun({ + id: "team-dup", + status: "completed", + finalResult: "All done", + time: { created: 10 }, + }), + ]); + + const runs = getTeamRunsForSession("session-1"); + expect(runs).toHaveLength(1); + expect(runs[0].status).toBe("completed"); + expect(runs[0].finalResult).toBe("All done"); + expect(getTeamRun("team-dup")?.status).toBe("completed"); + }); }); describe("hydrateTeamRuns", () => { @@ -179,4 +199,18 @@ describe("team store selectors", () => { expect(gatewayMock.sendTeamMessage).toHaveBeenCalledWith("team-123", "Need a tighter plan"); }); }); + + describe("createTeamRun", () => { + it("upserts the created run when a matching notification already added it", async () => { + const handlers = connectTeamHandlers(); + const run = makeRun({ id: "team-dup", status: "planning" }); + + handlers.onTeamRunUpdated(run); + gatewayMock.createTeamRun.mockResolvedValue(run); + + await createTeamRun("session-1", "Investigate issue", "heavy", "/repo", "claude"); + + expect(teamStore.runs.filter((candidate) => candidate.id === "team-dup")).toHaveLength(1); + }); + }); }); From f284648d65220476e4aec31195fdf02d9ba20a48 Mon Sep 17 00:00:00 2001 From: FridayLiu Date: Sun, 19 Apr 2026 13:57:46 +0000 Subject: [PATCH 6/6] Preserve team run worktree context Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/services/agent-team/dag-executor.ts | 5 +- .../main/services/agent-team/heavy-brain.ts | 13 ++-- electron/main/services/agent-team/index.ts | 8 +++ .../main/services/agent-team/light-brain.ts | 11 ++-- .../main/services/agent-team/task-executor.ts | 7 +- src/pages/Chat.tsx | 12 +++- src/stores/team.ts | 6 ++ src/types/unified.ts | 12 +++- .../services/agent-team/heavy-brain.test.ts | 57 +++++++++++++++++ .../services/agent-team/index.test.ts | 15 +++++ .../services/agent-team/light-brain.test.ts | 64 +++++++++++++++++++ .../services/agent-team/task-executor.test.ts | 20 ++++++ tests/unit/src/stores/team.test.ts | 29 ++++++++- 13 files changed, 242 insertions(+), 17 deletions(-) diff --git a/electron/main/services/agent-team/dag-executor.ts b/electron/main/services/agent-team/dag-executor.ts index 9e6e69ad..c0708789 100644 --- a/electron/main/services/agent-team/dag-executor.ts +++ b/electron/main/services/agent-team/dag-executor.ts @@ -108,7 +108,10 @@ export class DAGExecutor extends EventEmitter { const upstreamContext = TaskExecutor.buildUpstreamContext(dependencies); - return this.taskExecutor.execute(task, this.directory, { upstreamContext }); + return this.taskExecutor.execute(task, this.directory, { + upstreamContext, + defaultWorktreeId: run.worktreeId, + }); } /** diff --git a/electron/main/services/agent-team/heavy-brain.ts b/electron/main/services/agent-team/heavy-brain.ts index ea54392c..531335fb 100644 --- a/electron/main/services/agent-team/heavy-brain.ts +++ b/electron/main/services/agent-team/heavy-brain.ts @@ -123,8 +123,8 @@ export class HeavyBrainOrchestrator { const orchSession = await this.engineManager.createSession( engineType, - teamRun.directory, - undefined, + teamRun.parentDirectory ?? teamRun.directory, + teamRun.worktreeId, { systemPrompt }, ); teamRun.orchestratorSessionId = orchSession.id; @@ -490,14 +490,14 @@ export class HeavyBrainOrchestrator { ); } - private convertDispatchTasks(tasks: DispatchTask[]): TaskNode[] { + private convertDispatchTasks(tasks: DispatchTask[], defaultWorktreeId?: string): TaskNode[] { return tasks.map((t): TaskNode => ({ id: t.id || `task_${this.nextAutoTaskIndex++}`, description: t.description, prompt: t.prompt, engineType: t.engineType as EngineType | undefined, dependsOn: t.dependsOn || [], - worktreeId: t.worktreeId, + worktreeId: t.worktreeId ?? defaultWorktreeId, status: "pending", })); } @@ -541,7 +541,7 @@ export class HeavyBrainOrchestrator { tasks: DispatchTask[], onTaskUpdated: (task: TaskNode) => void, ): MergeDispatchTasksResult { - const newTasks = this.convertDispatchTasks(tasks); + const newTasks = this.convertDispatchTasks(tasks, teamRun.worktreeId); const validation = this.validateMergedTasks(teamRun.tasks, newTasks); if (!validation.ok) { return validation; @@ -652,8 +652,9 @@ export class HeavyBrainOrchestrator { onTaskUpdated(task); let state!: RunningTaskState; - const promise = executor.execute(task, teamRun.directory, { + const promise = executor.execute(task, teamRun.parentDirectory ?? teamRun.directory, { upstreamContext, + defaultWorktreeId: teamRun.worktreeId, onSessionCreated: (sessionId) => { state.sessionId = sessionId; if (this.cancelled || this.terminal) { diff --git a/electron/main/services/agent-team/index.ts b/electron/main/services/agent-team/index.ts index 40860c58..f8b9ffa1 100644 --- a/electron/main/services/agent-team/index.ts +++ b/electron/main/services/agent-team/index.ts @@ -124,10 +124,18 @@ export class AgentTeamService extends EventEmitter { throw new Error("AgentTeamService not initialized"); } + if (Boolean(req.worktreeId) !== Boolean(req.parentDirectory)) { + throw new Error( + "Team runs started from worktree sessions must include both worktreeId and parentDirectory.", + ); + } + const run: TeamRun = { id: timeId("team"), parentSessionId: req.sessionId, directory: req.directory, + parentDirectory: req.parentDirectory, + worktreeId: req.worktreeId, originalPrompt: req.prompt, mode: req.mode, status: "planning", diff --git a/electron/main/services/agent-team/light-brain.ts b/electron/main/services/agent-team/light-brain.ts index a862802d..44d13623 100644 --- a/electron/main/services/agent-team/light-brain.ts +++ b/electron/main/services/agent-team/light-brain.ts @@ -51,7 +51,7 @@ export class LightBrainOrchestrator { prompt: raw.prompt, engineType: raw.engineType as EngineType | undefined, dependsOn: raw.dependsOn, - worktreeId: raw.worktreeId, + worktreeId: raw.worktreeId ?? teamRun.worktreeId, status: "pending", })); @@ -65,7 +65,10 @@ export class LightBrainOrchestrator { this.autoApproveSessions, defaultEngineType, ); - const dagExecutor = new DAGExecutor(taskExecutor, teamRun.directory); + const dagExecutor = new DAGExecutor( + taskExecutor, + teamRun.parentDirectory ?? teamRun.directory, + ); // Forward task update events dagExecutor.on("task.updated", ({ task }) => onTaskUpdated(task)); @@ -106,8 +109,8 @@ export class LightBrainOrchestrator { const planSession = await this.engineManager.createSession( plannerEngineType, - teamRun.directory, - undefined, + teamRun.parentDirectory ?? teamRun.directory, + teamRun.worktreeId, { systemPrompt }, ); diff --git a/electron/main/services/agent-team/task-executor.ts b/electron/main/services/agent-team/task-executor.ts index 1115b3ed..694a9ab3 100644 --- a/electron/main/services/agent-team/task-executor.ts +++ b/electron/main/services/agent-team/task-executor.ts @@ -22,6 +22,7 @@ export type AutoApproveSessionTracker = Set | ((sessionId: string) => vo export interface TaskExecutionOptions { upstreamContext?: string; + defaultWorktreeId?: string; onSessionCreated?: (sessionId: string) => void; shouldCancel?: () => boolean; inactivityTimeoutMs?: number; @@ -188,8 +189,12 @@ export class TaskExecutor { inactivityTimeoutMs: number, ): Promise { const engineType = (task.engineType as EngineType) || this.defaultEngineType; + const worktreeId = task.worktreeId ?? options.defaultWorktreeId; + if (!task.worktreeId && worktreeId) { + task.worktreeId = worktreeId; + } - const session = await this.engineManager.createSession(engineType, directory, task.worktreeId); + const session = await this.engineManager.createSession(engineType, directory, worktreeId); task.sessionId = session.id; this.registerAutoApprove(session.id); diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index 6a17932f..99dd2349 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -1763,11 +1763,21 @@ export default function Chat() { if (!sessionId) return; const session = sessionStore.list.find(s => s.id === sessionId); const directory = session?.directory || "."; + const parentDirectory = session?.worktreeId + ? sessionStore.projects.find((project) => project.id === session.projectID)?.directory + : undefined; if (getActiveTeamRunForSession(sessionId)) { return; } try { - await createTeamRun(sessionId, text, mode, directory, currentEngineType()); + if (session?.worktreeId && !parentDirectory) { + throw new Error("Unable to resolve the parent project directory for this worktree session."); + } + + await createTeamRun(sessionId, text, mode, directory, currentEngineType(), { + worktreeId: session?.worktreeId, + parentDirectory, + }); notify(t().notification.teamRunStarted, "info", 3000); } catch (err: any) { logger.error("[TeamRun] Failed to create:", err); diff --git a/src/stores/team.ts b/src/stores/team.ts index 6c42bd9d..01bcdb99 100644 --- a/src/stores/team.ts +++ b/src/stores/team.ts @@ -112,6 +112,10 @@ export async function createTeamRun( mode: "light" | "heavy", directory: string, engineType?: string, + context?: { + worktreeId?: string; + parentDirectory?: string; + }, ): Promise { const run = await gateway.createTeamRun({ sessionId, @@ -119,6 +123,8 @@ export async function createTeamRun( mode, directory, engineType, + worktreeId: context?.worktreeId, + parentDirectory: context?.parentDirectory, }); setTeamStore("runs", (runs) => upsertTeamRun(runs, run)); setTeamStore("activeRunId", run.id); diff --git a/src/types/unified.ts b/src/types/unified.ts index 381d96fa..0bab5afe 100644 --- a/src/types/unified.ts +++ b/src/types/unified.ts @@ -1005,8 +1005,12 @@ export interface TeamRun { id: string; /** The parent session that initiated this team run */ parentSessionId: string; - /** Directory for all child sessions */ + /** Directory of the session that initiated this team run */ directory: string; + /** Parent repo directory for worktree-originated team runs */ + parentDirectory?: string; + /** Default worktree for child sessions created by this run */ + worktreeId?: string; /** User's original request */ originalPrompt: string; /** Light or Heavy brain mode */ @@ -1034,8 +1038,12 @@ export interface TeamCreateRequest { mode: TeamMode; /** Engine for the planner (light) or orchestrator (heavy) */ engineType?: EngineType; - /** Working directory */ + /** Working directory of the initiating session */ directory: string; + /** Parent repo directory when the initiating session belongs to a worktree */ + parentDirectory?: string; + /** Worktree name when the initiating session belongs to a worktree */ + worktreeId?: string; } export interface TeamCancelRequest { diff --git a/tests/unit/electron/services/agent-team/heavy-brain.test.ts b/tests/unit/electron/services/agent-team/heavy-brain.test.ts index 311bda8f..afa7a704 100644 --- a/tests/unit/electron/services/agent-team/heavy-brain.test.ts +++ b/tests/unit/electron/services/agent-team/heavy-brain.test.ts @@ -524,4 +524,61 @@ describe("HeavyBrainOrchestrator", () => { expect(teamRun.tasks[0].worktreeId).toBe("feature-branch"); expect(engineManager.createSession).toHaveBeenNthCalledWith(2, "opencode", "/repo", "feature-branch"); }); + + it("keeps worktree team runs inside the parent project worktree context", async () => { + const sendMessage = vi.fn(async (sessionId: string, content: Array<{ text?: string }>) => { + const text = content[0]?.text ?? ""; + + if (sessionId === "orch-session") { + if (text.includes("## Task Completed: A")) { + return makeJsonMessage({ action: "complete", result: "done" }); + } + + return makeJsonMessage({ + action: "dispatch", + tasks: [ + { + id: "A", + description: "Task A", + prompt: "Run A", + dependsOn: [], + }, + ], + }); + } + + if (sessionId === "worker-a") { + return makeTextMessage("A result"); + } + + throw new Error(`Unexpected session ${sessionId}`); + }); + + const engineManager = createEngineManagerMock(["orch-session", "worker-a"], sendMessage); + const orchestrator = new HeavyBrainOrchestrator(engineManager, new Set()); + const teamRun = makeRun({ + directory: "/repo/.worktrees/feature-branch", + parentDirectory: "/repo", + worktreeId: "feature-branch", + }); + + await orchestrator.run(teamRun, "opencode", () => {}); + + expect(teamRun.tasks[0].worktreeId).toBe("feature-branch"); + expect(engineManager.createSession).toHaveBeenNthCalledWith( + 1, + "opencode", + "/repo", + "feature-branch", + expect.objectContaining({ + systemPrompt: expect.any(String), + }), + ); + expect(engineManager.createSession).toHaveBeenNthCalledWith( + 2, + "opencode", + "/repo", + "feature-branch", + ); + }); }); diff --git a/tests/unit/electron/services/agent-team/index.test.ts b/tests/unit/electron/services/agent-team/index.test.ts index de08332e..2c1e3ab8 100644 --- a/tests/unit/electron/services/agent-team/index.test.ts +++ b/tests/unit/electron/services/agent-team/index.test.ts @@ -162,6 +162,21 @@ describe("AgentTeamService", () => { "A:completed", ]); }); + + it("rejects incomplete worktree team-run context", async () => { + const service = createService(); + service.init(createEngineManagerMock("opencode")); + + await expect(service.createRun({ + sessionId: "parent-session", + prompt: "Do the work", + mode: "light", + directory: "/repo/.worktrees/feature-branch", + worktreeId: "feature-branch", + } as any)).rejects.toThrow( + "Team runs started from worktree sessions must include both worktreeId and parentDirectory.", + ); + }); }); describe("relay contract", () => { diff --git a/tests/unit/electron/services/agent-team/light-brain.test.ts b/tests/unit/electron/services/agent-team/light-brain.test.ts index a731c2e8..4459f5e0 100644 --- a/tests/unit/electron/services/agent-team/light-brain.test.ts +++ b/tests/unit/electron/services/agent-team/light-brain.test.ts @@ -45,6 +45,70 @@ function makeRun(overrides: Partial = {}): TeamRun { } describe("LightBrainOrchestrator", () => { + it("keeps worktree team runs inside the parent project worktree context", async () => { + const engineManager = { + getDefaultEngineType: vi.fn(() => "opencode"), + listEngines: vi.fn(() => [{ + type: "opencode", + name: "OpenCode", + status: "running", + }]), + createSession: vi.fn() + .mockResolvedValueOnce({ id: "planner-session" }) + .mockResolvedValueOnce({ id: "worker-session" }), + sendMessage: vi.fn(async (sessionId: string) => { + if (sessionId === "planner-session") { + return makeTextMessage(`\`\`\`json +{ + "tasks": [ + { + "id": "t1", + "description": "Edit isolated files", + "prompt": "Apply the requested change", + "dependsOn": [] + } + ] +} +\`\`\``); + } + + if (sessionId === "worker-session") { + return makeTextMessage("worker done"); + } + + throw new Error(`Unexpected session ${sessionId}`); + }), + cancelMessage: vi.fn(async () => {}), + } as any; + + const orchestrator = new LightBrainOrchestrator(engineManager, new Set()); + const teamRun = makeRun({ + directory: "/repo/.worktrees/feature-branch", + parentDirectory: "/repo", + worktreeId: "feature-branch", + }); + + await orchestrator.run(teamRun, () => {}, "opencode"); + + expect(teamRun.status).toBe("completed"); + expect(teamRun.tasks[0].worktreeId).toBe("feature-branch"); + expect(engineManager.createSession).toHaveBeenNthCalledWith( + 1, + "opencode", + "/repo", + "feature-branch", + expect.objectContaining({ + systemPrompt: expect.any(String), + }), + ); + expect(engineManager.createSession).toHaveBeenNthCalledWith( + 2, + "opencode", + "/repo", + "feature-branch", + ); + }); + it("passes planner-provided worktreeId to worker sessions", async () => { const engineManager = { getDefaultEngineType: vi.fn(() => "opencode"), diff --git a/tests/unit/electron/services/agent-team/task-executor.test.ts b/tests/unit/electron/services/agent-team/task-executor.test.ts index 33cda2f1..65220138 100644 --- a/tests/unit/electron/services/agent-team/task-executor.test.ts +++ b/tests/unit/electron/services/agent-team/task-executor.test.ts @@ -132,4 +132,24 @@ describe("TaskExecutor", () => { expect(engineManager.createSession).toHaveBeenCalledWith("opencode", "/repo", "feature-branch"); }); + + it("inherits the default worktreeId when the task does not override it", async () => { + const engineManager = new EngineManagerMock(); + const executor = new TaskExecutor(engineManager as any, new Set(), "opencode"); + const task = makeTask({ id: "t1" }); + + await executor.execute( + task, + "/repo", + { + defaultWorktreeId: "feature-branch", + maxRetries: 0, + retryBackoffMs: 0, + inactivityTimeoutMs: 1000, + }, + ); + + expect(task.worktreeId).toBe("feature-branch"); + expect(engineManager.createSession).toHaveBeenCalledWith("opencode", "/repo", "feature-branch"); + }); }); diff --git a/tests/unit/src/stores/team.test.ts b/tests/unit/src/stores/team.test.ts index c2f1b397..39630776 100644 --- a/tests/unit/src/stores/team.test.ts +++ b/tests/unit/src/stores/team.test.ts @@ -203,13 +203,38 @@ describe("team store selectors", () => { describe("createTeamRun", () => { it("upserts the created run when a matching notification already added it", async () => { const handlers = connectTeamHandlers(); - const run = makeRun({ id: "team-dup", status: "planning" }); + const run = makeRun({ + id: "team-dup", + status: "planning", + directory: "/repo/.worktrees/feature-branch", + parentDirectory: "/repo", + worktreeId: "feature-branch", + }); handlers.onTeamRunUpdated(run); gatewayMock.createTeamRun.mockResolvedValue(run); - await createTeamRun("session-1", "Investigate issue", "heavy", "/repo", "claude"); + await createTeamRun( + "session-1", + "Investigate issue", + "heavy", + "/repo/.worktrees/feature-branch", + "claude", + { + worktreeId: "feature-branch", + parentDirectory: "/repo", + }, + ); + expect(gatewayMock.createTeamRun).toHaveBeenCalledWith({ + sessionId: "session-1", + prompt: "Investigate issue", + mode: "heavy", + directory: "/repo/.worktrees/feature-branch", + engineType: "claude", + worktreeId: "feature-branch", + parentDirectory: "/repo", + }); expect(teamStore.runs.filter((candidate) => candidate.id === "team-dup")).toHaveLength(1); }); });