mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
chore: bump version to 0.1431
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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 → <spawn1337>task.md</spawn1337>
|
||||
→ SpawnDetector parses tag
|
||||
→ Orchestrator creates agent directory
|
||||
→ Spawns Claude in its own screen session
|
||||
→ Agent works autonomously
|
||||
→ Reports result via <promise>PHRASE</promise>
|
||||
→ 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<br/><small>~/.claude/projects/*/subagents</small>"]
|
||||
end
|
||||
|
||||
@@ -411,8 +362,6 @@ flowchart TB
|
||||
|
||||
subgraph External["External"]
|
||||
CLI["Claude CLI"]
|
||||
A1["Agent 1<br/><small>(screen)</small>"]
|
||||
A2["Agent 2<br/><small>(screen)</small>"]
|
||||
BG["Background Agents<br/><small>(Task tool)</small>"]
|
||||
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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 914 KiB |
@@ -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 `<promise>` 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-<agentId>/
|
||||
→ Spawns interactive Claude session in screen
|
||||
→ Injects initial prompt via writeViaScreen()
|
||||
→ Agent works autonomously, writes progress to spawn-comms/
|
||||
→ RalphTracker detects <promise>PHRASE</promise> on child
|
||||
→ Orchestrator reads result.md, notifies parent via SSE
|
||||
```
|
||||
|
||||
## Agent Directory Structure
|
||||
|
||||
Each agent gets: `~/claudeman-cases/spawn-<agentId>/`
|
||||
|
||||
```
|
||||
spawn-<agentId>/
|
||||
├── 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 `<spawn1337>` tags in terminal output |
|
||||
+3
-5
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('MCP server failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
<promise>${spec.completionPhrase}</promise>
|
||||
|
||||
**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: <promise>${task.spec.completionPhrase}</promise>`;
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<string, AgentContext> = new Map();
|
||||
private _completedAgents: Map<string, AgentContext> = 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<string, (phrase: string) => void> = new Map();
|
||||
|
||||
constructor(config?: Partial<SpawnOrchestratorConfig>) {
|
||||
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<SpawnOrchestratorConfig>): 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+25
-51
@@ -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<agentId, { type, model, status, startTime, element, relativePos }>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, { input: number; output: number }> = 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<SpawnOrchestratorConfig>;
|
||||
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<ScheduledRun> {
|
||||
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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, (phrase: string) => 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls;
|
||||
const warningCalls = writeToSessionCalls.filter(
|
||||
(call: [string, string]) => call[1]?.includes('WARNING') && call[1]?.includes('timeout')
|
||||
);
|
||||
expect(warningCalls.length).toBe(0);
|
||||
|
||||
warningOrchestrator.removeAllListeners();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user