mcp-subagents
Version:
Multi-Agent AI Orchestration via Model Context Protocol - Access specialized CLI AI agents (Aider, Qwen, Gemini, Goose, etc.) with intelligent fallback and configuration
271 lines • 8.94 kB
JavaScript
/**
* Process Manager
*
* Handles proper process spawning, management, and cleanup to prevent
* zombie processes and ensure all child processes are terminated.
*/
import { spawn } from 'child_process';
import { promisify } from 'util';
import { exec as execCallback } from 'child_process';
const exec = promisify(execCallback);
/**
* Spawn a process with proper management and cleanup
*/
export class ManagedProcess {
process = null;
output = [];
killed = false;
cleanupTimer;
earlyFailCallback;
async spawn(options) {
const { command, args, env, cwd, timeout } = options;
// Use provided environment directly
const processEnv = env;
// Spawn with process group (detached: true on Unix)
this.process = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'],
shell: false,
env: processEnv,
cwd,
detached: process.platform !== 'win32', // Create new process group on Unix
});
// Set up output capture with early error detection
this.process.stdout?.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
this.output.push(...lines);
this.checkForEarlyErrors(lines);
});
this.process.stderr?.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
this.output.push(...lines);
this.checkForEarlyErrors(lines);
});
// Set up timeout if specified - with race condition protection
if (timeout && timeout > 0) {
this.cleanupTimer = setTimeout(() => {
if (!this.killed && this.process && this.process.exitCode === null) {
this.terminate();
}
}, timeout);
}
// Wait for process completion
return new Promise((resolve, reject) => {
if (!this.process) {
reject(new Error('Process not initialized'));
return;
}
// Set up early fail callback
this.earlyFailCallback = (_reason) => {
if (this.cleanupTimer) {
clearTimeout(this.cleanupTimer);
delete this.cleanupTimer;
}
this.terminate();
const result = {
output: this.output,
exitCode: 1, // Indicate failure
signal: null
};
// Use setImmediate to ensure immediate resolution
setImmediate(() => resolve(result));
};
this.process.on('exit', (code, signal) => {
// Clear timeout immediately to prevent race conditions
if (this.cleanupTimer) {
clearTimeout(this.cleanupTimer);
delete this.cleanupTimer;
}
// Resolve immediately - no additional delays
const result = {
output: this.output,
exitCode: code,
signal
};
// Use setImmediate to ensure resolution happens in next tick
setImmediate(() => resolve(result));
});
this.process.on('error', (error) => {
// Clear timeout immediately
if (this.cleanupTimer) {
clearTimeout(this.cleanupTimer);
delete this.cleanupTimer;
}
// Reject immediately
setImmediate(() => reject(error));
});
});
}
/**
* Send input to the process via stdin
*/
sendInput(input) {
if (this.process?.stdin && !this.process.stdin.destroyed) {
this.process.stdin.write(input);
this.process.stdin.end();
}
}
/**
* Terminate the process and all its children
*/
async terminate() {
if (this.killed || !this.process) {
return;
}
this.killed = true;
const pid = this.process.pid;
if (!pid) {
return;
}
try {
if (process.platform === 'win32') {
// On Windows, use taskkill to kill the process tree
await this.killWindowsProcessTree(pid);
}
else {
// On Unix, kill the process group
await this.killUnixProcessGroup(pid);
}
}
catch (error) {
console.error(`Failed to terminate process ${pid}:`, error);
// Last resort: try basic kill
try {
this.process.kill('SIGKILL');
}
catch {
// Ignore errors if the process is already gone
}
}
}
async killUnixProcessGroup(pid) {
try {
// First try SIGTERM to the process group
process.kill(-pid, 'SIGTERM');
// Give it 2 seconds to clean up
await new Promise(resolve => setTimeout(resolve, 2000));
// Check if still running and force kill if needed
try {
process.kill(-pid, 0); // Check if process group exists
// Still running, force kill
process.kill(-pid, 'SIGKILL');
}
catch {
// Process group is gone, good
}
}
catch (error) {
if (error.code !== 'ESRCH') { // ESRCH means process not found
throw error;
}
}
}
async killWindowsProcessTree(pid) {
try {
// Kill the process tree on Windows
await exec(`taskkill /F /T /PID ${pid}`);
}
catch {
// Fallback to basic kill
this.process?.kill('SIGKILL');
}
}
/**
* Get the process ID
*/
getPid() {
return this.process?.pid;
}
/**
* Check if the process is still running
*/
isRunning() {
return this.process !== null && !this.killed && this.process.exitCode === null;
}
/**
* Check output lines for patterns that indicate early failure
*/
checkForEarlyErrors(lines) {
if (!this.earlyFailCallback)
return;
for (const line of lines) {
const lowerLine = line.toLowerCase();
// Gemini specific error patterns
if (lowerLine.includes('quota exceeded') && lowerLine.includes('gemini')) {
this.earlyFailCallback('Gemini quota exceeded');
return;
}
// Generic API rate limit patterns
if (lowerLine.includes('rate limit') || lowerLine.includes('429')) {
this.earlyFailCallback('Rate limit exceeded');
return;
}
// Authentication errors
if (lowerLine.includes('unauthorized') || lowerLine.includes('401')) {
this.earlyFailCallback('Authentication failed');
return;
}
// Permission errors
if (lowerLine.includes('permission denied') || lowerLine.includes('403')) {
this.earlyFailCallback('Permission denied');
return;
}
// Generic error patterns that suggest hanging
if (lowerLine.includes('error:') &&
(lowerLine.includes('gaxioserror') || lowerLine.includes('connection'))) {
this.earlyFailCallback('Connection error detected');
return;
}
}
}
}
/**
* Check if we're running as a child of an MCP server
*/
export function isSpawnedByMCPServer() {
return process.env['MCP_SPAWNED_BY_SERVER'] === '1';
}
/**
* Process pool to manage concurrent processes
*/
export class ProcessPool {
processes = new Map();
/**
* Add a process to the pool
*/
add(id, process) {
this.processes.set(id, process);
}
/**
* Remove a process from the pool
*/
remove(id) {
this.processes.delete(id);
}
/**
* Get a process by ID
*/
get(id) {
return this.processes.get(id);
}
/**
* Terminate all processes in the pool
*/
async terminateAll() {
const promises = Array.from(this.processes.values()).map(process => process.terminate());
await Promise.allSettled(promises);
this.processes.clear();
}
/**
* Get the number of running processes
*/
getRunningCount() {
let count = 0;
for (const process of this.processes.values()) {
if (process.isRunning()) {
count++;
}
}
return count;
}
}
//# sourceMappingURL=process-manager.js.map