claude-flow-novice
Version:
Claude Flow Novice - Advanced orchestration platform for multi-agent AI workflows with CFN Loop architecture Includes Local RuVector Accelerator and all CFN skills for complete functionality.
364 lines (363 loc) • 13.9 kB
JavaScript
/**
* Agent Spawning Abstraction Layer
*
* Provides unified interface for spawning agents in both Task Mode and CLI Mode.
* Abstracts away the complexity of determining spawn method and managing processes.
*
* Key Features:
* - Automatic mode detection (Task vs CLI)
* - Process management and cleanup
* - Error handling and retry logic
* - Process PID tracking
* - Wait for agent completion
* - Resource limits and timeouts
*/ import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { CoordinationError, CoordinationErrorType } from './types-export';
/**
* Agent Spawner
*
* Creates and manages agent processes with support for both Task and CLI modes.
*
* Task Mode: Spawns via npx CLI (no Redis coordination)
* CLI Mode: Spawns via npx CLI with Redis coordination environment variables
*/ export class AgentSpawner extends EventEmitter {
logger;
executionMode;
canUseRedis;
projectRoot;
dockerEnabled;
// Track running agents
processes = new Map();
constructor(config){
super();
this.logger = config.logger;
this.executionMode = config.executionMode;
this.canUseRedis = config.canUseRedis;
this.projectRoot = config.projectRoot || process.cwd();
this.dockerEnabled = config.dockerEnabled ?? false;
}
/**
* Spawn an agent (auto-detects mode)
*
* Automatically selects appropriate spawn method based on execution mode.
*/ async spawnAgent(config) {
if (this.executionMode === 'cli' || this.executionMode === 'unknown') {
// Try CLI mode first
return this.spawnCLIAgent(config);
} else {
// Task mode: Still spawn via CLI but without Redis
return this.spawnTaskAgent(config);
}
}
/**
* Spawn agent in CLI mode
*
* Spawns via npx with TASK_ID and AGENT_ID for Redis coordination.
*/ async spawnCLIAgent(config) {
if (!this.canUseRedis && this.executionMode !== 'unknown') {
// In true CLI mode but Redis unavailable
this.logger.warn('CLI Mode but Redis unavailable - spawning without coordination');
}
const args = [
'claude-flow-novice',
'agent',
config.agentType,
'--task-id',
config.taskId,
'--agent-id',
config.agentId
];
if (config.iteration !== undefined) {
args.push('--iteration', config.iteration.toString());
}
if (config.context) {
args.push('--context', JSON.stringify(config.context));
}
// Build environment
const env = {
...process.env,
TASK_ID: config.taskId,
AGENT_ID: config.agentId,
CFN_MODE: 'cli',
...config.environment || {}
};
// Add Redis connection info if available
if (this.canUseRedis) {
const redisHost = process.env.REDIS_HOST || 'localhost';
const redisPort = process.env.REDIS_PORT || '6379';
const redisPassword = process.env.REDIS_PASSWORD || process.env.CFN_REDIS_PASSWORD;
env.REDIS_HOST = redisHost;
env.REDIS_PORT = redisPort;
if (redisPassword) {
env.REDIS_PASSWORD = redisPassword;
}
}
return this._spawnProcess(config, args, env, 'cli');
}
/**
* Spawn agent in Task mode
*
* Spawns via npx without Task/Agent IDs for Main Chat execution.
*/ async spawnTaskAgent(config) {
const args = [
'claude-flow-novice',
'agent',
config.agentType
];
// In Task Mode, don't pass TASK_ID/AGENT_ID (Main Chat doesn't know about them)
if (config.context) {
args.push('--context', JSON.stringify(config.context));
}
// Build environment without TASK_ID/AGENT_ID
const env = {
...process.env,
CFN_MODE: 'task',
...config.environment || {}
};
// Remove Redis connection in Task Mode
delete env.TASK_ID;
delete env.AGENT_ID;
delete env.REDIS_HOST;
delete env.REDIS_PORT;
delete env.REDIS_PASSWORD;
return this._spawnProcess(config, args, env, 'task');
}
/**
* Internal: Spawn process with retry logic
*
* Handles actual process spawning with error handling and retries.
*/ async _spawnProcess(config, args, env, mode) {
const maxRetries = config.maxRetries ?? 1;
let lastError = null;
for(let attempt = 1; attempt <= maxRetries; attempt++){
try {
// Spawn the process
const spawnOptions = {
stdio: [
'ignore',
'pipe',
'pipe'
],
env,
detached: true,
timeout: config.timeoutMs ?? 600000
};
this.logger.debug(`Spawning agent ${config.agentId} (attempt ${attempt}/${maxRetries})`);
const process1 = spawn('npx', args, spawnOptions);
const pid = process1.pid || null;
const processInfo = {
agentId: config.agentId,
agentType: config.agentType,
taskId: config.taskId,
pid,
mode,
process: process1,
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
killed: false
};
// Track process
this.processes.set(config.agentId, processInfo);
// Attach event handlers
this._attachProcessHandlers(processInfo);
this.logger.info(`Spawned agent ${config.agentId} (PID: ${pid || 'unknown'}, mode: ${mode})`);
this.emit('agent-spawned', processInfo);
return processInfo;
} catch (error) {
lastError = error;
if (attempt < maxRetries) {
// Wait before retry (exponential backoff)
const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
this.logger.warn(`Failed to spawn agent ${config.agentId} (attempt ${attempt}/${maxRetries}), retrying in ${delayMs}ms...`);
await new Promise((resolve)=>setTimeout(resolve, delayMs));
}
}
}
throw new CoordinationError(CoordinationErrorType.AGENT_SPAWN_ERROR, `Failed to spawn agent ${config.agentId} after ${maxRetries} attempts: ${lastError?.message}`, mode, true // Retryable
);
}
/**
* Attach process event handlers
*
* Monitors process lifecycle and emits events.
*/ _attachProcessHandlers(processInfo) {
const { process: process1, agentId } = processInfo;
if (!process1) return;
// Handle process exit
process1.on('exit', (code, signal)=>{
processInfo.exitCode = code;
processInfo.signal = signal;
this.logger.info(`Agent ${agentId} exited with code ${code}${signal ? ` (signal: ${signal})` : ''}`);
this.emit('agent-exit', processInfo);
});
// Handle process errors
process1.on('error', (error)=>{
this.logger.error(`Agent ${agentId} process error`, error);
this.emit('agent-error', {
agentId,
error
});
});
// Handle stdout/stderr
process1.stdout?.on('data', (data)=>{
this.logger.debug(`Agent ${agentId} stdout:`, data.toString());
});
process1.stderr?.on('data', (data)=>{
this.logger.debug(`Agent ${agentId} stderr:`, data.toString());
});
}
/**
* Kill an agent process
*
* Forcefully terminates an agent and cleanup.
*/ async killAgent(agentId, signal = 'SIGTERM') {
const processInfo = this.processes.get(agentId);
if (!processInfo || !processInfo.process || !processInfo.pid) {
this.logger.warn(`Cannot kill agent ${agentId}: not found or no PID`);
return;
}
try {
// Kill the process group (not just the process)
process.kill(-processInfo.pid, signal);
processInfo.killed = true;
this.logger.info(`Killed agent ${agentId} (PID: ${processInfo.pid}, signal: ${signal})`);
this.emit('agent-killed', processInfo);
// Wait a bit for graceful shutdown
await new Promise((resolve)=>setTimeout(resolve, 1000));
// Force kill if still alive
if (!processInfo.process.killed) {
process.kill(-processInfo.pid, 'SIGKILL');
this.logger.warn(`Force-killed agent ${agentId} (SIGKILL)`);
}
} catch (error) {
this.logger.error(`Failed to kill agent ${agentId}`, error);
}
}
/**
* Get agent PID
*
* Returns process ID for a spawned agent.
*/ getAgentPID(agentId) {
return this.processes.get(agentId)?.pid ?? null;
}
/**
* Get agent process info
*
* Returns full process information for tracking.
*/ getAgentProcessInfo(agentId) {
return this.processes.get(agentId) ?? null;
}
/**
* Get all running agents
*
* Returns information for all spawned agents.
*/ getAllRunningAgents() {
return Array.from(this.processes.values()).filter((info)=>info.process && !info.process.killed);
}
/**
* Wait for agent completion
*
* Blocking wait for agent process to exit with timeout.
*/ async waitForAgent(agentId, timeoutMs = 600000 // 10 minutes
) {
const startTime = Date.now();
return new Promise((resolve, reject)=>{
const processInfo = this.processes.get(agentId);
if (!processInfo) {
return reject(new CoordinationError(CoordinationErrorType.AGENT_NOT_FOUND, `Agent ${agentId} not found`));
}
// If already exited, return immediately
if (processInfo.exitCode !== null) {
return resolve(processInfo);
}
// Wait for exit event
const exitHandler = ()=>{
resolve(processInfo);
clearTimeout(timeoutHandle);
};
const timeoutHandle = setTimeout(()=>{
if (processInfo.process) {
processInfo.process.removeListener('exit', exitHandler);
}
processInfo.exitCode = -1; // Timeout code
reject(new CoordinationError(CoordinationErrorType.TIMEOUT, `Agent ${agentId} did not complete within ${timeoutMs}ms`));
}, timeoutMs);
if (processInfo.process) {
processInfo.process.once('exit', exitHandler);
}
});
}
/**
* Wait for multiple agents
*
* Blocking wait for all agents to complete.
*/ async waitForAgents(agentIds, timeoutMs = 600000) {
const results = new Map();
const startTime = Date.now();
for (const agentId of agentIds){
const elapsedMs = Date.now() - startTime;
const remainingMs = timeoutMs - elapsedMs;
if (remainingMs <= 0) {
throw new CoordinationError(CoordinationErrorType.TIMEOUT, `Timeout waiting for agents: exceeded ${timeoutMs}ms`);
}
try {
const processInfo = await this.waitForAgent(agentId, remainingMs);
results.set(agentId, processInfo);
} catch (error) {
// Continue waiting for other agents even if one fails
const processInfo = this.processes.get(agentId);
if (processInfo) {
results.set(agentId, processInfo);
}
}
}
return results;
}
/**
* Cleanup all spawned agents
*
* Kills all running agents and clears tracking.
*/ async cleanup() {
const runningAgents = this.getAllRunningAgents();
this.logger.info(`Cleaning up ${runningAgents.length} running agents...`);
for (const agentInfo of runningAgents){
try {
await this.killAgent(agentInfo.agentId, 'SIGTERM');
} catch (error) {
this.logger.error(`Failed to cleanup agent ${agentInfo.agentId}`, error);
}
}
this.processes.clear();
this.logger.info('Agent cleanup complete');
}
/**
* Get statistics about spawned agents
*
* Returns summary of spawned agents by status.
*/ getSpawnStatistics() {
const all = Array.from(this.processes.values());
const running = all.filter((info)=>info.exitCode === null && !info.killed);
const completed = all.filter((info)=>info.exitCode === 0 && !info.killed);
const failed = all.filter((info)=>info.exitCode !== 0 && info.exitCode !== null || info.killed);
return {
total: all.length,
running: running.length,
completed: completed.length,
failed: failed.length
};
}
}
/**
* Factory function for creating spawner with sensible defaults
*/ export function createAgentSpawner(logger, config) {
return new AgentSpawner({
logger,
executionMode: config?.executionMode ?? 'unknown',
canUseRedis: config?.canUseRedis ?? false,
projectRoot: config?.projectRoot,
dockerEnabled: config?.dockerEnabled ?? false
});
}
//# sourceMappingURL=spawn-agent.js.map