UNPKG

converse-mcp-server

Version:

Converse MCP Server - Converse with other LLMs with chat and consensus tools

600 lines (526 loc) 19.1 kB
/** * Job Runner - Background Execution Orchestration * * Provides background execution orchestration for async chat and consensus operations. * Uses bounded concurrency control with p-limit to manage parallel LLM requests, * integrates with AsyncJobStore for state management, and emits events for monitoring. * Core execution layer enabling async=true functionality in MCP tools. */ import pLimit from 'p-limit'; import { EventEmitter } from 'events'; import { debugLog, debugError } from '../utils/console.js'; import { JOB_STATUS } from './asyncJobStore.js'; import { getEventBus } from './eventBus.js'; /** * Custom error class for job runner operations */ export class JobRunnerError extends Error { constructor(message, code = 'JOB_RUNNER_ERROR') { super(message); this.name = 'JobRunnerError'; this.code = code; } } /** * JobRunner - Orchestrates background execution of async operations */ export class JobRunner extends EventEmitter { /** * Create a new JobRunner * @param {object} dependencies - Required dependencies * @param {object} dependencies.asyncJobStore - AsyncJobStore instance * @param {object} dependencies.eventBus - EventBus instance (optional, uses global if not provided) * @param {object} options - Configuration options * @param {number} options.concurrency - Maximum concurrent jobs (default: 10) * @param {number} options.defaultTimeout - Default job timeout in ms (default: 90 minutes) */ constructor(dependencies, options = {}) { super(); // Validate dependencies if (!dependencies || !dependencies.asyncJobStore) { throw new JobRunnerError( 'AsyncJobStore is required', 'MISSING_DEPENDENCIES', ); } this.asyncJobStore = dependencies.asyncJobStore; this.fileCache = dependencies.fileCache; // Optional for caching support this.eventBus = dependencies.eventBus || getEventBus(); // Use provided EventBus or global instance // Configuration this.concurrency = options.concurrency || 10; this.defaultTimeout = options.defaultTimeout || 90 * 60 * 1000; // 90 minutes // Create bounded concurrency limiter this.limiter = pLimit(this.concurrency); // Track active jobs and abort controllers this.activeJobs = new Map(); this.abortControllers = new Map(); // Statistics this.stats = { submitted: 0, completed: 0, failed: 0, cancelled: 0, activeCount: 0, }; debugLog( `JobRunner: Initialized with concurrency limit ${this.concurrency}`, ); } /** * Submit a job for background execution * @param {object} jobSpec - Job specification * @param {string} jobSpec.sessionId - Session identifier * @param {string} jobSpec.tool - Tool name (always 'chat') * @param {string} jobSpec.mode - Chat mode ('chat' | 'consensus' | 'roundtable') * @param {Function} runFunction - Function to execute (async) * @param {object} options - Execution options * @param {number} options.timeout - Job timeout in ms * @param {boolean} options.priority - High priority execution * @returns {string} Job ID for status tracking * @throws {JobRunnerError} If submission fails */ async submit(jobSpec, runFunction, options = {}) { try { // Debug log the jobSpec debugLog('JobRunner: Received jobSpec:', jobSpec); // Validate parameters if (!jobSpec || !jobSpec.tool) { throw new JobRunnerError( 'Invalid job specification: tool is required', 'INVALID_JOB_SPEC', ); } if (typeof runFunction !== 'function') { throw new JobRunnerError( 'runFunction must be a callable function', 'INVALID_RUN_FUNCTION', ); } // Create job in store const jobId = await this.asyncJobStore.create(jobSpec.tool, { sessionId: jobSpec.sessionId, mode: jobSpec.mode, timeout: options.timeout || this.defaultTimeout, priority: options.priority || false, ...jobSpec.options, }); // Set up abort controller for timeout and cancellation const abortController = new globalThis.AbortController(); this.abortControllers.set(jobId, abortController); // Emit job created event through EventBus this.eventBus.emitJobCreated(jobId, jobSpec.sessionId, { tool: jobSpec.tool, options: jobSpec.options, }); // Also emit through local EventEmitter for backward compatibility this.emit('job.created', { jobId, sessionId: jobSpec.sessionId, tool: jobSpec.tool, timestamp: Date.now(), }); // Submit to limiter queue for background execution const limitedExecution = this.limiter(() => this._executeJob(jobId, runFunction, options, abortController.signal), ); // Don't await - execute in background globalThis.setImmediate(() => { limitedExecution.catch((error) => { debugError( `JobRunner: Background execution failed for ${jobId}:`, error, ); }); }); // Track statistics this.stats.submitted++; this.stats.activeCount++; debugLog(`JobRunner: Submitted job ${jobId} for background execution`); return jobId; } catch (error) { if (error instanceof JobRunnerError) { throw error; } throw new JobRunnerError( `Failed to submit job: ${error.message}`, 'SUBMISSION_ERROR', ); } } /** * Cancel a running job * @param {string} jobId - Job identifier * @returns {Promise<boolean>} True if cancelled */ async cancel(jobId) { try { // Check if job exists and is cancellable const jobState = await this.asyncJobStore.get(jobId); if (!jobState) { return false; } if (![JOB_STATUS.QUEUED, JOB_STATUS.RUNNING].includes(jobState.status)) { return false; // Already completed or failed } // Signal abort const abortController = this.abortControllers.get(jobId); if (abortController) { abortController.abort('Job cancelled by user'); } // Update job status await this.asyncJobStore.update(jobId, { status: JOB_STATUS.CANCELLED, }); // Clean up tracking this.activeJobs.delete(jobId); this.abortControllers.delete(jobId); // Update stats this.stats.cancelled++; this.stats.activeCount = Math.max(0, this.stats.activeCount - 1); // Emit cancellation event through EventBus this.eventBus.emitJobCancelled(jobId, jobState.sessionId, { reason: 'User cancellation', }); // Also emit through local EventEmitter for backward compatibility this.emit('job.cancelled', { jobId, timestamp: Date.now(), }); debugLog(`JobRunner: Cancelled job ${jobId}`); return true; } catch (error) { debugError(`JobRunner: Failed to cancel job ${jobId}:`, error); return false; } } /** * Get current statistics * @returns {object} Runner statistics */ getStats() { return { ...this.stats, concurrency: this.concurrency, queueSize: this.limiter.pendingCount, activeSize: this.limiter.activeCount, totalJobs: this.activeJobs.size, }; } /** * Execute a job in the background with proper error handling and state management * @param {string} jobId - Job identifier * @param {Function} runFunction - Function to execute * @param {object} options - Execution options * @param {AbortSignal} signal - Abort signal for cancellation * @private */ async _executeJob(jobId, runFunction, options, signal) { let jobState = null; try { // Get current job state jobState = await this.asyncJobStore.get(jobId); if (!jobState) { throw new JobRunnerError( `Job ${jobId} not found in store`, 'JOB_NOT_FOUND', ); } // Check if already cancelled or aborted if (signal.aborted) { await this.asyncJobStore.update(jobId, { status: JOB_STATUS.CANCELLED, }); return; } // Update status to running await this.asyncJobStore.update(jobId, { status: JOB_STATUS.RUNNING, }); this.activeJobs.set(jobId, { startedAt: Date.now(), tool: jobState.tool, }); // Emit job started event through EventBus this.eventBus.emitJobStarted(jobId, jobState.sessionId, { tool: jobState.tool, }); // Also emit through local EventEmitter for backward compatibility this.emit('job.started', { jobId, tool: jobState.tool, timestamp: Date.now(), }); // Set up timeout const timeout = options.timeout || this.defaultTimeout; const timeoutHandle = setTimeout(() => { if (!signal.aborted) { debugLog(`JobRunner: Job ${jobId} timed out after ${timeout}ms`); this.abortControllers .get(jobId) ?.abort(`Job timed out after ${timeout}ms`); } }, timeout); try { // Create execution context for the run function const context = { jobId, tool: jobState.tool, signal, updateJob: (updates) => this.asyncJobStore.update(jobId, updates), emitEvent: (eventType, data) => this.emit(eventType, { jobId, ...data }), }; // Execute the job function debugLog(`JobRunner: Executing job ${jobId} (${jobState.tool})`); const result = await runFunction(context); // Clear timeout clearTimeout(timeoutHandle); // Check if aborted during execution if (signal.aborted) { await this.asyncJobStore.update(jobId, { status: JOB_STATUS.CANCELLED, }); return; } // Complete the job await this.asyncJobStore.complete(jobId, result); // Write to FileCache if available if (this.fileCache) { try { // Get the latest job state with all metadata const finalJobState = await this.asyncJobStore.get(jobId); await this.fileCache.writeSnapshot(jobId, { jobId, status: JOB_STATUS.COMPLETED, tool: finalJobState.tool, mode: finalJobState.mode, result, completedAt: Date.now(), createdAt: finalJobState.createdAt, startedAt: finalJobState.startedAt, provider: finalJobState.provider, model: finalJobState.model, title: finalJobState.title, final_summary: finalJobState.final_summary, }); debugLog( `JobRunner: Written snapshot to FileCache for completed job ${jobId}`, ); } catch (cacheError) { debugError( `JobRunner: Failed to write to FileCache for job ${jobId}:`, cacheError, ); // Continue even if cache write fails } } // Emit completion event through EventBus this.eventBus.emitJobCompleted(jobId, jobState.sessionId, result); // Also emit through local EventEmitter for backward compatibility this.emit('job.completed', { jobId, tool: jobState.tool, result: result ? 'present' : 'null', timestamp: Date.now(), }); this.stats.completed++; debugLog(`JobRunner: Completed job ${jobId}`); } catch (executionError) { // Clear timeout clearTimeout(timeoutHandle); // Check if this was due to cancellation if (signal.aborted) { // If the execution function returned a result before cancellation, preserve it let partialResult = null; if (executionError && executionError.partialResult) { partialResult = executionError.partialResult; } await this.asyncJobStore.update(jobId, { status: JOB_STATUS.CANCELLED, result: partialResult, // Preserve any partial results }); // Write to FileCache if available if (this.fileCache) { try { // Get the latest job state with all metadata const cancelledJobState = await this.asyncJobStore.get(jobId); await this.fileCache.writeSnapshot(jobId, { jobId, status: JOB_STATUS.CANCELLED, tool: cancelledJobState.tool, mode: cancelledJobState.mode, result: partialResult, cancelledAt: Date.now(), createdAt: cancelledJobState.createdAt, startedAt: cancelledJobState.startedAt, provider: cancelledJobState.provider, model: cancelledJobState.model, title: cancelledJobState.title, }); debugLog( `JobRunner: Written snapshot to FileCache for cancelled job ${jobId}`, ); } catch (cacheError) { debugError( `JobRunner: Failed to write to FileCache for job ${jobId}:`, cacheError, ); // Continue even if cache write fails } } // Emit cancellation event through EventBus this.eventBus.emitJobCancelled(jobId, jobState.sessionId, { reason: 'Job aborted during execution', partial_result: partialResult, }); // Also emit through local EventEmitter for backward compatibility this.emit('job.cancelled', { jobId, timestamp: Date.now(), partial_result: partialResult, }); this.stats.cancelled++; debugLog(`JobRunner: Job ${jobId} was cancelled during execution`); return; } // Handle execution error await this.asyncJobStore.fail(jobId, executionError); // Write to FileCache if available if (this.fileCache) { try { // Get the latest job state with all metadata const failedJobState = await this.asyncJobStore.get(jobId); await this.fileCache.writeSnapshot(jobId, { jobId, status: JOB_STATUS.FAILED, tool: failedJobState.tool, mode: failedJobState.mode, error: executionError.message || executionError, failedAt: Date.now(), createdAt: failedJobState.createdAt, startedAt: failedJobState.startedAt, provider: failedJobState.provider, model: failedJobState.model, title: failedJobState.title, }); debugLog( `JobRunner: Written snapshot to FileCache for failed job ${jobId}`, ); } catch (cacheError) { debugError( `JobRunner: Failed to write to FileCache for job ${jobId}:`, cacheError, ); // Continue even if cache write fails } } // Emit failure event through EventBus this.eventBus.emitJobFailed(jobId, jobState.sessionId, executionError); // Also emit through local EventEmitter for backward compatibility this.emit('job.failed', { jobId, tool: jobState.tool, error: executionError.message, timestamp: Date.now(), }); this.stats.failed++; debugError(`JobRunner: Job ${jobId} failed:`, executionError); } } catch (error) { // Handle system-level errors debugError('JobRunner: System error during job execution:', error); if (jobState) { try { await this.asyncJobStore.fail(jobId, error); // Emit failure event through EventBus this.eventBus.emitJobFailed(jobId, jobState.sessionId, error); // Also emit through local EventEmitter for backward compatibility this.emit('job.failed', { jobId, sessionId: jobState.sessionId, tool: jobState.tool, error: error.message, timestamp: Date.now(), }); } catch (updateError) { debugError( `JobRunner: Failed to update job ${jobId} after system error:`, updateError, ); } } this.stats.failed++; } finally { // Clean up tracking this.activeJobs.delete(jobId); this.abortControllers.delete(jobId); this.stats.activeCount = Math.max(0, this.stats.activeCount - 1); } } /** * Gracefully shutdown the job runner * @param {number} timeoutMs - Max time to wait for running jobs (default: 30s) * @returns {Promise<void>} */ async shutdown(timeoutMs = 30000) { debugLog(`JobRunner: Starting graceful shutdown (timeout: ${timeoutMs}ms)`); // Cancel all queued jobs for (const [, abortController] of this.abortControllers.entries()) { abortController.abort('JobRunner shutdown'); } // Wait for active jobs to complete or timeout const shutdownStart = Date.now(); while ( this.stats.activeCount > 0 && Date.now() - shutdownStart < timeoutMs ) { await new Promise((resolve) => setTimeout(resolve, 100)); } if (this.stats.activeCount > 0) { debugLog( `JobRunner: Forced shutdown - ${this.stats.activeCount} jobs still active`, ); } // Clear all tracking this.activeJobs.clear(); this.abortControllers.clear(); this.removeAllListeners(); debugLog('JobRunner: Shutdown complete'); } } /** * Create a new JobRunner instance * @param {object} dependencies - Required dependencies * @param {object} options - Configuration options * @returns {JobRunner} JobRunner instance */ export function createJobRunner(dependencies, options = {}) { return new JobRunner(dependencies, options); } // Singleton instance for global usage let globalJobRunner = null; /** * Get the global JobRunner instance * @param {object} dependencies - Dependencies for initialization * @param {object} options - Configuration options * @returns {JobRunner} Global JobRunner instance */ export function getJobRunner(dependencies = null, options = {}) { if (!globalJobRunner && dependencies) { globalJobRunner = createJobRunner(dependencies, options); } return globalJobRunner; } /** * Set a custom JobRunner instance (for testing) * @param {JobRunner|null} runner - JobRunner instance or null to reset */ export function setJobRunner(runner) { if (runner !== null && !(runner instanceof JobRunner)) { throw new JobRunnerError( 'Runner must be a JobRunner instance', 'INVALID_RUNNER', ); } globalJobRunner = runner; }