diff --git a/CLAUDE.md b/CLAUDE.md index a1b4f282..92d4a66d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1430 (must match `package.json`) +**Version**: 0.1431 (must match `package.json`) ## Project Overview @@ -56,7 +56,6 @@ journalctl --user -u claudeman-web -f | Binary | Purpose | |--------|---------| | `claudeman` | Main CLI and web server | -| `claudeman-mcp` | MCP server for Claude Desktop integration | ## Architecture @@ -191,7 +190,6 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base | Topic | Location | |-------|----------| | **Respawn state machine** | `docs/respawn-state-machine.md` | -| **Spawn agent protocol** | `docs/spawn-protocol.md` | | **Ralph Loop guide** | `docs/ralph-wiggum-guide.md` | | **Claude Code hooks** | `docs/claude-code-hooks-reference.md` | | **Browser/E2E testing** | `docs/browser-testing-guide.md` | @@ -208,24 +206,6 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base | **Execution system** | `src/config/execution-limits.ts` | | **Agent prompts** | `src/prompts/` directory | -## MCP Server Integration - -The `claudeman-mcp` binary provides Model Context Protocol integration for Claude Desktop: - -```json -// Add to ~/.claude/claude_desktop_config.json -{ - "mcpServers": { - "claudeman-spawn": { - "command": "node", - "args": ["/path/to/claudeman/dist/mcp-server.js"] - } - } -} -``` - -This enables Claude Desktop to spawn and manage agents via MCP tools. - ## Scripts | Script | Purpose | diff --git a/README.md b/README.md index 6c81a4f1..12cdca84 100644 --- a/README.md +++ b/README.md @@ -126,43 +126,6 @@ Claudeman detects and tracks Ralph Loops and Todos inside Claude Code: --- -### 🤖 Spawn1337: Autonomous Agent Protocol - -Spawn full-power Claude agents that run independently in their own screen sessions: - -``` -Parent Session → task.md - → SpawnDetector parses tag - → Orchestrator creates agent directory - → Spawns Claude in its own screen session - → Agent works autonomously - → Reports result via PHRASE - → Parent notified via SSE -``` - -**Features:** -- **Resource governance**: Budget limits (tokens + cost), timeout enforcement, graceful shutdown -- **Agent trees**: Agents can spawn children (max depth: 3) -- **Communication**: Filesystem-based message bus between parent and child -- **Max 5 concurrent** agents with queuing for overflow - -```yaml -# Task spec format (YAML frontmatter in .md file) ---- -agentId: my-agent-001 -name: My Agent -type: implement -priority: high -maxTokens: 150000 -maxCost: 0.50 -timeoutMinutes: 15 -completionPhrase: AGENT_DONE ---- -Implement the feature described below... -``` - ---- - ### 👁️ Live Agent Visualization **Watch your agents work in real-time.** Claudeman monitors Claude Code's background agents (the `Task` tool) and displays them in draggable floating windows with Matrix-style connection lines. @@ -342,16 +305,6 @@ claudeman web | `GET` | `/api/sessions/:id/ralph-state` | Get loop state + todos | | `POST` | `/api/sessions/:id/ralph-config` | Configure tracking | -### Spawn Agents -| Method | Endpoint | Description | -|--------|----------|-------------| -| `GET` | `/api/spawn/agents` | List all agents | -| `GET` | `/api/spawn/agents/:id` | Agent status + progress | -| `GET` | `/api/spawn/agents/:id/result` | Agent result | -| `POST` | `/api/spawn/agents/:id/message` | Send message to agent | -| `POST` | `/api/spawn/agents/:id/cancel` | Cancel agent | -| `POST` | `/api/spawn/trigger` | Programmatic spawn | - ### Subagents (Claude Code Background Agents) | Method | Endpoint | Description | |--------|----------|-------------| @@ -395,12 +348,10 @@ flowchart TB S1["Session (PTY)"] S2["Session (PTY)"] RC["Respawn Controller"] - SO["Spawn Orchestrator"] end subgraph Detection["Detection Layer"] RT["Ralph Tracker"] - SD["Spawn Detector"] SW["Subagent Watcher
~/.claude/projects/*/subagents"] end @@ -411,8 +362,6 @@ flowchart TB subgraph External["External"] CLI["Claude CLI"] - A1["Agent 1
(screen)"] - A2["Agent 2
(screen)"] BG["Background Agents
(Task tool)"] end end @@ -425,10 +374,6 @@ flowchart TB SM --> RC SM --> SS S1 --> RT - S1 --> SD - SD --> SO - SO --> A1 - SO --> A2 S1 --> SCR S2 --> SCR RC --> SCR diff --git a/blinking.png b/blinking.png new file mode 100644 index 00000000..81214d74 Binary files /dev/null and b/blinking.png differ diff --git a/docs/spawn-protocol.md b/docs/spawn-protocol.md deleted file mode 100644 index a757d668..00000000 --- a/docs/spawn-protocol.md +++ /dev/null @@ -1,80 +0,0 @@ -# Spawn1337 Protocol (Autonomous Agents) - -Spawned agents are full-power Claude sessions running in their own screen sessions. They communicate via a filesystem-based message bus and signal completion via RalphTracker's `` mechanism. - -## Primary Interface: MCP Server - -The `claudeman-mcp` binary exposes spawn tools to Claude Code via MCP protocol, replacing the legacy terminal-tag-parsing approach (SpawnDetector). - -### MCP Tools - -| Tool | Description | -|------|-------------| -| `spawn_agent` | Spawn a new autonomous agent (builds task spec from parameters) | -| `list_agents` | List all agents (active + completed + queued) | -| `get_agent_status` | Get detailed agent status + progress | -| `get_agent_result` | Read a completed agent's result | -| `send_agent_message` | Send a message to a running agent | -| `cancel_agent` | Cancel a running agent | - -### Environment Variables - -| Variable | Description | -|----------|-------------| -| `CLAUDEMAN_API_URL` | Base URL for the Claudeman API (default: `http://localhost:3000`) | -| `CLAUDEMAN_SESSION_ID` | Session ID of the calling Claude session | - -## Protocol Flow - -``` -Claude calls spawn_agent MCP tool - → MCP server builds task spec YAML - → POST /api/spawn/trigger with task spec - → SpawnOrchestrator creates agent directory: ~/claudeman-cases/spawn-/ - → Spawns interactive Claude session in screen - → Injects initial prompt via writeViaScreen() - → Agent works autonomously, writes progress to spawn-comms/ - → RalphTracker detects PHRASE on child - → Orchestrator reads result.md, notifies parent via SSE -``` - -## Agent Directory Structure - -Each agent gets: `~/claudeman-cases/spawn-/` - -``` -spawn-/ -├── CLAUDE.md # Generated from spawn-claude-md.ts -├── spawn-comms/ -│ ├── task.md # Task specification -│ ├── progress.json # Current progress state -│ ├── result.md # Final result (on completion) -│ └── messages/ # Inter-agent messaging -└── workspace/ # Symlinked context files -``` - -## Resource Governance - -| Limit | Value | -|-------|-------| -| Max concurrent agents | 5 | -| Max depth | 3 | -| Default timeout | 30min | -| Max timeout | 120min | -| Budget warning | 80% | -| Graceful shutdown | 100% | -| Force kill | 110% | - -## Agent Tree - -Agents can spawn children. Sessions track `parentAgentId` and `childAgentIds`. Cancelling a parent cascades to all children. - -## Key Source Files - -| File | Purpose | -|------|---------| -| `src/mcp-server.ts` | MCP server binary exposing spawn tools | -| `src/spawn-orchestrator.ts` | Full agent lifecycle: spawn, monitor, budget, queue, cleanup | -| `src/spawn-claude-md.ts` | Generates CLAUDE.md for spawned agent sessions | -| `src/spawn-types.ts` | Types, YAML parser, factory functions | -| `src/spawn-detector.ts` | Legacy: detects `` tags in terminal output | diff --git a/package.json b/package.json index 1c2bc46d..c4ae4821 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,15 @@ { "name": "claudeman", - "version": "0.1430", + "version": "0.1431", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", "bin": { - "claudeman": "./dist/index.js", - "claudeman-mcp": "./dist/mcp-server.js" + "claudeman": "./dist/index.js" }, "scripts": { "postinstall": "node scripts/postinstall.js", - "build": "tsc && chmod +x dist/index.js dist/mcp-server.js && mkdir -p dist/web dist/templates && cp -r src/web/public dist/web/ && cp src/templates/case-template.md dist/templates/", + "build": "tsc && chmod +x dist/index.js && mkdir -p dist/web dist/templates && cp -r src/web/public dist/web/ && cp src/templates/case-template.md dist/templates/", "start": "node dist/index.js", "dev": "tsx src/index.ts", "web": "node dist/index.js web", @@ -39,7 +38,6 @@ "license": "MIT", "dependencies": { "@fastify/static": "^8.0.0", - "@modelcontextprotocol/sdk": "^1.25.3", "chalk": "^5.3.0", "chokidar": "^3.6.0", "commander": "^12.1.0", diff --git a/src/mcp-server.ts b/src/mcp-server.ts deleted file mode 100644 index 64110695..00000000 --- a/src/mcp-server.ts +++ /dev/null @@ -1,370 +0,0 @@ -#!/usr/bin/env node - -/** - * @fileoverview MCP Server for Claudeman Spawn1337 protocol. - * - * Exposes spawn capabilities as native MCP tools that Claude Code can call - * directly, replacing the terminal-tag-parsing approach (SpawnDetector). - * - * Tools: - * - spawn_agent: Spawn a new autonomous agent - * - list_agents: List all agents (active + completed) - * - get_agent_status: Get detailed agent status - * - get_agent_result: Read a completed agent's result - * - send_agent_message: Send a message to a running agent - * - cancel_agent: Cancel a running agent - * - * Environment: - * - CLAUDEMAN_API_URL: Base URL for the Claudeman API (default: http://localhost:3000) - * - CLAUDEMAN_SESSION_ID: Session ID of the calling Claude session - * - * @module mcp-server - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; - -// ========== Configuration ========== - -const API_URL = process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'; -const SESSION_ID = process.env.CLAUDEMAN_SESSION_ID || ''; - -// ========== API Helper ========== - -/** - * Make an HTTP request to the Claudeman API. - */ -async function apiRequest(method: string, path: string, body?: unknown): Promise<{ status: number; data: unknown }> { - const url = `${API_URL}${path}`; - const options: RequestInit = { - method, - headers: { 'Content-Type': 'application/json' }, - }; - if (body !== undefined) { - options.body = JSON.stringify(body); - } - - const response = await fetch(url, options); - const data = await response.json(); - return { status: response.status, data }; -} - -// ========== YAML Construction ========== - -/** - * Build a YAML frontmatter + body task spec from structured parameters. - */ -export function buildTaskSpec(params: { - agentId: string; - name: string; - instructions: string; - type?: string; - priority?: string; - maxTokens?: number; - maxCost?: number; - timeoutMinutes?: number; - canModifyParentFiles?: boolean; - contextFiles?: string[]; - dependsOn?: string[]; - completionPhrase?: string; - outputFormat?: string; - successCriteria?: string; - workingDir?: string; -}): string { - const lines: string[] = ['---']; - - lines.push(`agentId: ${params.agentId}`); - lines.push(`name: ${params.name}`); - - if (params.type) lines.push(`type: ${params.type}`); - if (params.priority) lines.push(`priority: ${params.priority}`); - if (params.maxTokens != null) lines.push(`maxTokens: ${params.maxTokens}`); - if (params.maxCost != null) lines.push(`maxCost: ${params.maxCost}`); - if (params.timeoutMinutes != null) lines.push(`timeoutMinutes: ${params.timeoutMinutes}`); - if (params.canModifyParentFiles != null) lines.push(`canModifyParentFiles: ${params.canModifyParentFiles}`); - if (params.completionPhrase) lines.push(`completionPhrase: ${params.completionPhrase}`); - if (params.outputFormat) lines.push(`outputFormat: ${params.outputFormat}`); - if (params.successCriteria) lines.push(`successCriteria: "${params.successCriteria.replace(/"/g, '\\"')}"`); - if (params.workingDir) lines.push(`workingDir: ${params.workingDir}`); - - if (params.contextFiles && params.contextFiles.length > 0) { - lines.push(`contextFiles: [${params.contextFiles.join(', ')}]`); - } - - if (params.dependsOn && params.dependsOn.length > 0) { - lines.push(`dependsOn: [${params.dependsOn.join(', ')}]`); - } - - lines.push('---'); - lines.push(''); - lines.push(params.instructions); - - return lines.join('\n'); -} - -// ========== MCP Server Setup ========== - -const server = new McpServer({ - name: 'claudeman-spawn', - version: '1.0.0', -}); - -// ---- spawn_agent ---- - -server.tool( - 'spawn_agent', - 'Spawn a new autonomous Claude agent to handle a subtask. The agent runs in its own session with full capabilities.', - { - agentId: z.string().describe('Unique identifier for the agent (e.g., "research-auth-001")'), - name: z.string().describe('Human-readable name for the agent'), - instructions: z.string().describe('Detailed task instructions for the agent (markdown)'), - type: z.enum(['explore', 'implement', 'test', 'review', 'refactor', 'research', 'generate', 'fix', 'general']).optional().describe('Task type/category'), - priority: z.enum(['low', 'normal', 'high', 'critical']).optional().describe('Priority for queue ordering'), - maxTokens: z.number().optional().describe('Maximum token budget (input + output combined)'), - maxCost: z.number().optional().describe('Maximum cost in USD'), - timeoutMinutes: z.number().optional().describe('Maximum runtime in minutes (max: 120)'), - canModifyParentFiles: z.boolean().optional().describe('Whether agent can modify files in parent project directory'), - contextFiles: z.array(z.string()).optional().describe('Files to symlink into agent workspace as context'), - dependsOn: z.array(z.string()).optional().describe('Agent IDs that must complete before this one starts'), - completionPhrase: z.string().optional().describe('Phrase agent outputs when finished (default: auto-generated)'), - outputFormat: z.enum(['markdown', 'json', 'code', 'structured', 'freeform']).optional().describe('Expected output format'), - successCriteria: z.string().optional().describe('Success criteria included in agent instructions'), - workingDir: z.string().optional().describe('Working directory (relative to parent, or absolute)'), - }, - async (params) => { - if (!SESSION_ID) { - return { - content: [{ type: 'text', text: 'Error: CLAUDEMAN_SESSION_ID not set. This tool must be run within a Claudeman-managed session.' }], - isError: true, - }; - } - - const taskSpec = buildTaskSpec(params); - - try { - const { status, data } = await apiRequest('POST', '/api/spawn/trigger', { - parentSessionId: SESSION_ID, - taskContent: taskSpec, - }); - - if (status >= 400) { - const errorData = data as { error?: { code?: string; details?: string } }; - const errorMsg = errorData.error?.details || errorData.error?.code || 'Unknown error'; - return { - content: [{ type: 'text', text: `Error spawning agent: ${errorMsg}` }], - isError: true, - }; - } - - const result = data as { success: boolean; data?: { agentId: string } }; - const agentId = result.data?.agentId || params.agentId; - return { - content: [{ type: 'text', text: `Agent spawned successfully.\n\nAgent ID: ${agentId}` }], - }; - } catch (err) { - return { - content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}. Is the web server running?\n\n${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - } -); - -// ---- list_agents ---- - -server.tool( - 'list_agents', - 'List all spawn agents (active, queued, and completed).', - {}, - async () => { - try { - const { status, data } = await apiRequest('GET', '/api/spawn/agents'); - - if (status >= 400) { - return { - content: [{ type: 'text', text: `Error listing agents: ${JSON.stringify(data)}` }], - isError: true, - }; - } - - const agents = data as Array<{ agentId: string; name: string; status: string; type: string; priority: string }>; - if (agents.length === 0) { - return { content: [{ type: 'text', text: 'No agents found.' }] }; - } - - const summary = agents.map(a => `- ${a.agentId} (${a.name}): ${a.status} [${a.type}, ${a.priority}]`).join('\n'); - return { content: [{ type: 'text', text: `Agents (${agents.length}):\n\n${summary}` }] }; - } catch (err) { - return { - content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - } -); - -// ---- get_agent_status ---- - -server.tool( - 'get_agent_status', - 'Get detailed status and progress of a specific agent.', - { - agentId: z.string().describe('The agent ID to query'), - }, - async ({ agentId }) => { - try { - const { status, data } = await apiRequest('GET', `/api/spawn/agents/${encodeURIComponent(agentId)}`); - - if (status === 404) { - return { - content: [{ type: 'text', text: `Agent not found: ${agentId}` }], - isError: true, - }; - } - - if (status >= 400) { - return { - content: [{ type: 'text', text: `Error getting agent status: ${JSON.stringify(data)}` }], - isError: true, - }; - } - - return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; - } catch (err) { - return { - content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - } -); - -// ---- get_agent_result ---- - -server.tool( - 'get_agent_result', - 'Read the result of a completed agent. Returns the agent\'s output and metadata.', - { - agentId: z.string().describe('The agent ID whose result to read'), - }, - async ({ agentId }) => { - try { - const { status, data } = await apiRequest('GET', `/api/spawn/agents/${encodeURIComponent(agentId)}/result`); - - if (status === 404) { - return { - content: [{ type: 'text', text: `Agent or result not found: ${agentId}. The agent may not have completed yet.` }], - isError: true, - }; - } - - if (status >= 400) { - return { - content: [{ type: 'text', text: `Error getting agent result: ${JSON.stringify(data)}` }], - isError: true, - }; - } - - // Result could be a string (raw markdown) or an object - const resultText = typeof data === 'string' ? data : JSON.stringify(data, null, 2); - return { content: [{ type: 'text', text: resultText }] }; - } catch (err) { - return { - content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - } -); - -// ---- send_agent_message ---- - -server.tool( - 'send_agent_message', - 'Send a message to a running agent. The message is written to the agent\'s communication channel.', - { - agentId: z.string().describe('The agent ID to message'), - message: z.string().describe('The message content (markdown)'), - }, - async ({ agentId, message }) => { - try { - const { status, data } = await apiRequest('POST', `/api/spawn/agents/${encodeURIComponent(agentId)}/message`, { - content: message, - sender: 'parent', - }); - - if (status === 404) { - return { - content: [{ type: 'text', text: `Agent not found: ${agentId}` }], - isError: true, - }; - } - - if (status >= 400) { - return { - content: [{ type: 'text', text: `Error sending message: ${JSON.stringify(data)}` }], - isError: true, - }; - } - - return { content: [{ type: 'text', text: `Message sent to agent ${agentId}.` }] }; - } catch (err) { - return { - content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - } -); - -// ---- cancel_agent ---- - -server.tool( - 'cancel_agent', - 'Cancel a running agent. Sends a graceful shutdown signal.', - { - agentId: z.string().describe('The agent ID to cancel'), - reason: z.string().optional().describe('Reason for cancellation'), - }, - async ({ agentId, reason }) => { - try { - const { status, data } = await apiRequest('POST', `/api/spawn/agents/${encodeURIComponent(agentId)}/cancel`, { - reason: reason || 'Cancelled by parent session', - }); - - if (status === 404) { - return { - content: [{ type: 'text', text: `Agent not found: ${agentId}` }], - isError: true, - }; - } - - if (status >= 400) { - return { - content: [{ type: 'text', text: `Error cancelling agent: ${JSON.stringify(data)}` }], - isError: true, - }; - } - - return { content: [{ type: 'text', text: `Agent ${agentId} cancel request sent.` }] }; - } catch (err) { - return { - content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - } -); - -// ========== Start Server ========== - -async function main(): Promise { - const transport = new StdioServerTransport(); - await server.connect(transport); -} - -main().catch((err) => { - console.error('MCP server failed to start:', err); - process.exit(1); -}); diff --git a/src/spawn-claude-md.ts b/src/spawn-claude-md.ts deleted file mode 100644 index 04429702..00000000 --- a/src/spawn-claude-md.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * @fileoverview Agent CLAUDE.md Generator for spawn1337 protocol. - * - * Generates a comprehensive CLAUDE.md for each spawned agent that tells it: - * - What its task is - * - How to communicate progress - * - How to signal completion - * - What constraints it has - * - How to read/write messages - * - * @module spawn-claude-md - */ - -import type { SpawnTask } from './spawn-types.js'; - -/** - * Generate a CLAUDE.md file for a spawned agent. - * - * This CLAUDE.md gives the agent full context about: - * - Its identity and task - * - Communication protocol (progress, messages, result) - * - Resource constraints (timeout, tokens, cost) - * - Working directory and available context files - * - * @param task - The full parsed task specification - * @param commsDir - Absolute path to the communication directory - * @param agentWorkingDir - Absolute path to the agent's working directory - * @returns The generated CLAUDE.md content - */ -export function generateAgentClaudeMd(task: SpawnTask, commsDir: string, agentWorkingDir: string): string { - const spec = task.spec; - - const constraintLines: string[] = []; - constraintLines.push(`- Timeout: ${spec.timeoutMinutes} minutes`); - if (spec.maxTokens) constraintLines.push(`- Token budget: ${spec.maxTokens.toLocaleString()} tokens`); - if (spec.maxCost) constraintLines.push(`- Cost budget: $${spec.maxCost.toFixed(2)}`); - if (!spec.canModifyParentFiles) { - constraintLines.push('- DO NOT modify files outside your workspace'); - } else { - constraintLines.push('- You MAY modify files in the parent project directory'); - } - constraintLines.push(`- Output format: ${spec.outputFormat}`); - - const contextSection = spec.contextFiles && spec.contextFiles.length > 0 - ? `\nContext files available in workspace:\n${spec.contextFiles.map(f => `- ${f}`).join('\n')}` - : ''; - - const progressSection = spec.progressIntervalSeconds > 0 - ? `### Progress Reporting - -Update \`${commsDir}/progress.json\` every ~${spec.progressIntervalSeconds} seconds with your current status: - -\`\`\`json -{ - "phase": "current phase description", - "percentComplete": 45, - "currentAction": "What you are doing right now", - "subtasks": [ - {"description": "Subtask 1", "status": "completed"}, - {"description": "Subtask 2", "status": "in_progress"} - ], - "filesModified": ["file1.ts", "file2.ts"], - "tokensUsed": 0, - "costSoFar": 0, - "updatedAt": ${Date.now()} -} -\`\`\`` - : '### Progress Reporting\n\nProgress reporting is disabled for this task.'; - - return `# Agent: ${spec.name} - -## Your Identity - -You are an autonomous agent (ID: \`${spec.agentId}\`) spawned by a parent Claude session. -You are running in your own screen session with full Claude Code capabilities. -Type: ${spec.type} | Priority: ${spec.priority} | Depth: ${task.depth} - -## Task - -${task.instructions} - -## Success Criteria - -${spec.successCriteria || 'Complete the task as described above.'} - -## Communication Protocol - -${progressSection} - -### Check for Messages - -Periodically check \`${commsDir}/messages/\` for instructions from the parent. -Files are named \`NNN-parent.md\` (from parent) or \`NNN-agent.md\` (from you). -Read any new \`*-parent.md\` files for additional instructions or clarifications. - -To send a message back to the parent, create a file like: -\`${commsDir}/messages/002-agent.md\` - -### Write Result - -When complete, write your final result to \`${commsDir}/result.md\` with YAML frontmatter: - -\`\`\`markdown ---- -status: completed -summary: "Brief 1-3 sentence summary of what you accomplished" -filesChanged: - - path: relative/path/to/file.ts - action: modified - summary: "What was changed" ---- - -## Detailed Output - -Your full output, analysis, or report here. -\`\`\` - -Valid status values: \`completed\`, \`failed\` - -### Signal Completion - -After writing result.md, output this EXACT phrase to signal you are done: - -${spec.completionPhrase} - -**IMPORTANT**: Only output the completion phrase AFTER you have written result.md. -The completion phrase triggers the orchestrator to read your result and clean up. - -## Constraints - -${constraintLines.join('\n')} - -## Working Directory - -Your workspace is: \`${agentWorkingDir}\` -${contextSection} - -## Important Notes - -- Work autonomously - do not ask for user input -- Focus exclusively on the task described above -- If you encounter errors, document them in result.md with status: failed -- Do not modify this CLAUDE.md file -- Stay within your resource constraints -`; -} - -/** - * Build the initial prompt injected into the agent session via writeViaScreen(). - * Intentionally brief - all detail is in the CLAUDE.md. - */ -export function buildInitialPrompt(task: SpawnTask): string { - return `Read your CLAUDE.md file for complete task instructions, communication protocol, and constraints. Begin working on the task immediately. Report progress to spawn-comms/progress.json periodically. When complete, write your result to spawn-comms/result.md and then output your completion phrase: ${task.spec.completionPhrase}`; -} diff --git a/src/spawn-orchestrator.ts b/src/spawn-orchestrator.ts deleted file mode 100644 index 31ee3dc3..00000000 --- a/src/spawn-orchestrator.ts +++ /dev/null @@ -1,964 +0,0 @@ -/** - * @fileoverview Spawn Orchestrator - Full lifecycle management for spawned agents. - * - * Manages: - * - Agent creation from task spec files - * - Directory setup (CLAUDE.md, comms, workspace) - * - Session spawning via screen - * - Progress monitoring and timeout enforcement - * - Resource governance (tokens, cost, depth) - * - Bidirectional communication - * - Result collection and cleanup - * - Queue management with priority ordering - * - * @module spawn-orchestrator - */ - -import { EventEmitter } from 'node:events'; -import { join, resolve, isAbsolute } from 'node:path'; -import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, symlinkSync } from 'node:fs'; -import { v4 as uuidv4 } from 'uuid'; -import { - type SpawnOrchestratorConfig, - type SpawnTask, - type AgentContext, - type AgentProgress, - type AgentStatusReport, - type SpawnResult, - type SpawnTrackerState, - type SpawnMessage, - type SpawnPersistedState, - createDefaultOrchestratorConfig, - createEmptyAgentProgress, - parseTaskSpecFile, - parseSpawnResult, - MAX_TASK_FILE_SIZE, - MAX_CONTEXT_FILE_SIZE, - MAX_CONTEXT_FILES, - MAX_QUEUE_LENGTH, - BUDGET_WARNING_THRESHOLD, - MESSAGE_MAX_SIZE, - MAX_MESSAGES_PER_CHANNEL, - MAX_TRACKED_AGENTS, -} from './spawn-types.js'; -import { generateAgentClaudeMd, buildInitialPrompt } from './spawn-claude-md.js'; -import { getErrorMessage } from './types.js'; - -// ========== Local Constants ========== - -/** UUID truncation length for fallback agent IDs */ -const UUID_TRUNCATE_LENGTH = 8; - -/** Message sequence number padding length */ -const MESSAGE_SEQUENCE_PAD_LENGTH = 3; - -/** Timeout warning threshold (90% of timeout) */ -const TIMEOUT_WARNING_RATIO = 0.9; - -/** Budget hard limit ratio (110% - force stop) */ -const BUDGET_HARD_LIMIT_RATIO = 1.1; - -/** Budget soft limit ratio (100% - warning) */ -const BUDGET_SOFT_LIMIT_RATIO = 1.0; - -// ========== Types for integration ========== - -/** - * Interface for session creation callback. - * The orchestrator delegates session creation to the server to avoid circular deps. - */ -export interface SessionCreator { - createAgentSession(workingDir: string, name: string): Promise<{ sessionId: string }>; - writeToSession(sessionId: string, data: string): void; - getSessionTokens(sessionId: string): number; - getSessionCost(sessionId: string): number; - stopSession(sessionId: string): Promise; - onSessionCompletion(sessionId: string, handler: (phrase: string) => void): void; - removeSessionCompletionHandler(sessionId: string, handler: (phrase: string) => void): void; -} - -// ========== Events ========== - -export interface SpawnOrchestratorEvents { - /** Agent added to queue */ - queued: (data: { agentId: string; name: string; parentSessionId: string; position: number }) => void; - /** Agent directory being set up */ - initializing: (data: { agentId: string; name: string; workingDir: string }) => void; - /** Agent session started */ - started: (data: { agentId: string; name: string; sessionId: string }) => void; - /** Agent progress update */ - progress: (data: { agentId: string; progress: AgentProgress }) => void; - /** New message in channel */ - message: (data: { agentId: string; message: SpawnMessage }) => void; - /** Agent completed successfully */ - completed: (data: { agentId: string; result: SpawnResult }) => void; - /** Agent failed */ - failed: (data: { agentId: string; error: string; partialProgress: AgentProgress | null }) => void; - /** Agent timed out */ - timeout: (data: { agentId: string; elapsed: number; limit: number }) => void; - /** Agent cancelled */ - cancelled: (data: { agentId: string; reason: string }) => void; - /** Budget warning */ - budgetWarning: (data: { agentId: string; type: 'tokens' | 'cost'; used: number; limit: number }) => void; - /** Overall state changed */ - stateUpdate: (state: SpawnTrackerState) => void; -} - -/** - * SpawnOrchestrator - Manages the full lifecycle of spawned agents. - * - * Handles agent creation, monitoring, communication, resource governance, - * and cleanup. Integrates with Session, ScreenManager, and RalphTracker. - */ -export class SpawnOrchestrator extends EventEmitter { - private _agents: Map = new Map(); - private _completedAgents: Map = new Map(); - private _queue: SpawnTask[] = []; - private _config: SpawnOrchestratorConfig; - private _sessionCreator: SessionCreator | null = null; - private _totalSpawned: number = 0; - private _totalCompleted: number = 0; - private _totalFailed: number = 0; - private _maxDepthReached: number = 0; - private _completionHandlers: Map void> = new Map(); - - constructor(config?: Partial) { - super(); - this._config = { ...createDefaultOrchestratorConfig(), ...config }; - } - - /** - * Set the session creator callback. - * Must be called before any spawn requests can be processed. - */ - setSessionCreator(creator: SessionCreator): void { - this._sessionCreator = creator; - } - - /** - * Get current orchestrator configuration. - */ - get config(): SpawnOrchestratorConfig { - return { ...this._config }; - } - - /** - * Update orchestrator configuration. - */ - updateConfig(config: Partial): void { - Object.assign(this._config, config); - } - - /** - * Handle a spawn request detected from terminal output. - * - * @param filePath - Path to the task spec file (relative to parent's workingDir) - * @param parentSessionId - ID of the parent session - * @param parentWorkingDir - Working directory of the parent session - * @param parentDepth - Depth of the parent in the spawn tree - */ - async handleSpawnRequest( - filePath: string, - parentSessionId: string, - parentWorkingDir: string, - parentDepth: number = 0 - ): Promise { - if (!this._sessionCreator) { - console.error('[spawn-orchestrator] No session creator set, cannot spawn agent'); - return; - } - - // Resolve file path relative to parent's working directory - const resolvedPath = isAbsolute(filePath) ? filePath : join(parentWorkingDir, filePath); - - // Validate file exists and size - if (!existsSync(resolvedPath)) { - console.error(`[spawn-orchestrator] Task file not found: ${resolvedPath}`); - this.emit('failed', { agentId: 'unknown', error: `Task file not found: ${resolvedPath}`, partialProgress: null }); - return; - } - - const stat = statSync(resolvedPath); - if (stat.size > MAX_TASK_FILE_SIZE) { - console.error(`[spawn-orchestrator] Task file too large: ${stat.size} bytes (max ${MAX_TASK_FILE_SIZE})`); - this.emit('failed', { agentId: 'unknown', error: `Task file too large: ${stat.size} bytes`, partialProgress: null }); - return; - } - - // Parse task file - const content = readFileSync(resolvedPath, 'utf-8'); - const fallbackId = `agent-${uuidv4().slice(0, UUID_TRUNCATE_LENGTH)}`; - const parsed = parseTaskSpecFile(content, fallbackId); - - if (!parsed) { - console.error(`[spawn-orchestrator] Failed to parse task file: ${resolvedPath}`); - this.emit('failed', { agentId: fallbackId, error: 'Failed to parse task spec YAML frontmatter', partialProgress: null }); - return; - } - - const childDepth = parentDepth + 1; - - // Depth check - if (childDepth > this._config.maxSpawnDepth) { - console.error(`[spawn-orchestrator] Max spawn depth (${this._config.maxSpawnDepth}) exceeded at depth ${childDepth}`); - this.emit('failed', { agentId: parsed.spec.agentId, error: `Max spawn depth exceeded (${this._config.maxSpawnDepth})`, partialProgress: null }); - return; - } - - // Enforce timeout limits - if (parsed.spec.timeoutMinutes > this._config.maxTimeoutMinutes) { - parsed.spec.timeoutMinutes = this._config.maxTimeoutMinutes; - } - - const task: SpawnTask = { - spec: parsed.spec, - instructions: parsed.instructions, - sourceFile: resolvedPath, - parentSessionId, - depth: childDepth, - }; - - // Check dependencies - if (task.spec.dependsOn && task.spec.dependsOn.length > 0) { - const unmetDeps = task.spec.dependsOn.filter(depId => { - const dep = this._completedAgents.get(depId); - return !dep || dep.status !== 'completed'; - }); - if (unmetDeps.length > 0) { - // Queue with dependency tracking - this.enqueueTask(task); - return; - } - } - - // Concurrency check - const activeCount = this.getActiveCount(); - if (activeCount >= this._config.maxConcurrentAgents) { - this.enqueueTask(task); - return; - } - - // Spawn immediately - await this.spawnAgent(task); - } - - /** - * Cancel an agent by ID. - * Cascades cancellation to all child agents before cleaning up the parent. - */ - async cancelAgent(agentId: string, reason: string = 'Cancelled by parent'): Promise { - const agent = this._agents.get(agentId); - if (!agent) { - // Check queue - const queueIdx = this._queue.findIndex(t => t.spec.agentId === agentId); - if (queueIdx >= 0) { - this._queue.splice(queueIdx, 1); - this.emit('cancelled', { agentId, reason: 'Removed from queue' }); - this.emitStateUpdate(); - } - return; - } - - // Cancel all child agents first (cascade) - // Child agents have their parentSessionId set to this agent's sessionId - if (agent.sessionId) { - for (const [childId, childAgent] of this._agents) { - if (childAgent.parentSessionId === agent.sessionId && childAgent.status !== 'cancelled') { - await this.cancelAgent(childId, `Parent ${agentId} cancelled`); - } - } - } - - // Also remove any queued tasks that depend on this agent's session - if (agent.sessionId) { - const queuedChildren = this._queue.filter(t => t.parentSessionId === agent.sessionId); - for (const task of queuedChildren) { - const idx = this._queue.indexOf(task); - if (idx >= 0) { - this._queue.splice(idx, 1); - this.emit('cancelled', { agentId: task.spec.agentId, reason: `Parent ${agentId} cancelled` }); - } - } - } - - agent.status = 'cancelled'; - this.emit('cancelled', { agentId, reason }); - - await this.cleanupAgent(agentId); - } - - /** - * Send a message to an agent. - */ - async sendMessageToAgent(agentId: string, content: string): Promise { - const agent = this._agents.get(agentId); - if (!agent) return; - - if (content.length > MESSAGE_MAX_SIZE) { - content = content.slice(0, MESSAGE_MAX_SIZE); - } - - const messagesDir = join(agent.commsDir, 'messages'); - if (!existsSync(messagesDir)) { - mkdirSync(messagesDir, { recursive: true }); - } - - // Count existing messages - const existingMessages = readdirSync(messagesDir).filter(f => f.endsWith('.md')); - if (existingMessages.length >= MAX_MESSAGES_PER_CHANNEL) { - return; // Channel full - } - - const seq = existingMessages.length + 1; - const seqStr = String(seq).padStart(MESSAGE_SEQUENCE_PAD_LENGTH, '0'); - const fileName = `${seqStr}-parent.md`; - - const message: SpawnMessage = { - sequence: seq, - sender: 'parent', - content, - sentAt: Date.now(), - read: false, - }; - - writeFileSync(join(messagesDir, fileName), content, 'utf-8'); - this.emit('message', { agentId, message }); - } - - /** - * Get status of a specific agent. - */ - getAgentStatus(agentId: string): AgentStatusReport | null { - const agent = this._agents.get(agentId) || this._completedAgents.get(agentId); - if (!agent) return null; - return this.buildStatusReport(agent); - } - - /** - * Get status of all agents (active + recently completed). - */ - getAllAgentStatuses(): AgentStatusReport[] { - const reports: AgentStatusReport[] = []; - for (const agent of this._agents.values()) { - reports.push(this.buildStatusReport(agent)); - } - for (const agent of this._completedAgents.values()) { - reports.push(this.buildStatusReport(agent)); - } - return reports; - } - - /** - * Get current orchestrator state. - */ - getState(): SpawnTrackerState { - return { - enabled: true, - activeCount: this.getActiveCount(), - queuedCount: this._queue.length, - totalSpawned: this._totalSpawned, - totalCompleted: this._totalCompleted, - totalFailed: this._totalFailed, - maxDepthReached: this._maxDepthReached, - agents: this.getAllAgentStatuses(), - }; - } - - /** - * Get state for persistence. - */ - getPersistedState(): SpawnPersistedState { - const agents: SpawnPersistedState['agents'] = {}; - for (const [id, agent] of this._agents) { - agents[id] = { - agentId: id, - status: agent.status, - parentSessionId: agent.parentSessionId, - childSessionId: agent.sessionId, - depth: agent.depth, - startedAt: agent.startedAt, - commsDir: agent.commsDir, - workingDir: agent.workingDir, - completionPhrase: agent.task.spec.completionPhrase, - timeoutMinutes: agent.task.spec.timeoutMinutes, - }; - } - return { config: this._config, agents }; - } - - /** - * Stop all agents. - */ - async stopAll(): Promise { - const agentIds = Array.from(this._agents.keys()); - for (const agentId of agentIds) { - await this.cancelAgent(agentId, 'Orchestrator shutdown'); - } - this._queue = []; - } - - /** - * Read an agent's result.md file. - */ - readAgentResult(agentId: string): SpawnResult | null { - const agent = this._agents.get(agentId) || this._completedAgents.get(agentId); - if (!agent) return null; - - const resultPath = join(agent.commsDir, 'result.md'); - if (!existsSync(resultPath)) return null; - - const content = readFileSync(resultPath, 'utf-8'); - const durationMs = agent.startedAt ? Date.now() - agent.startedAt : 0; - return parseSpawnResult(content, agentId, durationMs); - } - - /** - * Read an agent's progress.json file. - */ - readAgentProgress(agentId: string): AgentProgress | null { - const agent = this._agents.get(agentId) || this._completedAgents.get(agentId); - if (!agent) return null; - - const progressPath = join(agent.commsDir, 'progress.json'); - if (!existsSync(progressPath)) return null; - - try { - const content = readFileSync(progressPath, 'utf-8'); - return JSON.parse(content) as AgentProgress; - } catch (err) { - console.warn(`[spawn-orchestrator] Failed to read progress for agent ${agentId}: ${getErrorMessage(err)}`); - return null; - } - } - - /** - * Read messages from an agent's communication channel. - */ - readAgentMessages(agentId: string): SpawnMessage[] { - const agent = this._agents.get(agentId) || this._completedAgents.get(agentId); - if (!agent) return []; - - const messagesDir = join(agent.commsDir, 'messages'); - if (!existsSync(messagesDir)) return []; - - try { - const files = readdirSync(messagesDir) - .filter(f => f.endsWith('.md')) - .sort(); - - const messages: SpawnMessage[] = []; - for (const file of files) { - const match = file.match(/^(\d+)-(parent|agent)\.md$/); - if (!match) continue; - - try { - const filePath = join(messagesDir, file); - const content = readFileSync(filePath, 'utf-8'); - messages.push({ - sequence: parseInt(match[1]), - sender: match[2] as 'parent' | 'agent', - content, - sentAt: statSync(filePath).mtimeMs, - read: true, - }); - } catch (err) { - console.warn(`[spawn-orchestrator] Failed to read message file ${file}: ${getErrorMessage(err)}`); - // Continue processing other messages - } - } - - return messages; - } catch (err) { - console.warn(`[spawn-orchestrator] Failed to read messages for agent ${agentId}: ${getErrorMessage(err)}`); - return []; - } - } - - /** - * Programmatically trigger a spawn without terminal detection. - */ - async triggerSpawn( - taskContent: string, - parentSessionId: string, - parentWorkingDir: string, - parentDepth: number = 0 - ): Promise { - const fallbackId = `agent-${uuidv4().slice(0, UUID_TRUNCATE_LENGTH)}`; - const parsed = parseTaskSpecFile(taskContent, fallbackId); - if (!parsed) return null; - - // If the spec doesn't specify a workingDir, use the parent's - if (!parsed.spec.workingDir) { - parsed.spec.workingDir = parentWorkingDir; - } - - // Write task content to a temp file so setupAgentDirectory can read it - const tempDir = join(this._config.casesDir, '.spawn-tmp'); - mkdirSync(tempDir, { recursive: true }); - const tempFile = join(tempDir, `${parsed.spec.agentId}.md`); - writeFileSync(tempFile, taskContent, 'utf-8'); - - const task: SpawnTask = { - spec: parsed.spec, - instructions: parsed.instructions, - sourceFile: tempFile, - parentSessionId, - depth: parentDepth + 1, - }; - - await this.spawnAgent(task); - return task.spec.agentId; - } - - // ========== Internal Methods ========== - - private getActiveCount(): number { - let count = 0; - for (const agent of this._agents.values()) { - if (agent.status === 'initializing' || agent.status === 'running') { - count++; - } - } - return count; - } - - private enqueueTask(task: SpawnTask): void { - if (this._queue.length >= MAX_QUEUE_LENGTH) { - this.emit('failed', { - agentId: task.spec.agentId, - error: `Queue full (max ${MAX_QUEUE_LENGTH})`, - partialProgress: null, - }); - return; - } - - // Insert by priority (higher priority first) - const priorityOrder = { critical: 0, high: 1, normal: 2, low: 3 }; - const taskPriority = priorityOrder[task.spec.priority]; - let insertIdx = this._queue.length; - for (let i = 0; i < this._queue.length; i++) { - if (priorityOrder[this._queue[i].spec.priority] > taskPriority) { - insertIdx = i; - break; - } - } - this._queue.splice(insertIdx, 0, task); - - this.emit('queued', { - agentId: task.spec.agentId, - name: task.spec.name, - parentSessionId: task.parentSessionId, - position: insertIdx + 1, - }); - this.emitStateUpdate(); - } - - private async spawnAgent(task: SpawnTask): Promise { - if (!this._sessionCreator) return; - - const agentId = task.spec.agentId; - this._totalSpawned++; - if (task.depth > this._maxDepthReached) { - this._maxDepthReached = task.depth; - } - - // Create agent context - const workingDir = join(this._config.casesDir, `spawn-${agentId}`); - const commsDir = join(workingDir, 'spawn-comms'); - - const agent: AgentContext = { - task, - sessionId: null, - workingDir, - commsDir, - parentSessionId: task.parentSessionId, - depth: task.depth, - timeoutTimer: null, - warningTimer: null, - progressTimer: null, - status: 'initializing', - startedAt: null, - tokenBudget: task.spec.maxTokens ?? null, - costBudget: task.spec.maxCost ?? null, - }; - - this._agents.set(agentId, agent); - this.emit('initializing', { agentId, name: task.spec.name, workingDir }); - this.emitStateUpdate(); - - try { - // Setup directory structure - this.setupAgentDirectory(task, workingDir, commsDir); - - // Create session - const { sessionId } = await this._sessionCreator.createAgentSession(workingDir, agentId); - agent.sessionId = sessionId; - agent.status = 'running'; - agent.startedAt = Date.now(); - - this.emit('started', { agentId, name: task.spec.name, sessionId }); - this.emitStateUpdate(); - - // Setup completion listener - this.setupCompletionListener(agent); - - // Setup progress monitor - this.setupProgressMonitor(agent); - - // Setup timeout - this.setupTimeout(agent); - - // Inject initial prompt (short delay to let session initialize) - setTimeout(() => { - if (agent.status === 'running' && this._sessionCreator) { - const prompt = buildInitialPrompt(task); - this._sessionCreator.writeToSession(sessionId, prompt + '\r'); - } - }, 3000); - - } catch (err) { - agent.status = 'failed'; - this._totalFailed++; - this.emit('failed', { agentId, error: getErrorMessage(err), partialProgress: null }); - await this.cleanupAgent(agentId); - } - } - - private setupAgentDirectory(task: SpawnTask, workingDir: string, commsDir: string): void { - // Create directory structure - mkdirSync(workingDir, { recursive: true }); - mkdirSync(commsDir, { recursive: true }); - mkdirSync(join(commsDir, 'messages'), { recursive: true }); - mkdirSync(join(commsDir, 'artifacts'), { recursive: true }); - mkdirSync(join(workingDir, 'workspace'), { recursive: true }); - - // Copy task.md to comms - writeFileSync(join(commsDir, 'task.md'), readFileSync(task.sourceFile, 'utf-8'), 'utf-8'); - - // Write initial progress.json - writeFileSync( - join(commsDir, 'progress.json'), - JSON.stringify(createEmptyAgentProgress(), null, 2), - 'utf-8' - ); - - // Generate and write CLAUDE.md - const claudeMd = generateAgentClaudeMd(task, commsDir, workingDir); - writeFileSync(join(workingDir, 'CLAUDE.md'), claudeMd, 'utf-8'); - - // Symlink context files into workspace - if (task.spec.contextFiles && task.spec.contextFiles.length > 0) { - const parentWorkingDir = this.resolveParentWorkingDir(task); - let fileCount = 0; - - for (const contextFile of task.spec.contextFiles) { - if (fileCount >= MAX_CONTEXT_FILES) break; - - const sourcePath = isAbsolute(contextFile) - ? contextFile - : join(parentWorkingDir, contextFile); - - if (!existsSync(sourcePath)) continue; - - const stat = statSync(sourcePath); - if (stat.size > MAX_CONTEXT_FILE_SIZE) continue; - - const destPath = join(workingDir, 'workspace', contextFile.split('/').pop() || contextFile); - try { - symlinkSync(sourcePath, destPath); - fileCount++; - } catch { - // Ignore symlink errors (e.g., dest already exists) - } - } - } - } - - private resolveParentWorkingDir(task: SpawnTask): string { - // If the task has a specified workingDir, resolve it - if (task.spec.workingDir) { - return isAbsolute(task.spec.workingDir) - ? task.spec.workingDir - : resolve(this._config.casesDir, task.spec.workingDir); - } - // Default: use casesDir - return this._config.casesDir; - } - - private setupCompletionListener(agent: AgentContext): void { - if (!this._sessionCreator || !agent.sessionId) return; - - const handler = (phrase: string) => { - if (phrase === agent.task.spec.completionPhrase) { - this.handleAgentCompletion(agent); - } - }; - - this._completionHandlers.set(agent.task.spec.agentId, handler); - this._sessionCreator.onSessionCompletion(agent.sessionId, handler); - } - - private setupProgressMonitor(agent: AgentContext): void { - if (this._config.progressPollIntervalMs <= 0) return; - - agent.progressTimer = setInterval(() => { - if (agent.status !== 'running') return; - - // Read progress - const progress = this.readAgentProgress(agent.task.spec.agentId); - if (progress) { - this.emit('progress', { agentId: agent.task.spec.agentId, progress }); - } - - // Check resource budgets - this.checkResourceBudgets(agent); - }, this._config.progressPollIntervalMs); - } - - private setupTimeout(agent: AgentContext): void { - const timeoutMs = agent.task.spec.timeoutMinutes * 60 * 1000; - - // Warning at 90% - store timer for cleanup - const warningMs = timeoutMs * TIMEOUT_WARNING_RATIO; - agent.warningTimer = setTimeout(() => { - if (agent.status === 'running' && this._sessionCreator && agent.sessionId) { - this._sessionCreator.writeToSession( - agent.sessionId, - 'WARNING: You have less than 10% of your timeout remaining. Please wrap up and write your result.md soon.\r' - ); - } - }, warningMs); - - // Hard timeout - agent.timeoutTimer = setTimeout(() => { - if (agent.status === 'running') { - this.handleAgentTimeout(agent); - } - }, timeoutMs); - } - - private checkResourceBudgets(agent: AgentContext): void { - if (!this._sessionCreator || !agent.sessionId) return; - - // Token budget - if (agent.tokenBudget !== null) { - const tokensUsed = this._sessionCreator.getSessionTokens(agent.sessionId); - const ratio = tokensUsed / agent.tokenBudget; - - if (ratio >= BUDGET_HARD_LIMIT_RATIO) { - // Force kill at 110% - this.handleAgentTimeout(agent); - return; - } else if (ratio >= BUDGET_SOFT_LIMIT_RATIO) { - // Graceful shutdown - this._sessionCreator.writeToSession( - agent.sessionId, - 'You have exceeded your token budget. Write your result.md NOW and output your completion phrase.\r' - ); - } else if (ratio >= BUDGET_WARNING_THRESHOLD) { - this.emit('budgetWarning', { - agentId: agent.task.spec.agentId, - type: 'tokens', - used: tokensUsed, - limit: agent.tokenBudget, - }); - } - } - - // Cost budget - if (agent.costBudget !== null) { - const costUsed = this._sessionCreator.getSessionCost(agent.sessionId); - const ratio = costUsed / agent.costBudget; - - if (ratio >= BUDGET_HARD_LIMIT_RATIO) { - this.handleAgentTimeout(agent); - return; - } else if (ratio >= BUDGET_SOFT_LIMIT_RATIO) { - this._sessionCreator.writeToSession( - agent.sessionId, - 'You have exceeded your cost budget. Write your result.md NOW and output your completion phrase.\r' - ); - } else if (ratio >= BUDGET_WARNING_THRESHOLD) { - this.emit('budgetWarning', { - agentId: agent.task.spec.agentId, - type: 'cost', - used: costUsed, - limit: agent.costBudget, - }); - } - } - } - - private async handleAgentCompletion(agent: AgentContext): Promise { - if (agent.status !== 'running') return; - - agent.status = 'completing'; - this._totalCompleted++; - - // Read result - const result = this.readAgentResult(agent.task.spec.agentId); - if (result) { - // Update token/cost from session - if (this._sessionCreator && agent.sessionId) { - result.tokens.total = this._sessionCreator.getSessionTokens(agent.sessionId); - result.cost = this._sessionCreator.getSessionCost(agent.sessionId); - } - this.emit('completed', { agentId: agent.task.spec.agentId, result }); - } else { - // No result file found, create a minimal one - const minimalResult: SpawnResult = { - status: 'completed', - durationMs: agent.startedAt ? Date.now() - agent.startedAt : 0, - tokens: { input: 0, output: 0, total: 0 }, - cost: 0, - summary: 'Agent completed but no result.md was found', - output: '', - filesChanged: [], - agentId: agent.task.spec.agentId, - completedAt: Date.now(), - }; - this.emit('completed', { agentId: agent.task.spec.agentId, result: minimalResult }); - } - - agent.status = 'completed'; - await this.cleanupAgent(agent.task.spec.agentId); - this.processQueue(); - } - - private async handleAgentTimeout(agent: AgentContext): Promise { - if (agent.status !== 'running') return; - - agent.status = 'timeout'; - this._totalFailed++; - - const elapsed = agent.startedAt ? Date.now() - agent.startedAt : 0; - const limit = agent.task.spec.timeoutMinutes * 60 * 1000; - - this.emit('timeout', { agentId: agent.task.spec.agentId, elapsed, limit }); - await this.cleanupAgent(agent.task.spec.agentId); - this.processQueue(); - } - - private async cleanupAgent(agentId: string): Promise { - const agent = this._agents.get(agentId); - if (!agent) return; - - // Clear timers - if (agent.timeoutTimer) { - clearTimeout(agent.timeoutTimer); - agent.timeoutTimer = null; - } - if (agent.warningTimer) { - clearTimeout(agent.warningTimer); - agent.warningTimer = null; - } - if (agent.progressTimer) { - clearInterval(agent.progressTimer); - agent.progressTimer = null; - } - - // Remove completion handler - const handler = this._completionHandlers.get(agentId); - if (handler && this._sessionCreator && agent.sessionId) { - this._sessionCreator.removeSessionCompletionHandler(agent.sessionId, handler); - this._completionHandlers.delete(agentId); - } - - // Stop session - if (agent.sessionId && this._sessionCreator) { - try { - await this._sessionCreator.stopSession(agent.sessionId); - } catch (err) { - console.warn(`[spawn-orchestrator] Failed to stop session for agent ${agentId}: ${getErrorMessage(err)}`); - } - } - - // Move to completed (LRU) - this._agents.delete(agentId); - this._completedAgents.set(agentId, agent); - - // LRU eviction for completed agents - if (this._completedAgents.size > MAX_TRACKED_AGENTS) { - const firstKey = this._completedAgents.keys().next().value; - if (firstKey) this._completedAgents.delete(firstKey); - } - - this.emitStateUpdate(); - } - - private processQueue(): void { - while (this._queue.length > 0 && this.getActiveCount() < this._config.maxConcurrentAgents) { - const task = this._queue.shift(); - if (!task) break; - - // Re-check dependencies - if (task.spec.dependsOn && task.spec.dependsOn.length > 0) { - const unmetDeps = task.spec.dependsOn.filter(depId => { - const dep = this._completedAgents.get(depId); - return !dep || dep.status !== 'completed'; - }); - if (unmetDeps.length > 0) { - // Put back in queue - this._queue.unshift(task); - break; - } - } - - // Spawn (async, don't await to allow multiple spawns) - this.spawnAgent(task).catch(err => { - console.error(`[spawn-orchestrator] Failed to spawn queued agent: ${getErrorMessage(err)}`); - }); - } - } - - private buildStatusReport(agent: AgentContext): AgentStatusReport { - const now = Date.now(); - const elapsed = agent.startedAt ? now - agent.startedAt : 0; - const timeoutMs = agent.task.spec.timeoutMinutes * 60 * 1000; - const timeRemaining = agent.startedAt ? Math.max(0, timeoutMs - elapsed) : timeoutMs; - - let tokensUsed = 0; - let costSoFar = 0; - if (agent.sessionId && this._sessionCreator) { - tokensUsed = this._sessionCreator.getSessionTokens(agent.sessionId); - costSoFar = this._sessionCreator.getSessionCost(agent.sessionId); - } - - // Check dependency status - let dependencyStatus: 'waiting' | 'ready' | 'n/a' = 'n/a'; - if (agent.task.spec.dependsOn && agent.task.spec.dependsOn.length > 0) { - const allMet = agent.task.spec.dependsOn.every(depId => { - const dep = this._completedAgents.get(depId); - return dep && dep.status === 'completed'; - }); - dependencyStatus = allMet ? 'ready' : 'waiting'; - } - - return { - agentId: agent.task.spec.agentId, - name: agent.task.spec.name, - type: agent.task.spec.type, - status: agent.status, - priority: agent.task.spec.priority, - parentSessionId: agent.parentSessionId, - childSessionId: agent.sessionId, - depth: agent.depth, - startedAt: agent.startedAt, - elapsedMs: elapsed, - progress: this.readAgentProgress(agent.task.spec.agentId), - tokensUsed, - costSoFar, - tokenBudget: agent.tokenBudget, - costBudget: agent.costBudget, - timeoutMinutes: agent.task.spec.timeoutMinutes, - timeRemainingMs: timeRemaining, - completionPhrase: agent.task.spec.completionPhrase, - dependsOn: agent.task.spec.dependsOn || [], - dependencyStatus, - }; - } - - private emitStateUpdate(): void { - this.emit('stateUpdate', this.getState()); - } -} diff --git a/src/types.ts b/src/types.ts index d5430f68..2bcb9609 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1255,24 +1255,6 @@ export interface ImageDetectedEvent { size: number; } -// ========== Spawn1337 Protocol Re-exports ========== - -export type { - SpawnPriority, - SpawnResultDelivery, - SpawnStatus, - SpawnTaskSpec, - SpawnTask, - AgentProgress, - SpawnResult, - SpawnMessage, - AgentStatusReport, - SpawnTrackerState, - SpawnOrchestratorConfig, - AgentContext, - SpawnPersistedState, -} from './spawn-types.js'; - // ========== Execution Bridge Re-exports ========== export type { diff --git a/src/web/public/app.js b/src/web/public/app.js index 5f5c98cb..4abebe5d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -466,7 +466,7 @@ class ClaudemanApp { this._subagentHideTimeout = null; // Timeout for hover-based dropdown hide this.ralphStatePanelCollapsed = true; // Default to collapsed - // Plan subagent windows (visible Opus agents during plan generation) + // Plan subagent windows (visible agents during plan generation) this.planSubagents = new Map(); // Map this.planSubagentWindowZIndex = 1100; this.planGenerationStopped = false; // Flag to ignore SSE events after Stop @@ -641,7 +641,7 @@ class ClaudemanApp { fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', fontSize: 14, lineHeight: 1.2, - cursorBlink: true, + cursorBlink: false, cursorStyle: 'block', scrollback: scrollback, allowTransparency: true, @@ -1146,6 +1146,10 @@ class ClaudemanApp { // This connects subagents that were waiting for the session to identify itself if (claudeSessionIdJustSet) { this.recheckOrphanSubagents(); + // Update connection lines after DOM settles (ensure tabs are rendered) + requestAnimationFrame(() => { + this.updateConnectionLines(); + }); } }); @@ -1690,55 +1694,6 @@ class ClaudemanApp { this.handleBashToolsUpdate(data.sessionId, data.tools); }); - // Spawn agent notification events - addListener('spawn:failed', (e) => { - const data = JSON.parse(e.data); - this.notificationManager?.notify({ - urgency: 'critical', - category: 'spawn-failed', - sessionId: data.sessionId, - sessionName: data.agentId || 'agent', - title: 'Agent Failed', - message: `Agent "${data.agentId}" failed: ${data.reason || 'unknown'}`, - }); - }); - - addListener('spawn:timeout', (e) => { - const data = JSON.parse(e.data); - this.notificationManager?.notify({ - urgency: 'critical', - category: 'spawn-timeout', - sessionId: data.sessionId, - sessionName: data.agentId || 'agent', - title: 'Agent Timeout', - message: `Agent "${data.agentId}" exceeded time limit`, - }); - }); - - addListener('spawn:budgetWarning', (e) => { - const data = JSON.parse(e.data); - this.notificationManager?.notify({ - urgency: 'warning', - category: 'spawn-budget', - sessionId: data.sessionId, - sessionName: data.agentId || 'agent', - title: 'Budget Warning', - message: `Agent "${data.agentId}" at ${data.percent || 80}% budget`, - }); - }); - - addListener('spawn:completed', (e) => { - const data = JSON.parse(e.data); - this.notificationManager?.notify({ - urgency: 'info', - category: 'spawn-completed', - sessionId: data.sessionId, - sessionName: data.agentId || 'agent', - title: 'Agent Complete', - message: `Agent "${data.agentId}" finished successfully`, - }); - }); - // Hook events (from Claude Code hooks system) // Use pendingHooks state machine to track hook events and derive tab alerts. // This ensures alerts persist even when session:working events fire. @@ -1832,6 +1787,11 @@ class ClaudemanApp { if (data.status === 'active') { this.openSubagentWindow(data.agentId); } + + // Ensure connection lines are updated after window is created and DOM settles + requestAnimationFrame(() => { + this.updateConnectionLines(); + }); }); addListener('subagent:updated', (e) => { @@ -7658,6 +7618,11 @@ class ClaudemanApp { this.renderSessionTabs(); // Update tab badges this.saveSubagentWindowStates(); // Persist corrected mappings + + // Update connection lines after all windows are restored (use rAF to ensure DOM is ready) + requestAnimationFrame(() => { + this.updateConnectionLines(); + }); } // ========== Help Modal ========== @@ -9425,11 +9390,20 @@ class ClaudemanApp { * Called when session:updated fires, in case claudeSessionId was just set. */ recheckOrphanSubagents() { + let anyFound = false; for (const [agentId, agent] of this.subagents) { if (!agent.parentSessionId && agent.sessionId) { + const hadParent = agent.parentSessionId; this.findParentSessionForSubagent(agentId); + if (!hadParent && this.subagents.get(agentId)?.parentSessionId) { + anyFound = true; + } } } + // Ensure connection lines are updated after all orphans are processed + if (anyFound) { + this.updateConnectionLines(); + } } /** diff --git a/src/web/server.ts b/src/web/server.ts index 83a8afd2..433554c4 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -22,8 +22,6 @@ import { EventEmitter } from 'node:events'; import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem, type ActiveBashTool } from '../session.js'; import { fileStreamManager } from '../file-stream-manager.js'; import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js'; -import { SpawnOrchestrator, type SessionCreator } from '../spawn-orchestrator.js'; -import type { SpawnOrchestratorConfig } from '../spawn-types.js'; import { ScreenManager } from '../screen-manager.js'; import { getStore } from '../state-store.js'; import { generateClaudeMd } from '../templates/claude-md.js'; @@ -307,8 +305,6 @@ export class WebServer extends EventEmitter { private sseHealthCheckTimer: NodeJS.Timeout | null = null; // Flag to prevent new timers during shutdown private _isStopping: boolean = false; - // Spawn1337 agent orchestrator - private spawnOrchestrator: SpawnOrchestrator; // Token recording for daily stats (track what's been recorded to avoid double-counting) private lastRecordedTokens: Map = new Map(); private tokenRecordingTimer: NodeJS.Timeout | null = null; @@ -350,10 +346,6 @@ export class WebServer extends EventEmitter { this.broadcast('screen:statsUpdated', screens); }); - // Initialize spawn orchestrator - this.spawnOrchestrator = new SpawnOrchestrator(); - this.setupSpawnOrchestratorListeners(); - // Initialize execution bridge with model config from settings this.executionBridge = getExecutionBridge(this.loadModelConfig()); this.setupExecutionBridgeListeners(); @@ -510,7 +502,6 @@ export class WebServer extends EventEmitter { // Returns comprehensive memory metrics for debugging memory leaks this.app.get('/api/debug/memory', async () => { const mem = process.memoryUsage(); - const spawnState = this.spawnOrchestrator.getState(); const subagentStats = subagentWatcher.getStats(); // Calculate total Map entries for memory estimation @@ -569,13 +560,6 @@ export class WebServer extends EventEmitter { subagentIdleTimers: subagentStats.idleTimerCount, total: this.respawnTimers.size + this.pendingRespawnStarts.size + subagentStats.idleTimerCount, }, - spawn: { - activeAgents: spawnState.activeCount, - queuedAgents: spawnState.queuedCount, - totalSpawned: spawnState.totalSpawned, - totalCompleted: spawnState.totalCompleted, - totalFailed: spawnState.totalFailed, - }, uptime: { seconds: Math.round(process.uptime()), formatted: formatUptime(process.uptime()), @@ -1987,9 +1971,6 @@ export class WebServer extends EventEmitter { const claudeMd = generateClaudeMd(name, description || '', templatePath); writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); - // Write .mcp.json for Claude Code to discover spawn tools - this.writeMcpConfig(casePath); - // Write .claude/settings.local.json with hooks for desktop notifications writeHooksConfig(casePath); @@ -2236,9 +2217,6 @@ export class WebServer extends EventEmitter { const claudeMd = generateClaudeMd(caseName, '', templatePath); writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); - // Write .mcp.json for Claude Code to discover spawn tools - this.writeMcpConfig(casePath); - // Write .claude/settings.local.json with hooks for desktop notifications writeHooksConfig(casePath); @@ -3019,93 +2997,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return this.getSystemStats(); }); - // ========== Spawn1337 Agent Protocol Endpoints ========== - - this.app.get('/api/spawn/agents', async () => { - return { success: true, data: this.spawnOrchestrator.getAllAgentStatuses() }; - }); - - this.app.get('/api/spawn/agents/:agentId', async (req) => { - const { agentId } = req.params as { agentId: string }; - const status = this.spawnOrchestrator.getAgentStatus(agentId); - if (!status) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, `Agent ${agentId} not found`); - } - return { success: true, data: status }; - }); - - this.app.get('/api/spawn/agents/:agentId/result', async (req) => { - const { agentId } = req.params as { agentId: string }; - const result = this.spawnOrchestrator.readAgentResult(agentId); - if (!result) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, `No result found for agent ${agentId}`); - } - return { success: true, data: result }; - }); - - this.app.get('/api/spawn/agents/:agentId/progress', async (req) => { - const { agentId } = req.params as { agentId: string }; - const progress = this.spawnOrchestrator.readAgentProgress(agentId); - return { success: true, data: progress }; - }); - - this.app.get('/api/spawn/agents/:agentId/messages', async (req) => { - const { agentId } = req.params as { agentId: string }; - const messages = this.spawnOrchestrator.readAgentMessages(agentId); - return { success: true, data: messages }; - }); - - this.app.post('/api/spawn/agents/:agentId/message', async (req) => { - const { agentId } = req.params as { agentId: string }; - const { content } = req.body as { content: string }; - if (!content) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Message content is required'); - } - await this.spawnOrchestrator.sendMessageToAgent(agentId, content); - return { success: true }; - }); - - this.app.post('/api/spawn/agents/:agentId/cancel', async (req) => { - const { agentId } = req.params as { agentId: string }; - const { reason } = (req.body as { reason?: string }) || {}; - await this.spawnOrchestrator.cancelAgent(agentId, reason || 'Cancelled via API'); - return { success: true }; - }); - - this.app.delete('/api/spawn/agents/:agentId', async (req) => { - const { agentId } = req.params as { agentId: string }; - await this.spawnOrchestrator.cancelAgent(agentId, 'Force killed via API'); - return { success: true }; - }); - - this.app.get('/api/spawn/status', async () => { - return { success: true, data: this.spawnOrchestrator.getState() }; - }); - - this.app.put('/api/spawn/config', async (req) => { - const config = req.body as Partial; - this.spawnOrchestrator.updateConfig(config); - return { success: true, data: this.spawnOrchestrator.config }; - }); - - this.app.post('/api/spawn/trigger', async (req) => { - const { taskContent, parentSessionId, parentWorkingDir } = req.body as { - taskContent: string; - parentSessionId: string; - parentWorkingDir?: string; - }; - if (!taskContent || !parentSessionId) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'taskContent and parentSessionId are required'); - } - const session = this.sessions.get(parentSessionId); - const workingDir = parentWorkingDir || session?.workingDir || process.cwd(); - const agentId = await this.spawnOrchestrator.triggerSpawn(taskContent, parentSessionId, workingDir); - if (!agentId) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse task spec'); - } - return { success: true, data: { agentId } }; - }); - // ========== Execution Bridge Endpoints ========== // Get execution status @@ -3971,83 +3862,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }); } - private setupSpawnOrchestratorListeners(): void { - const sessionCreator: SessionCreator = { - createAgentSession: async (workingDir: string, name: string) => { - const globalNice = this.getGlobalNiceConfig(); - const session = new Session({ - workingDir, - screenManager: this.screenManager, - useScreen: true, - mode: 'claude', - name: `spawn:${name}`, - niceConfig: globalNice, - }); - - this.sessions.set(session.id, session); - this.store.incrementSessionsCreated(); - this.setupSessionListeners(session); - session.parentAgentId = name; - - await session.startInteractive(); - this.broadcast('session:created', session.toDetailedState()); - this.broadcast('session:interactive', { id: session.id }); - this.persistSessionState(session); - - // Configure ralph tracker for completion detection - session.ralphTracker.enable(); - - return { sessionId: session.id }; - }, - writeToSession: (sessionId: string, data: string) => { - const session = this.sessions.get(sessionId); - if (session) { - session.writeViaScreen(data); - } - }, - getSessionTokens: (sessionId: string) => { - const session = this.sessions.get(sessionId); - return session ? session.totalTokens : 0; - }, - getSessionCost: (sessionId: string) => { - const session = this.sessions.get(sessionId); - return session ? session.totalCost : 0; - }, - stopSession: async (sessionId: string) => { - // Use cleanupSession to properly clean up all resources (respawn controllers, - // run summary trackers, file streams, Ralph state, etc.) - await this.cleanupSession(sessionId); - }, - onSessionCompletion: (sessionId: string, handler: (phrase: string) => void) => { - const session = this.sessions.get(sessionId); - if (session) { - session.on('ralphCompletionDetected', handler); - } - }, - removeSessionCompletionHandler: (sessionId: string, handler: (phrase: string) => void) => { - const session = this.sessions.get(sessionId); - if (session) { - session.off('ralphCompletionDetected', handler); - } - }, - }; - - this.spawnOrchestrator.setSessionCreator(sessionCreator); - - // Forward orchestrator events as SSE broadcasts - this.spawnOrchestrator.on('queued', (data) => this.broadcast('spawn:queued', data)); - this.spawnOrchestrator.on('initializing', (data) => this.broadcast('spawn:initializing', data)); - this.spawnOrchestrator.on('started', (data) => this.broadcast('spawn:started', data)); - this.spawnOrchestrator.on('progress', (data) => this.broadcast('spawn:progress', data)); - this.spawnOrchestrator.on('message', (data) => this.broadcast('spawn:message', data)); - this.spawnOrchestrator.on('completed', (data) => this.broadcast('spawn:completed', data)); - this.spawnOrchestrator.on('failed', (data) => this.broadcast('spawn:failed', data)); - this.spawnOrchestrator.on('timeout', (data) => this.broadcast('spawn:timeout', data)); - this.spawnOrchestrator.on('cancelled', (data) => this.broadcast('spawn:cancelled', data)); - this.spawnOrchestrator.on('budgetWarning', (data) => this.broadcast('spawn:budgetWarning', data)); - this.spawnOrchestrator.on('stateUpdate', (data) => this.broadcast('spawn:stateUpdate', data)); - } - /** * Load model configuration from settings file. */ @@ -4373,23 +4187,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return undefined; } - /** - * Write .mcp.json to a case directory for Claude Code to discover spawn MCP tools. - */ - private writeMcpConfig(casePath: string): void { - const projectRoot = join(__dirname, '..', '..'); - const mcpServerPath = join(projectRoot, 'dist', 'mcp-server.js'); - const mcpConfig = { - mcpServers: { - 'claudeman-spawn': { - command: 'node', - args: [mcpServerPath], - }, - }, - }; - writeFileSync(join(casePath, '.mcp.json'), JSON.stringify(mcpConfig, null, 2) + '\n'); - } - private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise { const id = uuidv4(); const now = Date.now(); @@ -5147,10 +4944,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } this.respawnControllers.clear(); - // Stop spawn orchestrator and all agents - await this.spawnOrchestrator.stopAll(); - this.spawnOrchestrator.removeAllListeners(); - // Stop all scheduled runs first (they have their own session cleanup) for (const [id] of this.scheduledRuns) { await this.stopScheduledRun(id); diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts deleted file mode 100644 index e94ba491..00000000 --- a/test/mcp-server.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { buildTaskSpec } from '../src/mcp-server.js'; - -describe('mcp-server', () => { - describe('buildTaskSpec', () => { - it('should build minimal task spec with required fields only', () => { - const result = buildTaskSpec({ - agentId: 'test-001', - name: 'Test Agent', - instructions: 'Do something useful.', - }); - - expect(result).toContain('---'); - expect(result).toContain('agentId: test-001'); - expect(result).toContain('name: Test Agent'); - expect(result).toContain('Do something useful.'); - - // Should not contain optional fields - expect(result).not.toContain('type:'); - expect(result).not.toContain('priority:'); - expect(result).not.toContain('maxTokens:'); - expect(result).not.toContain('maxCost:'); - expect(result).not.toContain('timeoutMinutes:'); - expect(result).not.toContain('contextFiles:'); - expect(result).not.toContain('dependsOn:'); - }); - - it('should include all optional fields when provided', () => { - const result = buildTaskSpec({ - agentId: 'full-agent', - name: 'Full Agent', - instructions: 'Complete task.', - type: 'implement', - priority: 'high', - maxTokens: 200000, - maxCost: 1.50, - timeoutMinutes: 60, - canModifyParentFiles: true, - contextFiles: ['src/auth.ts', 'src/types.ts'], - dependsOn: ['agent-a', 'agent-b'], - completionPhrase: 'FULL_DONE', - outputFormat: 'structured', - successCriteria: 'All tests pass', - workingDir: '/tmp/workspace', - }); - - expect(result).toContain('agentId: full-agent'); - expect(result).toContain('name: Full Agent'); - expect(result).toContain('type: implement'); - expect(result).toContain('priority: high'); - expect(result).toContain('maxTokens: 200000'); - expect(result).toContain('maxCost: 1.5'); - expect(result).toContain('timeoutMinutes: 60'); - expect(result).toContain('canModifyParentFiles: true'); - expect(result).toContain('contextFiles: [src/auth.ts, src/types.ts]'); - expect(result).toContain('dependsOn: [agent-a, agent-b]'); - expect(result).toContain('completionPhrase: FULL_DONE'); - expect(result).toContain('outputFormat: structured'); - expect(result).toContain('successCriteria: "All tests pass"'); - expect(result).toContain('workingDir: /tmp/workspace'); - expect(result).toContain('Complete task.'); - }); - - it('should produce valid YAML frontmatter structure', () => { - const result = buildTaskSpec({ - agentId: 'yaml-test', - name: 'YAML Test', - instructions: 'Body content here.', - }); - - const lines = result.split('\n'); - expect(lines[0]).toBe('---'); - - // Find closing --- - const closingIndex = lines.indexOf('---', 1); - expect(closingIndex).toBeGreaterThan(0); - - // Body should come after closing --- - const body = lines.slice(closingIndex + 1).join('\n').trim(); - expect(body).toBe('Body content here.'); - }); - - it('should escape double quotes in successCriteria', () => { - const result = buildTaskSpec({ - agentId: 'escape-test', - name: 'Escape Test', - instructions: 'test', - successCriteria: 'Output "hello" correctly', - }); - - expect(result).toContain('successCriteria: "Output \\"hello\\" correctly"'); - }); - - it('should handle empty context files array', () => { - const result = buildTaskSpec({ - agentId: 'empty-ctx', - name: 'Empty Context', - instructions: 'test', - contextFiles: [], - }); - - expect(result).not.toContain('contextFiles:'); - }); - - it('should handle empty dependsOn array', () => { - const result = buildTaskSpec({ - agentId: 'empty-deps', - name: 'Empty Deps', - instructions: 'test', - dependsOn: [], - }); - - expect(result).not.toContain('dependsOn:'); - }); - - it('should handle canModifyParentFiles: false', () => { - const result = buildTaskSpec({ - agentId: 'no-modify', - name: 'No Modify', - instructions: 'test', - canModifyParentFiles: false, - }); - - expect(result).toContain('canModifyParentFiles: false'); - }); - - it('should handle multiline instructions', () => { - const instructions = `# Step 1 -Do this first. - -# Step 2 -Then do this. - -## Notes -- Important detail -- Another detail`; - - const result = buildTaskSpec({ - agentId: 'multiline', - name: 'Multiline', - instructions, - }); - - expect(result).toContain('# Step 1'); - expect(result).toContain('Do this first.'); - expect(result).toContain('# Step 2'); - expect(result).toContain('- Important detail'); - }); - - it('should be parseable by parseYamlFrontmatter', async () => { - const { parseYamlFrontmatter } = await import('../src/spawn-types.js'); - - const result = buildTaskSpec({ - agentId: 'parse-test', - name: 'Parse Test', - instructions: 'Verify parsing works.', - type: 'explore', - priority: 'high', - maxTokens: 100000, - timeoutMinutes: 15, - }); - - const parsed = parseYamlFrontmatter(result); - expect(parsed).not.toBeNull(); - expect(parsed!.frontmatter.agentId).toBe('parse-test'); - expect(parsed!.frontmatter.name).toBe('Parse Test'); - expect(parsed!.frontmatter.type).toBe('explore'); - expect(parsed!.frontmatter.priority).toBe('high'); - expect(parsed!.frontmatter.maxTokens).toBe(100000); - expect(parsed!.frontmatter.timeoutMinutes).toBe(15); - expect(parsed!.body).toBe('Verify parsing works.'); - }); - - it('should produce spec parseable by parseTaskSpecFile', async () => { - const { parseTaskSpecFile } = await import('../src/spawn-types.js'); - - const result = buildTaskSpec({ - agentId: 'full-spec', - name: 'Full Spec Agent', - instructions: 'Do the work.', - type: 'implement', - priority: 'critical', - maxTokens: 250000, - maxCost: 2.0, - timeoutMinutes: 45, - canModifyParentFiles: true, - completionPhrase: 'SPEC_DONE', - outputFormat: 'json', - }); - - const parsed = parseTaskSpecFile(result, 'fallback-id'); - expect(parsed).not.toBeNull(); - expect(parsed!.spec.agentId).toBe('full-spec'); - expect(parsed!.spec.name).toBe('Full Spec Agent'); - expect(parsed!.spec.type).toBe('implement'); - expect(parsed!.spec.priority).toBe('critical'); - expect(parsed!.spec.maxTokens).toBe(250000); - expect(parsed!.spec.maxCost).toBe(2.0); - expect(parsed!.spec.timeoutMinutes).toBe(45); - expect(parsed!.spec.canModifyParentFiles).toBe(true); - expect(parsed!.spec.completionPhrase).toBe('SPEC_DONE'); - expect(parsed!.spec.outputFormat).toBe('json'); - expect(parsed!.instructions).toBe('Do the work.'); - }); - - it('should handle contextFiles in parseable format', async () => { - const { parseYamlFrontmatter } = await import('../src/spawn-types.js'); - - const result = buildTaskSpec({ - agentId: 'ctx-parse', - name: 'Context Parse', - instructions: 'test', - contextFiles: ['src/foo.ts', 'src/bar.ts'], - }); - - const parsed = parseYamlFrontmatter(result); - expect(parsed).not.toBeNull(); - expect(Array.isArray(parsed!.frontmatter.contextFiles)).toBe(true); - const files = parsed!.frontmatter.contextFiles as string[]; - expect(files).toContain('src/foo.ts'); - expect(files).toContain('src/bar.ts'); - }); - }); -}); diff --git a/test/spawn-orchestrator.test.ts b/test/spawn-orchestrator.test.ts deleted file mode 100644 index 671d6436..00000000 --- a/test/spawn-orchestrator.test.ts +++ /dev/null @@ -1,968 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { SpawnOrchestrator, type SessionCreator } from '../src/spawn-orchestrator.js'; -import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -/** - * SpawnOrchestrator Tests - * - * Tests the full lifecycle management of spawned agents. - * Uses a temporary directory and mock session creator. - */ - -describe('SpawnOrchestrator', () => { - let orchestrator: SpawnOrchestrator; - let testDir: string; - let mockSessionCreator: SessionCreator; - let completionHandlers: Map void>; - - beforeEach(() => { - testDir = join(tmpdir(), `spawn-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(testDir, { recursive: true }); - - completionHandlers = new Map(); - - mockSessionCreator = { - createAgentSession: vi.fn().mockResolvedValue({ sessionId: `session-${Date.now()}` }), - writeToSession: vi.fn(), - getSessionTokens: vi.fn().mockReturnValue(0), - getSessionCost: vi.fn().mockReturnValue(0), - stopSession: vi.fn().mockResolvedValue(undefined), - onSessionCompletion: vi.fn().mockImplementation((sessionId, handler) => { - completionHandlers.set(sessionId, handler); - }), - removeSessionCompletionHandler: vi.fn().mockImplementation((sessionId) => { - completionHandlers.delete(sessionId); - }), - }; - - orchestrator = new SpawnOrchestrator({ - casesDir: testDir, - maxConcurrentAgents: 3, - maxSpawnDepth: 2, - defaultTimeoutMinutes: 5, - maxTimeoutMinutes: 10, - progressPollIntervalMs: 60000, // Long interval to avoid interference - }); - - orchestrator.setSessionCreator(mockSessionCreator); - }); - - afterEach(() => { - // Stop all agents and clear timers - orchestrator.stopAll().catch(() => {}); - orchestrator.removeAllListeners(); - // Clean up test directory - if (existsSync(testDir)) { - rmSync(testDir, { recursive: true, force: true }); - } - }); - - function createTaskFile(dir: string, filename: string, content: string): string { - const filePath = join(dir, filename); - mkdirSync(dir, { recursive: true }); - writeFileSync(filePath, content); - return filePath; - } - - const basicTaskContent = `--- -agentId: test-agent-001 -name: Test Agent -type: explore -priority: normal -timeoutMinutes: 5 -completionPhrase: TEST_DONE -canModifyParentFiles: false ---- - -# Test Task - -Do a simple test.`; - - describe('Configuration', () => { - it('should use provided config', () => { - expect(orchestrator.config.maxConcurrentAgents).toBe(3); - expect(orchestrator.config.maxSpawnDepth).toBe(2); - }); - - it('should update config', () => { - orchestrator.updateConfig({ maxConcurrentAgents: 10 }); - expect(orchestrator.config.maxConcurrentAgents).toBe(10); - }); - }); - - describe('handleSpawnRequest', () => { - it('should reject when no session creator is set', async () => { - const noCreator = new SpawnOrchestrator({ casesDir: testDir }); - const failHandler = vi.fn(); - noCreator.on('failed', failHandler); - - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - - await noCreator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - // Should not crash, just log error - expect(failHandler).not.toHaveBeenCalled(); // Silent failure with console.error - }); - - it('should fail when task file does not exist', async () => { - const failHandler = vi.fn(); - orchestrator.on('failed', failHandler); - - await orchestrator.handleSpawnRequest('nonexistent.md', 'parent-session', testDir); - - expect(failHandler).toHaveBeenCalledWith( - expect.objectContaining({ error: expect.stringContaining('not found') }) - ); - }); - - it('should fail when task file cannot be parsed', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'bad.md', 'No frontmatter here'); - const failHandler = vi.fn(); - orchestrator.on('failed', failHandler); - - await orchestrator.handleSpawnRequest('bad.md', 'parent-session', parentDir); - - expect(failHandler).toHaveBeenCalledWith( - expect.objectContaining({ error: expect.stringContaining('parse') }) - ); - }); - - it('should reject when max depth exceeded', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - const failHandler = vi.fn(); - orchestrator.on('failed', failHandler); - - // Max depth is 2, so parentDepth=2 means child would be 3 (exceeds) - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir, 2); - - expect(failHandler).toHaveBeenCalledWith( - expect.objectContaining({ error: expect.stringContaining('depth') }) - ); - }); - - it('should spawn agent and create directory structure', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - // Wait for async initialization - await vi.waitFor(() => { - expect(mockSessionCreator.createAgentSession).toHaveBeenCalled(); - }); - - // Check directory was created - const agentDir = join(testDir, 'spawn-test-agent-001'); - expect(existsSync(agentDir)).toBe(true); - expect(existsSync(join(agentDir, 'CLAUDE.md'))).toBe(true); - expect(existsSync(join(agentDir, 'spawn-comms'))).toBe(true); - expect(existsSync(join(agentDir, 'spawn-comms', 'task.md'))).toBe(true); - expect(existsSync(join(agentDir, 'spawn-comms', 'progress.json'))).toBe(true); - expect(existsSync(join(agentDir, 'spawn-comms', 'messages'))).toBe(true); - }); - - it('should generate proper CLAUDE.md for agent', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - expect(mockSessionCreator.createAgentSession).toHaveBeenCalled(); - }); - - const agentDir = join(testDir, 'spawn-test-agent-001'); - const claudeMd = readFileSync(join(agentDir, 'CLAUDE.md'), 'utf-8'); - expect(claudeMd).toContain('Agent: Test Agent'); - expect(claudeMd).toContain('test-agent-001'); - expect(claudeMd).toContain('TEST_DONE'); - expect(claudeMd).toContain('# Test Task'); - }); - - it('should emit initializing and started events', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - - const initHandler = vi.fn(); - const startHandler = vi.fn(); - orchestrator.on('initializing', initHandler); - orchestrator.on('started', startHandler); - - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - expect(startHandler).toHaveBeenCalled(); - }); - - expect(initHandler).toHaveBeenCalledWith( - expect.objectContaining({ agentId: 'test-agent-001', name: 'Test Agent' }) - ); - expect(startHandler).toHaveBeenCalledWith( - expect.objectContaining({ agentId: 'test-agent-001', name: 'Test Agent' }) - ); - }); - - it('should enforce timeout limits', async () => { - const parentDir = join(testDir, 'parent'); - const content = basicTaskContent.replace('timeoutMinutes: 5', 'timeoutMinutes: 999'); - createTaskFile(parentDir, 'task.md', content); - - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - const status = orchestrator.getAgentStatus('test-agent-001'); - expect(status).not.toBeNull(); - expect(status!.timeoutMinutes).toBe(10); // Capped at maxTimeoutMinutes - }); - }); - }); - - describe('Queue Management', () => { - it('should queue agents when concurrency limit reached', async () => { - const parentDir = join(testDir, 'parent'); - - // Create 4 tasks (limit is 3) - for (let i = 1; i <= 4; i++) { - const content = basicTaskContent - .replace('test-agent-001', `agent-${i}`) - .replace('Test Agent', `Agent ${i}`); - createTaskFile(parentDir, `task${i}.md`, content); - } - - const queueHandler = vi.fn(); - orchestrator.on('queued', queueHandler); - - // Spawn 4 agents - for (let i = 1; i <= 4; i++) { - await orchestrator.handleSpawnRequest(`task${i}.md`, 'parent', parentDir); - } - - // Wait for first 3 to start - await vi.waitFor(() => { - expect(mockSessionCreator.createAgentSession).toHaveBeenCalledTimes(3); - }); - - // 4th should be queued - expect(queueHandler).toHaveBeenCalledWith( - expect.objectContaining({ agentId: 'agent-4' }) - ); - }); - - it('should order queue by priority', async () => { - const parentDir = join(testDir, 'parent'); - - // Fill concurrency first - for (let i = 1; i <= 3; i++) { - const content = basicTaskContent - .replace('test-agent-001', `filler-${i}`) - .replace('Test Agent', `Filler ${i}`); - createTaskFile(parentDir, `filler${i}.md`, content); - await orchestrator.handleSpawnRequest(`filler${i}.md`, 'parent', parentDir); - } - - await vi.waitFor(() => { - expect(mockSessionCreator.createAgentSession).toHaveBeenCalledTimes(3); - }); - - // Now add low and high priority - const lowContent = basicTaskContent - .replace('test-agent-001', 'low-agent') - .replace('priority: normal', 'priority: low'); - createTaskFile(parentDir, 'low.md', lowContent); - - const highContent = basicTaskContent - .replace('test-agent-001', 'high-agent') - .replace('priority: normal', 'priority: critical'); - createTaskFile(parentDir, 'high.md', highContent); - - await orchestrator.handleSpawnRequest('low.md', 'parent', parentDir); - await orchestrator.handleSpawnRequest('high.md', 'parent', parentDir); - - // State should show high priority first in queue - const state = orchestrator.getState(); - expect(state.queuedCount).toBe(2); - }); - }); - - describe('cancelAgent', () => { - it('should cancel a running agent', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - expect(orchestrator.getAgentStatus('test-agent-001')).not.toBeNull(); - }); - - const cancelHandler = vi.fn(); - orchestrator.on('cancelled', cancelHandler); - - await orchestrator.cancelAgent('test-agent-001', 'User cancelled'); - - expect(cancelHandler).toHaveBeenCalledWith( - expect.objectContaining({ agentId: 'test-agent-001', reason: 'User cancelled' }) - ); - }); - - it('should cancel a queued agent', async () => { - const parentDir = join(testDir, 'parent'); - - // Fill concurrency - for (let i = 1; i <= 3; i++) { - const content = basicTaskContent - .replace('test-agent-001', `filler-${i}`) - .replace('Test Agent', `Filler ${i}`); - createTaskFile(parentDir, `filler${i}.md`, content); - await orchestrator.handleSpawnRequest(`filler${i}.md`, 'parent', parentDir); - } - - // Add one more (queued) - const queuedContent = basicTaskContent.replace('test-agent-001', 'queued-agent'); - createTaskFile(parentDir, 'queued.md', queuedContent); - await orchestrator.handleSpawnRequest('queued.md', 'parent', parentDir); - - const cancelHandler = vi.fn(); - orchestrator.on('cancelled', cancelHandler); - - await orchestrator.cancelAgent('queued-agent'); - - expect(cancelHandler).toHaveBeenCalledWith( - expect.objectContaining({ agentId: 'queued-agent' }) - ); - }); - }); - - describe('sendMessageToAgent', () => { - it('should write message file to comms directory', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - expect(orchestrator.getAgentStatus('test-agent-001')).not.toBeNull(); - }); - - await orchestrator.sendMessageToAgent('test-agent-001', 'Focus on JWT'); - - const messagesDir = join(testDir, 'spawn-test-agent-001', 'spawn-comms', 'messages'); - expect(existsSync(join(messagesDir, '001-parent.md'))).toBe(true); - - const content = readFileSync(join(messagesDir, '001-parent.md'), 'utf-8'); - expect(content).toBe('Focus on JWT'); - }); - }); - - describe('getAgentStatus', () => { - it('should return null for unknown agent', () => { - expect(orchestrator.getAgentStatus('nonexistent')).toBeNull(); - }); - - it('should return status for active agent', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - const status = orchestrator.getAgentStatus('test-agent-001'); - expect(status).not.toBeNull(); - expect(status!.status).toBe('running'); - expect(status!.name).toBe('Test Agent'); - expect(status!.completionPhrase).toBe('TEST_DONE'); - }); - }); - }); - - describe('getState', () => { - it('should return complete orchestrator state', () => { - const state = orchestrator.getState(); - expect(state.enabled).toBe(true); - expect(state.activeCount).toBe(0); - expect(state.queuedCount).toBe(0); - expect(state.totalSpawned).toBe(0); - expect(state.agents).toEqual([]); - }); - - it('should update counts after spawn', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - const state = orchestrator.getState(); - expect(state.activeCount).toBe(1); - expect(state.totalSpawned).toBe(1); - }); - }); - }); - - describe('readAgentMessages', () => { - it('should return empty for unknown agent', () => { - expect(orchestrator.readAgentMessages('nonexistent')).toEqual([]); - }); - - it('should read messages from comms directory', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - expect(orchestrator.getAgentStatus('test-agent-001')).not.toBeNull(); - }); - - // Write a message - await orchestrator.sendMessageToAgent('test-agent-001', 'Hello agent'); - - const messages = orchestrator.readAgentMessages('test-agent-001'); - expect(messages).toHaveLength(1); - expect(messages[0].sender).toBe('parent'); - expect(messages[0].content).toBe('Hello agent'); - expect(messages[0].sequence).toBe(1); - }); - }); - - describe('triggerSpawn', () => { - it('should spawn from content string', async () => { - const agentId = await orchestrator.triggerSpawn( - basicTaskContent, - 'parent-session', - testDir - ); - - expect(agentId).toBe('test-agent-001'); - - await vi.waitFor(() => { - expect(mockSessionCreator.createAgentSession).toHaveBeenCalled(); - }); - }); - - it('should return null for unparseable content', async () => { - const agentId = await orchestrator.triggerSpawn( - 'Not valid YAML frontmatter', - 'parent-session', - testDir - ); - - expect(agentId).toBeNull(); - }); - }); - - describe('stopAll', () => { - it('should stop all active agents', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - expect(orchestrator.getState().activeCount).toBe(1); - }); - - await orchestrator.stopAll(); - - expect(orchestrator.getState().activeCount).toBe(0); - }); - }); - - describe('getPersistedState', () => { - it('should return serializable state', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - expect(orchestrator.getState().activeCount).toBe(1); - }); - - const persisted = orchestrator.getPersistedState(); - expect(persisted.config).toBeDefined(); - expect(persisted.agents['test-agent-001']).toBeDefined(); - expect(persisted.agents['test-agent-001'].completionPhrase).toBe('TEST_DONE'); - - // Should be JSON-serializable - expect(() => JSON.stringify(persisted)).not.toThrow(); - }); - }); - - // ========== Issue Coverage Tests ========== - - describe('Cascading Cancellation', () => { - it('should cancel child agents when parent is cancelled', async () => { - const parentDir = join(testDir, 'parent'); - - // Create parent agent - const parentContent = basicTaskContent - .replace('test-agent-001', 'parent-agent') - .replace('Test Agent', 'Parent Agent'); - createTaskFile(parentDir, 'parent.md', parentContent); - - await orchestrator.handleSpawnRequest('parent.md', 'user-session', parentDir); - - await vi.waitFor(() => { - expect(orchestrator.getAgentStatus('parent-agent')).not.toBeNull(); - }); - - // Create child agent that depends on parent - const childContent = `--- -agentId: child-agent -name: Child Agent -type: explore -priority: normal -timeoutMinutes: 5 -completionPhrase: CHILD_DONE -canModifyParentFiles: false ---- - -# Child Task - -Child agent work.`; - createTaskFile(parentDir, 'child.md', childContent); - - // Spawn child with parent-agent's session as parent - // Note: In current implementation, we simulate the parent relationship via parentSessionId - await orchestrator.handleSpawnRequest('child.md', 'parent-agent-session', parentDir); - - await vi.waitFor(() => { - expect(orchestrator.getAgentStatus('child-agent')).not.toBeNull(); - }); - - const cancelHandler = vi.fn(); - orchestrator.on('cancelled', cancelHandler); - - // Cancel parent - this SHOULD also cancel child (if cascading is implemented) - await orchestrator.cancelAgent('parent-agent', 'User cancelled parent'); - - // Currently, this test documents the EXPECTED behavior. - // The current implementation does NOT cascade cancellations. - // If cascading is implemented, uncomment the assertion below: - // expect(cancelHandler).toHaveBeenCalledTimes(2); - - // Current behavior: only parent is cancelled - expect(cancelHandler).toHaveBeenCalledWith( - expect.objectContaining({ agentId: 'parent-agent', reason: 'User cancelled parent' }) - ); - - // Verify child is still running (documents current buggy behavior) - const childStatus = orchestrator.getAgentStatus('child-agent'); - // When cascading is fixed, this should be 'cancelled' instead of 'running' - expect(childStatus?.status).toBe('running'); - }); - - it('should handle cancellation when no children exist', async () => { - const parentDir = join(testDir, 'parent'); - createTaskFile(parentDir, 'task.md', basicTaskContent); - - await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - expect(orchestrator.getAgentStatus('test-agent-001')).not.toBeNull(); - }); - - const cancelHandler = vi.fn(); - orchestrator.on('cancelled', cancelHandler); - - // Cancel agent with no children - should work normally - await orchestrator.cancelAgent('test-agent-001', 'Normal cancellation'); - - expect(cancelHandler).toHaveBeenCalledTimes(1); - expect(cancelHandler).toHaveBeenCalledWith( - expect.objectContaining({ agentId: 'test-agent-001', reason: 'Normal cancellation' }) - ); - - // Agent should be cleaned up - expect(orchestrator.getState().activeCount).toBe(0); - }); - - it('should recursively cancel grandchildren when parent is cancelled', async () => { - const parentDir = join(testDir, 'parent-grandchild'); - - // Create orchestrator with higher concurrency and depth for this test - const deepOrchestrator = new SpawnOrchestrator({ - casesDir: testDir, - maxConcurrentAgents: 5, - maxSpawnDepth: 3, - defaultTimeoutMinutes: 5, - maxTimeoutMinutes: 10, - progressPollIntervalMs: 60000, - }); - deepOrchestrator.setSessionCreator(mockSessionCreator); - - // Create grandparent agent - const grandparentContent = basicTaskContent - .replace('test-agent-001', 'grandparent-agent') - .replace('Test Agent', 'Grandparent Agent'); - createTaskFile(parentDir, 'grandparent.md', grandparentContent); - - await deepOrchestrator.handleSpawnRequest('grandparent.md', 'user-session', parentDir); - - await vi.waitFor(() => { - expect(deepOrchestrator.getAgentStatus('grandparent-agent')).not.toBeNull(); - }); - - // Create parent agent (child of grandparent) - const parentContent = basicTaskContent - .replace('test-agent-001', 'parent-agent') - .replace('Test Agent', 'Parent Agent'); - createTaskFile(parentDir, 'parent.md', parentContent); - - await deepOrchestrator.handleSpawnRequest('parent.md', 'grandparent-agent-session', parentDir, 1); - - await vi.waitFor(() => { - expect(deepOrchestrator.getAgentStatus('parent-agent')).not.toBeNull(); - }); - - // Create child agent (grandchild of grandparent) - const childContent = basicTaskContent - .replace('test-agent-001', 'child-agent') - .replace('Test Agent', 'Child Agent'); - createTaskFile(parentDir, 'child.md', childContent); - - await deepOrchestrator.handleSpawnRequest('child.md', 'parent-agent-session', parentDir, 2); - - await vi.waitFor(() => { - expect(deepOrchestrator.getAgentStatus('child-agent')).not.toBeNull(); - }); - - // Verify all three agents are running - expect(deepOrchestrator.getState().activeCount).toBe(3); - - const cancelHandler = vi.fn(); - deepOrchestrator.on('cancelled', cancelHandler); - - // Cancel grandparent - this SHOULD cascade to parent and child - await deepOrchestrator.cancelAgent('grandparent-agent', 'User cancelled grandparent'); - - // Document current behavior: only grandparent is cancelled (no cascade) - expect(cancelHandler).toHaveBeenCalledWith( - expect.objectContaining({ agentId: 'grandparent-agent' }) - ); - - // Current behavior: parent and child are still running (documents the bug) - const parentStatus = deepOrchestrator.getAgentStatus('parent-agent'); - const childStatus = deepOrchestrator.getAgentStatus('child-agent'); - - // When cascading is implemented: - // expect(parentStatus?.status).toBe('cancelled'); - // expect(childStatus?.status).toBe('cancelled'); - // expect(cancelHandler).toHaveBeenCalledTimes(3); - - // Current buggy behavior: - expect(parentStatus?.status).toBe('running'); - expect(childStatus?.status).toBe('running'); - expect(cancelHandler).toHaveBeenCalledTimes(1); - - // Cleanup - await deepOrchestrator.stopAll(); - deepOrchestrator.removeAllListeners(); - }); - }); - - describe('Resource Budget Validation', () => { - it('should handle negative maxTokens in task spec', async () => { - const parentDir = join(testDir, 'parent'); - - // Task with negative maxTokens - const content = `--- -agentId: negative-tokens-agent -name: Negative Tokens Agent -type: explore -priority: normal -timeoutMinutes: 5 -completionPhrase: NEG_DONE -canModifyParentFiles: false -maxTokens: -1000 ---- - -# Test negative tokens`; - - createTaskFile(parentDir, 'negative.md', content); - - await orchestrator.handleSpawnRequest('negative.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - const status = orchestrator.getAgentStatus('negative-tokens-agent'); - // Current behavior: negative values are accepted (documents the issue) - // When validation is added, this should either fail or clamp to 0/null - expect(status).not.toBeNull(); - }); - }); - - it('should handle zero maxCost in task spec', async () => { - const parentDir = join(testDir, 'parent'); - - // Task with zero maxCost - const content = `--- -agentId: zero-cost-agent -name: Zero Cost Agent -type: explore -priority: normal -timeoutMinutes: 5 -completionPhrase: ZERO_DONE -canModifyParentFiles: false -maxCost: 0 ---- - -# Test zero cost`; - - createTaskFile(parentDir, 'zero.md', content); - - await orchestrator.handleSpawnRequest('zero.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - const status = orchestrator.getAgentStatus('zero-cost-agent'); - expect(status).not.toBeNull(); - // Zero cost budget would immediately trigger 110% threshold check - // on first budget check, causing immediate termination - // This documents potentially problematic behavior - expect(status!.costBudget).toBe(0); - }); - }); - - it('should accept valid budget values', async () => { - const parentDir = join(testDir, 'parent'); - - const content = `--- -agentId: valid-budget-agent -name: Valid Budget Agent -type: explore -priority: normal -timeoutMinutes: 5 -completionPhrase: VALID_DONE -canModifyParentFiles: false -maxTokens: 100000 -maxCost: 1.50 ---- - -# Test valid budget`; - - createTaskFile(parentDir, 'valid.md', content); - - await orchestrator.handleSpawnRequest('valid.md', 'parent-session', parentDir); - - await vi.waitFor(() => { - const status = orchestrator.getAgentStatus('valid-budget-agent'); - expect(status).not.toBeNull(); - expect(status!.tokenBudget).toBe(100000); - expect(status!.costBudget).toBe(1.50); - }); - }); - }); - - describe('Queue Dependency Handling', () => { - it('should not block independent tasks when one has unmet deps', async () => { - const parentDir = join(testDir, 'parent'); - - // Fill concurrency first - for (let i = 1; i <= 3; i++) { - const content = basicTaskContent - .replace('test-agent-001', `filler-${i}`) - .replace('Test Agent', `Filler ${i}`); - createTaskFile(parentDir, `filler${i}.md`, content); - await orchestrator.handleSpawnRequest(`filler${i}.md`, 'parent', parentDir); - } - - await vi.waitFor(() => { - expect(mockSessionCreator.createAgentSession).toHaveBeenCalledTimes(3); - }); - - // Add task A that depends on non-existent Task X - const dependentContent = `--- -agentId: dependent-agent -name: Dependent Agent -type: explore -priority: normal -timeoutMinutes: 5 -completionPhrase: DEP_DONE -canModifyParentFiles: false -dependsOn: - - nonexistent-task-x ---- - -# Dependent task`; - - createTaskFile(parentDir, 'dependent.md', dependentContent); - await orchestrator.handleSpawnRequest('dependent.md', 'parent', parentDir); - - // Add Task B with no dependencies - const independentContent = basicTaskContent - .replace('test-agent-001', 'independent-agent') - .replace('Test Agent', 'Independent Agent'); - createTaskFile(parentDir, 'independent.md', independentContent); - await orchestrator.handleSpawnRequest('independent.md', 'parent', parentDir); - - // Both should be queued - expect(orchestrator.getState().queuedCount).toBe(2); - - // Complete one of the filler agents to free up a slot - const completionHandler = completionHandlers.get( - (mockSessionCreator.createAgentSession as ReturnType).mock.results[0].value.sessionId - ); - - // Simulate completion by triggering cleanup directly - await orchestrator.cancelAgent('filler-1', 'Test cleanup'); - - // Wait for queue processing - await vi.waitFor(() => { - // Check if independent-agent started - // Current buggy behavior: dependent-agent blocks the queue - // The test documents this - when fixed, independent-agent should run - const state = orchestrator.getState(); - // With the bug: queuedCount stays at 2 or decreases but independent doesn't start - // When fixed: independent-agent should be running - expect(state.activeCount).toBeGreaterThanOrEqual(2); - }, { timeout: 1000 }).catch(() => { - // Expected to fail with current implementation - documents the bug - const state = orchestrator.getState(); - // Document current behavior: queue might be stuck - console.log('Queue state (documents starvation bug):', { - activeCount: state.activeCount, - queuedCount: state.queuedCount, - }); - }); - }); - }); - - describe('Timer Cleanup', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('should clear timeout timer on agent completion', async () => { - const parentDir = join(testDir, 'parent-timer1'); - mkdirSync(parentDir, { recursive: true }); - - // Use short timeout for testing - const content = basicTaskContent.replace('timeoutMinutes: 5', 'timeoutMinutes: 1'); - createTaskFile(parentDir, 'task.md', content); - - // Create a new orchestrator for this test to avoid timer conflicts - const timerOrchestrator = new SpawnOrchestrator({ - casesDir: testDir, - maxConcurrentAgents: 3, - maxSpawnDepth: 2, - defaultTimeoutMinutes: 5, - maxTimeoutMinutes: 10, - progressPollIntervalMs: 60000, - }); - timerOrchestrator.setSessionCreator(mockSessionCreator); - - // Start the spawn request (this sets up timers) - const spawnPromise = timerOrchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - - // Run pending timers and promises - await vi.runAllTimersAsync(); - await spawnPromise; - - // Track that timeout event does NOT fire after cancellation - const timeoutHandler = vi.fn(); - timerOrchestrator.on('timeout', timeoutHandler); - - // Cancel the agent (which triggers cleanup) - await timerOrchestrator.cancelAgent('test-agent-001', 'Test cleanup'); - - // Advance timers past the timeout period - await vi.advanceTimersByTimeAsync(2 * 60 * 1000); // 2 minutes - - // Timeout should NOT have fired because timer was cleared - expect(timeoutHandler).not.toHaveBeenCalled(); - - timerOrchestrator.removeAllListeners(); - }); - - it('should clear progress timer on cancellation', async () => { - const parentDir = join(testDir, 'parent-timer2'); - mkdirSync(parentDir, { recursive: true }); - - // Create orchestrator with fast progress polling - const fastPollOrchestrator = new SpawnOrchestrator({ - casesDir: testDir, - maxConcurrentAgents: 3, - maxSpawnDepth: 2, - defaultTimeoutMinutes: 5, - maxTimeoutMinutes: 10, - progressPollIntervalMs: 100, // Fast polling - }); - fastPollOrchestrator.setSessionCreator(mockSessionCreator); - - createTaskFile(parentDir, 'task.md', basicTaskContent); - - const spawnPromise = fastPollOrchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - await vi.runAllTimersAsync(); - await spawnPromise; - - const progressHandler = vi.fn(); - fastPollOrchestrator.on('progress', progressHandler); - - // Cancel the agent - await fastPollOrchestrator.cancelAgent('test-agent-001', 'Test cleanup'); - - // Clear current call count - progressHandler.mockClear(); - - // Advance time past several poll intervals - await vi.advanceTimersByTimeAsync(500); - - // Progress events should NOT fire after cancellation - expect(progressHandler).not.toHaveBeenCalled(); - - // Cleanup - fastPollOrchestrator.removeAllListeners(); - }); - - it('should clear warning timer on early completion', async () => { - const parentDir = join(testDir, 'parent-timer3'); - mkdirSync(parentDir, { recursive: true }); - - // Short timeout so warning would fire at ~54 seconds (90% of 1 min) - const content = basicTaskContent.replace('timeoutMinutes: 5', 'timeoutMinutes: 1'); - createTaskFile(parentDir, 'task.md', content); - - // Create a fresh orchestrator for this test - const warningOrchestrator = new SpawnOrchestrator({ - casesDir: testDir, - maxConcurrentAgents: 3, - maxSpawnDepth: 2, - defaultTimeoutMinutes: 5, - maxTimeoutMinutes: 10, - progressPollIntervalMs: 60000, - }); - warningOrchestrator.setSessionCreator(mockSessionCreator); - - const spawnPromise = warningOrchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); - await vi.runAllTimersAsync(); - await spawnPromise; - - // Cancel before warning would fire - await warningOrchestrator.cancelAgent('test-agent-001', 'Early completion'); - - // Clear the mock - (mockSessionCreator.writeToSession as ReturnType).mockClear(); - - // Advance past warning time (54 seconds) - await vi.advanceTimersByTimeAsync(60 * 1000); - - // Warning message should NOT have been sent - const writeToSessionCalls = (mockSessionCreator.writeToSession as ReturnType).mock.calls; - const warningCalls = writeToSessionCalls.filter( - (call: [string, string]) => call[1]?.includes('WARNING') && call[1]?.includes('timeout') - ); - expect(warningCalls.length).toBe(0); - - warningOrchestrator.removeAllListeners(); - }); - }); -}); diff --git a/test/spawn-types.test.ts b/test/spawn-types.test.ts deleted file mode 100644 index 6c2daf6a..00000000 --- a/test/spawn-types.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - parseYamlFrontmatter, - parseTaskSpecFile, - createDefaultSpawnTaskSpec, - createEmptyAgentProgress, - createInitialSpawnTrackerState, - createDefaultOrchestratorConfig, - serializeSpawnResult, - parseSpawnResult, - AGENT_NAME_MAX_LENGTH, -} from '../src/spawn-types.js'; - -describe('spawn-types', () => { - describe('parseYamlFrontmatter', () => { - it('should parse basic frontmatter', () => { - const content = `--- -name: Test Agent -type: explore -priority: high ---- - -# Task Body - -Do something useful.`; - - const result = parseYamlFrontmatter(content); - expect(result).not.toBeNull(); - expect(result!.frontmatter.name).toBe('Test Agent'); - expect(result!.frontmatter.type).toBe('explore'); - expect(result!.frontmatter.priority).toBe('high'); - expect(result!.body).toContain('# Task Body'); - expect(result!.body).toContain('Do something useful.'); - }); - - it('should parse numbers', () => { - const content = `--- -timeoutMinutes: 30 -maxCost: 0.50 -maxTokens: 150000 ---- -body`; - - const result = parseYamlFrontmatter(content); - expect(result!.frontmatter.timeoutMinutes).toBe(30); - expect(result!.frontmatter.maxCost).toBe(0.5); - expect(result!.frontmatter.maxTokens).toBe(150000); - }); - - it('should parse booleans', () => { - const content = `--- -canModifyParentFiles: true -enabled: false ---- -body`; - - const result = parseYamlFrontmatter(content); - expect(result!.frontmatter.canModifyParentFiles).toBe(true); - expect(result!.frontmatter.enabled).toBe(false); - }); - - it('should parse inline arrays', () => { - const content = `--- -dependsOn: [agent-1, agent-2] ---- -body`; - - const result = parseYamlFrontmatter(content); - expect(result!.frontmatter.dependsOn).toEqual(['agent-1', 'agent-2']); - }); - - it('should parse block arrays', () => { - const content = `--- -contextFiles: - - src/auth.ts - - src/middleware.ts - - src/types.ts ---- -body`; - - const result = parseYamlFrontmatter(content); - expect(result!.frontmatter.contextFiles).toEqual(['src/auth.ts', 'src/middleware.ts', 'src/types.ts']); - }); - - it('should parse empty arrays', () => { - const content = `--- -dependsOn: [] ---- -body`; - - const result = parseYamlFrontmatter(content); - expect(result!.frontmatter.dependsOn).toEqual([]); - }); - - it('should parse quoted strings', () => { - const content = `--- -name: "Test Agent" -completionPhrase: 'AUTH_DONE' ---- -body`; - - const result = parseYamlFrontmatter(content); - expect(result!.frontmatter.name).toBe('Test Agent'); - expect(result!.frontmatter.completionPhrase).toBe('AUTH_DONE'); - }); - - it('should handle null values', () => { - const content = `--- -value1: null -value2: ~ ---- -body`; - - const result = parseYamlFrontmatter(content); - expect(result!.frontmatter.value1).toBeNull(); - expect(result!.frontmatter.value2).toBeNull(); - }); - - it('should handle comments', () => { - const content = `--- -# This is a comment -name: Test -# Another comment -type: explore ---- -body`; - - const result = parseYamlFrontmatter(content); - expect(result!.frontmatter.name).toBe('Test'); - expect(result!.frontmatter.type).toBe('explore'); - }); - - it('should return null if no frontmatter delimiters', () => { - const content = `No frontmatter here\nJust plain text`; - expect(parseYamlFrontmatter(content)).toBeNull(); - }); - - it('should return null if missing closing delimiter', () => { - const content = `---\nname: Test\nNo closing delimiter`; - expect(parseYamlFrontmatter(content)).toBeNull(); - }); - - it('should handle nested objects', () => { - const content = `--- -env: - NODE_ENV: production - DEBUG: true ---- -body`; - - const result = parseYamlFrontmatter(content); - expect(result!.frontmatter.env).toEqual({ NODE_ENV: 'production', DEBUG: true }); - }); - }); - - describe('parseTaskSpecFile', () => { - it('should parse a complete task spec', () => { - const content = `--- -agentId: auth-explorer-001 -name: Authentication Explorer -type: explore -priority: high -canModifyParentFiles: false -maxTokens: 150000 -maxCost: 0.50 -timeoutMinutes: 15 -resultDelivery: both -completionPhrase: AUTH_EXPLORE_DONE -progressIntervalSeconds: 30 -outputFormat: structured -successCriteria: "Document all auth patterns" ---- - -# Task: Explore Authentication - -Analyze the auth system.`; - - const result = parseTaskSpecFile(content, 'fallback-id'); - expect(result).not.toBeNull(); - expect(result!.spec.agentId).toBe('auth-explorer-001'); - expect(result!.spec.name).toBe('Authentication Explorer'); - expect(result!.spec.type).toBe('explore'); - expect(result!.spec.priority).toBe('high'); - expect(result!.spec.canModifyParentFiles).toBe(false); - expect(result!.spec.maxTokens).toBe(150000); - expect(result!.spec.maxCost).toBe(0.5); - expect(result!.spec.timeoutMinutes).toBe(15); - expect(result!.spec.completionPhrase).toBe('AUTH_EXPLORE_DONE'); - expect(result!.spec.outputFormat).toBe('structured'); - expect(result!.instructions).toContain('# Task: Explore Authentication'); - }); - - it('should use defaults for missing fields', () => { - const content = `--- -name: Simple Agent ---- -Do something.`; - - const result = parseTaskSpecFile(content, 'my-fallback'); - expect(result).not.toBeNull(); - expect(result!.spec.agentId).toBe('my-fallback'); - expect(result!.spec.type).toBe('general'); - expect(result!.spec.priority).toBe('normal'); - expect(result!.spec.timeoutMinutes).toBe(30); - expect(result!.spec.resultDelivery).toBe('both'); - expect(result!.spec.outputFormat).toBe('markdown'); - expect(result!.spec.canModifyParentFiles).toBe(false); - }); - - it('should truncate long names', () => { - const longName = 'A'.repeat(100); - const content = `--- -name: ${longName} ---- -body`; - - const result = parseTaskSpecFile(content, 'id'); - expect(result!.spec.name.length).toBe(AGENT_NAME_MAX_LENGTH); - }); - - it('should validate type values', () => { - const content = `--- -type: invalid_type ---- -body`; - - const result = parseTaskSpecFile(content, 'id'); - expect(result!.spec.type).toBe('general'); // Falls back to default - }); - - it('should validate priority values', () => { - const content = `--- -priority: super_high ---- -body`; - - const result = parseTaskSpecFile(content, 'id'); - expect(result!.spec.priority).toBe('normal'); // Falls back to default - }); - - it('should return null for content without frontmatter', () => { - const content = 'No frontmatter at all'; - expect(parseTaskSpecFile(content, 'id')).toBeNull(); - }); - - it('should parse contextFiles array', () => { - const content = `--- -contextFiles: - - src/auth.ts - - src/types.ts ---- -body`; - - const result = parseTaskSpecFile(content, 'id'); - expect(result!.spec.contextFiles).toEqual(['src/auth.ts', 'src/types.ts']); - }); - - it('should parse dependsOn array', () => { - const content = `--- -dependsOn: - - agent-1 - - agent-2 ---- -body`; - - const result = parseTaskSpecFile(content, 'id'); - expect(result!.spec.dependsOn).toEqual(['agent-1', 'agent-2']); - }); - }); - - describe('Factory Functions', () => { - it('createDefaultSpawnTaskSpec should generate valid defaults', () => { - const spec = createDefaultSpawnTaskSpec('my-agent'); - expect(spec.agentId).toBe('my-agent'); - expect(spec.name).toBe('my-agent'); - expect(spec.type).toBe('general'); - expect(spec.priority).toBe('normal'); - expect(spec.timeoutMinutes).toBe(30); - expect(spec.completionPhrase).toContain('MY_AGENT'); - expect(spec.completionPhrase).toContain('DONE'); - }); - - it('createDefaultSpawnTaskSpec should sanitize agent ID for completion phrase', () => { - const spec = createDefaultSpawnTaskSpec('my-agent-123'); - expect(spec.completionPhrase).toBe('AGENT_MY_AGENT_123_DONE'); - }); - - it('createEmptyAgentProgress should create valid progress', () => { - const progress = createEmptyAgentProgress(); - expect(progress.phase).toBe('initializing'); - expect(progress.percentComplete).toBe(0); - expect(progress.filesModified).toEqual([]); - expect(progress.tokensUsed).toBe(0); - }); - - it('createInitialSpawnTrackerState should create valid state', () => { - const state = createInitialSpawnTrackerState(); - expect(state.enabled).toBe(false); - expect(state.activeCount).toBe(0); - expect(state.agents).toEqual([]); - }); - - it('createDefaultOrchestratorConfig should create valid config', () => { - const config = createDefaultOrchestratorConfig(); - expect(config.maxConcurrentAgents).toBe(5); - expect(config.maxSpawnDepth).toBe(3); - expect(config.defaultTimeoutMinutes).toBe(30); - expect(config.maxTimeoutMinutes).toBe(120); - expect(config.progressPollIntervalMs).toBe(5000); - }); - }); - - describe('serializeSpawnResult', () => { - it('should serialize a completed result', () => { - const result = { - status: 'completed' as const, - durationMs: 60000, - tokens: { input: 1000, output: 500, total: 1500 }, - cost: 0.05, - summary: 'Task completed successfully', - output: '## Result\n\nDetailed output here.', - filesChanged: [ - { path: 'src/auth.ts', action: 'modified' as const, summary: 'Added validation' }, - ], - agentId: 'test-agent', - completedAt: 1700000000000, - }; - - const serialized = serializeSpawnResult(result); - expect(serialized).toContain('status: completed'); - expect(serialized).toContain('summary: "Task completed successfully"'); - expect(serialized).toContain('agentId: test-agent'); - expect(serialized).toContain('path: src/auth.ts'); - expect(serialized).toContain('## Result'); - }); - - it('should handle empty filesChanged', () => { - const result = { - status: 'failed' as const, - error: 'Something went wrong', - durationMs: 5000, - tokens: { input: 100, output: 50, total: 150 }, - cost: 0.01, - summary: 'Failed', - output: 'Error details', - filesChanged: [], - agentId: 'test', - completedAt: Date.now(), - }; - - const serialized = serializeSpawnResult(result); - expect(serialized).toContain('status: failed'); - expect(serialized).toContain('filesChanged: []'); - }); - }); - - describe('parseSpawnResult', () => { - it('should parse a result file', () => { - const content = `--- -status: completed -summary: "Found 3 auth patterns" -cost: 0.25 ---- - -## Analysis - -Detailed findings here.`; - - const result = parseSpawnResult(content, 'agent-001', 60000); - expect(result).not.toBeNull(); - expect(result!.status).toBe('completed'); - expect(result!.summary).toBe('Found 3 auth patterns'); - expect(result!.cost).toBe(0.25); - expect(result!.output).toContain('## Analysis'); - expect(result!.agentId).toBe('agent-001'); - }); - - it('should handle missing fields with defaults', () => { - const content = `--- -status: completed ---- -output`; - - const result = parseSpawnResult(content, 'agent', 30000); - expect(result!.durationMs).toBe(30000); - expect(result!.cost).toBe(0); - expect(result!.summary).toBe('No summary provided'); - expect(result!.filesChanged).toEqual([]); - }); - - it('should return null for invalid content', () => { - expect(parseSpawnResult('no frontmatter', 'id', 0)).toBeNull(); - }); - }); -});