claude-flow
Version:
Enterprise-grade AI agent orchestration with ruv-swarm integration (Alpha Release)
483 lines (400 loc) • 13.2 kB
text/typescript
import { spawn, ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { Logger } from '../core/logger.js';
import { generateId } from '../utils/helpers.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
export interface BackgroundTask {
id: string;
type: 'claude-spawn' | 'script' | 'command';
command: string;
args: string[];
options?: {
cwd?: string;
env?: Record<string, string>;
timeout?: number;
retries?: number;
detached?: boolean;
};
status: 'pending' | 'running' | 'completed' | 'failed';
pid?: number;
output?: string;
error?: string;
startTime?: Date;
endTime?: Date;
retryCount: number;
}
export interface BackgroundExecutorConfig {
maxConcurrentTasks: number;
defaultTimeout: number;
logPath: string;
enablePersistence: boolean;
checkInterval: number;
cleanupInterval: number;
maxRetries: number;
}
export class BackgroundExecutor extends EventEmitter {
private logger: Logger;
private config: BackgroundExecutorConfig;
private tasks: Map<string, BackgroundTask>;
private processes: Map<string, ChildProcess>;
private queue: string[];
private isRunning: boolean = false;
private checkTimer?: NodeJS.Timeout;
private cleanupTimer?: NodeJS.Timeout;
constructor(config: Partial<BackgroundExecutorConfig> = {}) {
super();
this.logger = new Logger('BackgroundExecutor');
this.config = {
maxConcurrentTasks: 5,
defaultTimeout: 300000, // 5 minutes
logPath: './background-tasks',
enablePersistence: true,
checkInterval: 1000, // 1 second
cleanupInterval: 60000, // 1 minute
maxRetries: 3,
...config,
};
this.tasks = new Map();
this.processes = new Map();
this.queue = [];
}
async start(): Promise<void> {
if (this.isRunning) return;
this.logger.info('Starting background executor...');
this.isRunning = true;
// Create log directory
if (this.config.enablePersistence) {
await fs.mkdir(this.config.logPath, { recursive: true });
}
// Start background processing
this.checkTimer = setInterval(() => {
this.processQueue();
this.checkRunningTasks();
}, this.config.checkInterval);
// Start cleanup timer
this.cleanupTimer = setInterval(() => {
this.cleanupCompletedTasks();
}, this.config.cleanupInterval);
this.emit('executor:started');
}
async stop(): Promise<void> {
if (!this.isRunning) return;
this.logger.info('Stopping background executor...');
this.isRunning = false;
// Clear timers
if (this.checkTimer) {
clearInterval(this.checkTimer);
this.checkTimer = undefined;
}
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = undefined;
}
// Kill all running processes
for (const [taskId, process] of this.processes) {
this.logger.warn(`Killing process for task ${taskId}`);
process.kill('SIGTERM');
}
this.emit('executor:stopped');
}
async submitTask(
type: BackgroundTask['type'],
command: string,
args: string[] = [],
options: BackgroundTask['options'] = {},
): Promise<string> {
const taskId = generateId('bgtask');
const task: BackgroundTask = {
id: taskId,
type,
command,
args,
options: {
timeout: this.config.defaultTimeout,
retries: this.config.maxRetries,
...options,
},
status: 'pending',
retryCount: 0,
};
this.tasks.set(taskId, task);
this.queue.push(taskId);
if (this.config.enablePersistence) {
await this.saveTaskState(task);
}
this.logger.info(`Submitted background task: ${taskId} - ${command}`);
this.emit('task:submitted', task);
// Process immediately if possible
this.processQueue();
return taskId;
}
async submitClaudeTask(
prompt: string,
tools: string[] = [],
options: Partial<{
cwd: string;
env: Record<string, string>;
timeout: number;
model?: string;
maxTokens?: number;
}> = {},
): Promise<string> {
// Build claude command arguments
const args = ['-p', prompt];
if (tools.length > 0) {
args.push('--allowedTools', tools.join(','));
}
if (options.model) {
args.push('--model', options.model);
}
if (options.maxTokens) {
args.push('--max-tokens', options.maxTokens.toString());
}
args.push('--dangerously-skip-permissions');
return this.submitTask('claude-spawn', 'claude', args, {
...options,
detached: true, // Run in background
});
}
private async processQueue(): Promise<void> {
if (!this.isRunning) return;
// Check how many tasks are running
const runningTasks = Array.from(this.tasks.values()).filter(
(t) => t.status === 'running',
).length;
const availableSlots = this.config.maxConcurrentTasks - runningTasks;
// Process pending tasks
for (let i = 0; i < availableSlots && this.queue.length > 0; i++) {
const taskId = this.queue.shift();
if (!taskId) continue;
const task = this.tasks.get(taskId);
if (!task || task.status !== 'pending') continue;
await this.executeTask(task);
}
}
private async executeTask(task: BackgroundTask): Promise<void> {
try {
task.status = 'running';
task.startTime = new Date();
this.logger.info(`Executing task ${task.id}: ${task.command} ${task.args.join(' ')}`);
// Create log files for task
const logDir = path.join(this.config.logPath, task.id);
if (this.config.enablePersistence) {
await fs.mkdir(logDir, { recursive: true });
}
// Spawn process
const process = spawn(task.command, task.args, {
cwd: task.options?.cwd,
env: { ...process.env, ...task.options?.env },
detached: task.options?.detached,
stdio: ['ignore', 'pipe', 'pipe'],
});
task.pid = process.pid;
this.processes.set(task.id, process);
// Collect output
let stdout = '';
let stderr = '';
process.stdout?.on('data', (data) => {
stdout += data.toString();
this.emit('task:output', { taskId: task.id, data: data.toString() });
});
process.stderr?.on('data', (data) => {
stderr += data.toString();
this.emit('task:error', { taskId: task.id, data: data.toString() });
});
// Handle process completion
process.on('close', async (code) => {
task.endTime = new Date();
task.output = stdout;
task.error = stderr;
if (code === 0) {
task.status = 'completed';
this.logger.info(`Task ${task.id} completed successfully`);
this.emit('task:completed', task);
} else {
task.status = 'failed';
this.logger.error(`Task ${task.id} failed with code ${code}`);
// Retry logic
if (task.retryCount < (task.options?.retries || 0)) {
task.retryCount++;
task.status = 'pending';
this.queue.push(task.id);
this.logger.info(
`Retrying task ${task.id} (${task.retryCount}/${task.options?.retries})`,
);
this.emit('task:retry', task);
} else {
this.emit('task:failed', task);
}
}
this.processes.delete(task.id);
if (this.config.enablePersistence) {
await this.saveTaskOutput(task);
}
});
// Set timeout if specified
if (task.options?.timeout) {
setTimeout(() => {
if (this.processes.has(task.id)) {
this.logger.warn(`Task ${task.id} timed out after ${task.options?.timeout}ms`);
process.kill('SIGTERM');
}
}, task.options.timeout);
}
// For detached processes, unref to allow main process to exit
if (task.options?.detached) {
process.unref();
}
this.emit('task:started', task);
if (this.config.enablePersistence) {
await this.saveTaskState(task);
}
} catch (error) {
task.status = 'failed';
task.error = String(error);
task.endTime = new Date();
this.logger.error(`Failed to execute task ${task.id}:`, error);
this.emit('task:failed', task);
if (this.config.enablePersistence) {
await this.saveTaskState(task);
}
}
}
private checkRunningTasks(): void {
// Check for hung or timed out tasks
const now = Date.now();
for (const [taskId, task] of this.tasks) {
if (task.status !== 'running' || !task.startTime) continue;
const runtime = now - task.startTime.getTime();
const timeout = task.options?.timeout || this.config.defaultTimeout;
if (runtime > timeout) {
const process = this.processes.get(taskId);
if (process) {
this.logger.warn(`Killing timed out task ${taskId}`);
process.kill('SIGTERM');
// Force kill after 5 seconds
setTimeout(() => {
if (this.processes.has(taskId)) {
process.kill('SIGKILL');
}
}, 5000);
}
}
}
}
private cleanupCompletedTasks(): void {
const cutoffTime = Date.now() - 3600000; // 1 hour
for (const [taskId, task] of this.tasks) {
if (task.status === 'completed' || task.status === 'failed') {
if (task.endTime && task.endTime.getTime() < cutoffTime) {
this.tasks.delete(taskId);
this.logger.debug(`Cleaned up old task: ${taskId}`);
}
}
}
}
private async saveTaskState(task: BackgroundTask): Promise<void> {
if (!this.config.enablePersistence) return;
try {
const taskFile = path.join(this.config.logPath, task.id, 'task.json');
await fs.writeFile(taskFile, JSON.stringify(task, null, 2));
} catch (error) {
this.logger.error(`Failed to save task state for ${task.id}:`, error);
}
}
private async saveTaskOutput(task: BackgroundTask): Promise<void> {
if (!this.config.enablePersistence) return;
try {
const logDir = path.join(this.config.logPath, task.id);
if (task.output) {
await fs.writeFile(path.join(logDir, 'stdout.log'), task.output);
}
if (task.error) {
await fs.writeFile(path.join(logDir, 'stderr.log'), task.error);
}
// Save final task state
await this.saveTaskState(task);
} catch (error) {
this.logger.error(`Failed to save task output for ${task.id}:`, error);
}
}
// Public API methods
getTask(taskId: string): BackgroundTask | undefined {
return this.tasks.get(taskId);
}
getTasks(status?: BackgroundTask['status']): BackgroundTask[] {
const tasks = Array.from(this.tasks.values());
return status ? tasks.filter((t) => t.status === status) : tasks;
}
async waitForTask(taskId: string, timeout?: number): Promise<BackgroundTask> {
return new Promise((resolve, reject) => {
const task = this.tasks.get(taskId);
if (!task) {
reject(new Error('Task not found'));
return;
}
if (task.status === 'completed' || task.status === 'failed') {
resolve(task);
return;
}
const timeoutHandle = timeout
? setTimeout(() => {
reject(new Error('Wait timeout'));
}, timeout)
: undefined;
const checkTask = () => {
const currentTask = this.tasks.get(taskId);
if (!currentTask) {
if (timeoutHandle) clearTimeout(timeoutHandle);
reject(new Error('Task disappeared'));
return;
}
if (currentTask.status === 'completed' || currentTask.status === 'failed') {
if (timeoutHandle) clearTimeout(timeoutHandle);
resolve(currentTask);
} else {
setTimeout(checkTask, 100);
}
};
checkTask();
});
}
async killTask(taskId: string): Promise<void> {
const task = this.tasks.get(taskId);
if (!task) {
throw new Error('Task not found');
}
const process = this.processes.get(taskId);
if (process) {
this.logger.info(`Killing task ${taskId}`);
process.kill('SIGTERM');
// Force kill after 5 seconds
setTimeout(() => {
if (this.processes.has(taskId)) {
process.kill('SIGKILL');
}
}, 5000);
}
task.status = 'failed';
task.error = 'Task killed by user';
task.endTime = new Date();
this.emit('task:killed', task);
}
getStatus(): {
running: number;
pending: number;
completed: number;
failed: number;
queueLength: number;
} {
const tasks = Array.from(this.tasks.values());
return {
running: tasks.filter((t) => t.status === 'running').length,
pending: tasks.filter((t) => t.status === 'pending').length,
completed: tasks.filter((t) => t.status === 'completed').length,
failed: tasks.filter((t) => t.status === 'failed').length,
queueLength: this.queue.length,
};
}
}