@sirmrmarty/n8n-nodes-tmux-orchestrator
Version:
n8n nodes for orchestrating Claude AI agents through tmux sessions
621 lines (539 loc) • 21.8 kB
text/typescript
import { spawn, ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import { resourceManager } from './resourceManager';
import { circuitRegistry, CircuitState } from './circuitBreaker';
/**
* Python process connection for the pool
*/
interface PythonConnection {
process: ChildProcess;
id: string;
created: number;
lastUsed: number;
pendingRequests: Map<string, {
resolve: (result: any) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
timestamp: number;
}>;
healthy: boolean;
errorCount: number;
busy: boolean;
}
/**
* Configuration for Python process pool
*/
export interface PythonPoolConfig {
maxProcesses?: number;
minProcesses?: number;
idleTimeout?: number;
requestTimeout?: number;
maxErrorCount?: number;
healthCheckInterval?: number;
processRespawnDelay?: number;
}
/**
* Metrics for monitoring pool performance
*/
export interface PoolMetrics {
activeConnections: number;
idleConnections: number;
totalRequests: number;
successfulRequests: number;
failedRequests: number;
averageResponseTime: number;
poolUtilization: number;
healthyProcesses: number;
errorRate: number;
}
/**
* Python Process Pool for efficient subprocess management
*/
export class PythonProcessPool extends EventEmitter {
private connections: Map<string, PythonConnection> = new Map();
private requestQueue: Array<{
method: string;
args: any[];
resolve: (result: any) => void;
reject: (error: Error) => void;
}> = [];
private config: Required<PythonPoolConfig>;
private scriptPath: string;
private healthCheckResourceId?: string;
private idleCheckResourceId?: string;
// Metrics tracking
private metrics = {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
totalResponseTime: 0,
};
constructor(scriptPath: string, config: PythonPoolConfig = {}) {
super();
this.scriptPath = scriptPath;
this.config = {
maxProcesses: config.maxProcesses || 6,
minProcesses: config.minProcesses || 2,
idleTimeout: config.idleTimeout || 60000, // 1 minute
requestTimeout: config.requestTimeout || 30000, // 30 seconds
maxErrorCount: config.maxErrorCount || 5,
healthCheckInterval: config.healthCheckInterval || 30000, // 30 seconds
processRespawnDelay: config.processRespawnDelay || 1000, // 1 second
};
this.initialize();
}
/**
* Initialize the process pool
*/
private async initialize(): Promise<void> {
// Create minimum number of processes
for (let i = 0; i < this.config.minProcesses; i++) {
await this.createConnection();
}
// Start health check interval
const { id: healthCheckId } = resourceManager.createInterval(() => {
this.performHealthChecks();
}, this.config.healthCheckInterval, 'Python process pool health check');
this.healthCheckResourceId = healthCheckId;
// Start idle check interval
const { id: idleCheckId } = resourceManager.createInterval(() => {
this.checkIdleConnections();
}, 10000, 'Python process pool idle connection check');
this.idleCheckResourceId = idleCheckId;
}
/**
* Create a new Python connection
*/
private async createConnection(): Promise<PythonConnection | null> {
// Circuit breaker for connection creation
const circuitBreaker = circuitRegistry.getBreaker('python-connection-creation', {
failureThreshold: 3,
recoveryTimeout: 180000, // 3 minutes
successThreshold: 2,
maxRetryAttempts: 1,
onStateChange: (state, reason) => {
console.log(`[Circuit Breaker] Python connection creation: ${state} - ${reason}`);
},
onFailure: (error) => {
console.error(`[Circuit Breaker] Python connection creation failure:`, error.message);
}
});
return await circuitBreaker.execute(async () => {
return await this.performConnectionCreation();
}, async () => {
// Fallback: return null to indicate connection creation failed
console.warn('[Circuit Breaker] Python connection creation is in open state, skipping connection creation');
return null;
});
}
/**
* Perform the actual connection creation (extracted for circuit breaker protection)
*/
private async performConnectionCreation(): Promise<PythonConnection | null> {
try {
const id = uuidv4();
const pythonProcess = spawn('python3', [this.scriptPath, '--persistent'], {
stdio: ['pipe', 'pipe', 'pipe'],
});
const connection: PythonConnection = {
process: pythonProcess,
id,
created: Date.now(),
lastUsed: Date.now(),
pendingRequests: new Map(),
healthy: true,
errorCount: 0,
busy: false,
};
// Set up stdout handler
let buffer = '';
pythonProcess.stdout.on('data', (data) => {
buffer += data.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
this.handleResponse(connection, line);
}
}
});
// Set up stderr handler
pythonProcess.stderr.on('data', (data) => {
console.error(`Python process ${id} stderr:`, data.toString());
connection.errorCount++;
});
// Set up error handler
pythonProcess.on('error', (error) => {
console.error(`Python process ${id} error:`, error);
connection.healthy = false;
this.handleConnectionError(connection, error);
});
// Set up exit handler
pythonProcess.on('exit', (code) => {
console.log(`Python process ${id} exited with code ${code}`);
connection.healthy = false;
this.handleConnectionExit(connection, code);
});
// Perform initial health check
const healthCheck = await this.sendRequest(connection, 'ping', []);
if (healthCheck !== 'pong') {
throw new Error('Health check failed');
}
this.connections.set(id, connection);
this.emit('connectionCreated', id);
return connection;
} catch (error) {
console.error('Failed to create Python connection:', error);
return null;
}
}
/**
* Handle response from Python process
*/
private handleResponse(connection: PythonConnection, line: string): void {
try {
const response = JSON.parse(line);
const requestId = response.id;
if (!requestId) {
console.warn('Received response without ID:', response);
return;
}
const pendingRequest = connection.pendingRequests.get(requestId);
if (!pendingRequest) {
console.warn('No pending request for ID:', requestId);
return;
}
// Clear timeout
clearTimeout(pendingRequest.timeout);
// Calculate response time
const responseTime = Date.now() - pendingRequest.timestamp;
this.metrics.totalResponseTime += responseTime;
// Remove from pending requests
connection.pendingRequests.delete(requestId);
connection.busy = connection.pendingRequests.size > 0;
connection.lastUsed = Date.now();
// Handle response
if (response.error) {
this.metrics.failedRequests++;
connection.errorCount++;
pendingRequest.reject(new Error(response.error));
} else {
this.metrics.successfulRequests++;
connection.errorCount = 0; // Reset error count on success
pendingRequest.resolve(response.result);
}
// Process queued requests
this.processQueue();
} catch (error) {
console.error('Failed to handle response:', error);
connection.errorCount++;
}
}
/**
* Handle connection error
*/
private handleConnectionError(connection: PythonConnection, error: Error): void {
// Reject all pending requests
for (const [requestId, request] of connection.pendingRequests) {
clearTimeout(request.timeout);
request.reject(new Error(`Connection error: ${error.message}`));
this.metrics.failedRequests++;
}
connection.pendingRequests.clear();
// Remove from pool
this.connections.delete(connection.id);
// Schedule respawn
setTimeout(() => {
this.maintainMinimumConnections();
}, this.config.processRespawnDelay);
}
/**
* Handle connection exit
*/
private handleConnectionExit(connection: PythonConnection, code: number | null): void {
// Reject all pending requests
for (const [requestId, request] of connection.pendingRequests) {
clearTimeout(request.timeout);
request.reject(new Error(`Process exited with code ${code}`));
this.metrics.failedRequests++;
}
connection.pendingRequests.clear();
// Remove from pool
this.connections.delete(connection.id);
// Schedule respawn
setTimeout(() => {
this.maintainMinimumConnections();
}, this.config.processRespawnDelay);
}
/**
* Send request to a specific connection
*/
private async sendRequest(connection: PythonConnection, method: string, args: any[]): Promise<any> {
return new Promise((resolve, reject) => {
const requestId = uuidv4();
const request = {
id: requestId,
method,
args,
};
// Set up timeout
const timeout = setTimeout(() => {
connection.pendingRequests.delete(requestId);
connection.errorCount++;
reject(new Error(`Request timeout for method ${method}`));
}, this.config.requestTimeout);
// Store pending request
connection.pendingRequests.set(requestId, {
resolve,
reject,
timeout,
timestamp: Date.now(),
});
// Mark connection as busy
connection.busy = true;
// Send request
connection.process.stdin.write(JSON.stringify(request) + '\n');
});
}
/**
* Execute a method using the pool
*/
async execute(method: string, args: any[] = []): Promise<any> {
// Circuit breaker for process execution
const circuitBreaker = circuitRegistry.getBreaker(`python-process-execution-${method}`, {
failureThreshold: 5,
recoveryTimeout: 60000, // 1 minute
successThreshold: 3,
monitoringWindow: 300000, // 5 minutes
maxRetryAttempts: 2,
onStateChange: (state, reason) => {
console.log(`[Circuit Breaker] Python execution for ${method}: ${state} - ${reason}`);
},
onFailure: (error) => {
console.error(`[Circuit Breaker] Python execution failure for ${method}:`, error.message);
}
});
return await circuitBreaker.execute(async () => {
return await this.performExecution(method, args);
}, async () => {
// Fallback: return error result instead of throwing
console.warn(`[Circuit Breaker] Python execution for ${method} is in open state, returning fallback`);
throw new Error(`Python process execution temporarily unavailable for method ${method} due to circuit breaker protection`);
});
}
/**
* Perform the actual execution (extracted for circuit breaker protection)
*/
private async performExecution(method: string, args: any[] = []): Promise<any> {
this.metrics.totalRequests++;
// Find available connection
const connection = this.findAvailableConnection();
if (connection) {
try {
return await this.sendRequest(connection, method, args);
} catch (error) {
// If request fails, try another connection
const retryConnection = this.findAvailableConnection(connection.id);
if (retryConnection) {
return await this.sendRequest(retryConnection, method, args);
}
throw error;
}
} else {
// Queue the request
return new Promise((resolve, reject) => {
this.requestQueue.push({ method, args, resolve, reject });
// Try to create new connection if under limit
if (this.connections.size < this.config.maxProcesses) {
this.createConnection().then(() => {
this.processQueue();
});
}
});
}
}
/**
* Find an available connection
*/
private findAvailableConnection(excludeId?: string): PythonConnection | null {
let bestConnection: PythonConnection | null = null;
let minPendingRequests = Infinity;
for (const connection of this.connections.values()) {
if (connection.id === excludeId) continue;
if (!connection.healthy) continue;
if (connection.errorCount >= this.config.maxErrorCount) continue;
const pendingCount = connection.pendingRequests.size;
// Return immediately if we find an idle connection
if (pendingCount === 0) {
return connection;
}
// Track connection with fewest pending requests
if (pendingCount < minPendingRequests) {
minPendingRequests = pendingCount;
bestConnection = connection;
}
}
// Return connection with fewest pending requests if under threshold
if (bestConnection && minPendingRequests < 3) {
return bestConnection;
}
return null;
}
/**
* Process queued requests
*/
private processQueue(): void {
while (this.requestQueue.length > 0) {
const connection = this.findAvailableConnection();
if (!connection) break;
const request = this.requestQueue.shift();
if (!request) break;
this.sendRequest(connection, request.method, request.args)
.then(request.resolve)
.catch(request.reject);
}
}
/**
* Perform health checks on all connections
*/
private async performHealthChecks(): Promise<void> {
// Circuit breaker for health checks
const circuitBreaker = circuitRegistry.getBreaker('python-health-check', {
failureThreshold: 3,
recoveryTimeout: 120000, // 2 minutes
successThreshold: 2,
maxRetryAttempts: 1,
onStateChange: (state, reason) => {
console.log(`[Circuit Breaker] Python health check: ${state} - ${reason}`);
},
onFailure: (error) => {
console.error(`[Circuit Breaker] Python health check failure:`, error.message);
}
});
await circuitBreaker.execute(async () => {
return await this.performActualHealthChecks();
}, async () => {
// Fallback: skip health checks but maintain minimum connections
console.warn('[Circuit Breaker] Python health check is in open state, skipping health checks');
this.maintainMinimumConnections(); // Still try to maintain minimum connections
// No return value needed for void function
});
}
/**
* Perform the actual health checks (extracted for circuit breaker protection)
*/
private async performActualHealthChecks(): Promise<void> {
const checks: Promise<void>[] = [];
for (const connection of this.connections.values()) {
if (!connection.healthy) continue;
const check = this.sendRequest(connection, 'ping', [])
.then(result => {
if (result !== 'pong') {
connection.healthy = false;
connection.errorCount++;
}
})
.catch(() => {
connection.healthy = false;
connection.errorCount++;
});
checks.push(check);
}
await Promise.allSettled(checks);
// Remove unhealthy connections
for (const [id, connection] of this.connections) {
if (!connection.healthy || connection.errorCount >= this.config.maxErrorCount) {
connection.process.kill();
this.connections.delete(id);
}
}
// Maintain minimum connections
this.maintainMinimumConnections();
}
/**
* Check and remove idle connections
*/
private checkIdleConnections(): void {
const now = Date.now();
const toRemove: string[] = [];
for (const [id, connection] of this.connections) {
// Skip if connection has pending requests
if (connection.pendingRequests.size > 0) continue;
// Skip if we're at minimum connections
if (this.connections.size <= this.config.minProcesses) continue;
// Check if idle timeout exceeded
if (now - connection.lastUsed > this.config.idleTimeout) {
toRemove.push(id);
}
}
// Remove idle connections
for (const id of toRemove) {
const connection = this.connections.get(id);
if (connection) {
connection.process.kill();
this.connections.delete(id);
this.emit('connectionRemoved', id);
}
}
}
/**
* Maintain minimum number of connections
*/
private async maintainMinimumConnections(): Promise<void> {
const currentCount = this.connections.size;
const needed = this.config.minProcesses - currentCount;
if (needed > 0) {
const creates: Promise<PythonConnection | null>[] = [];
for (let i = 0; i < needed; i++) {
creates.push(this.createConnection());
}
await Promise.allSettled(creates);
}
}
/**
* Get pool metrics
*/
getMetrics(): PoolMetrics {
const activeConnections = Array.from(this.connections.values()).filter(c => c.busy).length;
const idleConnections = this.connections.size - activeConnections;
const healthyProcesses = Array.from(this.connections.values()).filter(c => c.healthy).length;
return {
activeConnections,
idleConnections,
totalRequests: this.metrics.totalRequests,
successfulRequests: this.metrics.successfulRequests,
failedRequests: this.metrics.failedRequests,
averageResponseTime: this.metrics.successfulRequests > 0
? this.metrics.totalResponseTime / this.metrics.successfulRequests
: 0,
poolUtilization: this.connections.size > 0
? (activeConnections / this.connections.size) * 100
: 0,
healthyProcesses,
errorRate: this.metrics.totalRequests > 0
? (this.metrics.failedRequests / this.metrics.totalRequests) * 100
: 0,
};
}
/**
* Destroy the pool and clean up resources
*/
async destroy(): Promise<void> {
// Clear intervals
if (this.healthCheckResourceId) {
await resourceManager.cleanup(this.healthCheckResourceId);
}
if (this.idleCheckResourceId) {
await resourceManager.cleanup(this.idleCheckResourceId);
}
// Kill all processes
for (const connection of this.connections.values()) {
connection.process.kill();
}
// Clear collections
this.connections.clear();
this.requestQueue = [];
this.emit('destroyed');
}
}