UNPKG

vibe-coder-mcp

Version:

Production-ready MCP server with complete agent integration, multi-transport support, and comprehensive development automation tools for AI-assisted workflows.

471 lines (470 loc) 17.7 kB
import { randomUUID } from 'crypto'; import { createHash } from 'crypto'; import logger from '../../logger.js'; import { TimeoutManager } from '../../tools/vibe-task-manager/utils/timeout-manager.js'; import { JobExecutionAdapter } from './execution-adapter.js'; import { JobTimeoutConfigManager } from '../../utils/job-timeout-config-manager.js'; export var JobStatus; (function (JobStatus) { JobStatus["PENDING"] = "pending"; JobStatus["RUNNING"] = "running"; JobStatus["COMPLETED"] = "completed"; JobStatus["FAILED"] = "failed"; })(JobStatus || (JobStatus = {})); class JobManager { static instance = null; jobs = new Map(); jobsByFingerprint = new Map(); timeoutManager; executionAdapter = null; timeoutConfigManager; constructor() { this.timeoutManager = TimeoutManager.getInstance(); this.timeoutConfigManager = JobTimeoutConfigManager.getInstance(); this.initializeTimeoutConfig(); this.cleanupOrphanedJobs(); } async initializeTimeoutConfig() { try { await this.timeoutConfigManager.initialize(); logger.info('Job timeout configuration initialized'); } catch (error) { logger.error({ error }, 'Failed to initialize job timeout configuration'); } } static getInstance() { if (!JobManager.instance) { JobManager.instance = new JobManager(); } return JobManager.instance; } initializeExecutionAdapter(executionCoordinator) { this.executionAdapter = new JobExecutionAdapter(executionCoordinator); this.executionAdapter.registerJobLifecycleHooks((jobId, status, message) => { this.updateJobStatus(jobId, status, message); }); logger.info('JobManager initialized with ExecutionCoordinator adapter'); } isExecutionAdapterInitialized() { return this.executionAdapter !== null; } getExecutionAdapter() { return this.executionAdapter; } generateJobFingerprint(toolName, params) { const normalizedParams = JSON.stringify(params, Object.keys(params).sort()); const fingerprintData = `${toolName}:${normalizedParams}`; return createHash('md5').update(fingerprintData).digest('hex'); } findExistingActiveJob(fingerprint) { const jobId = this.jobsByFingerprint.get(fingerprint); if (!jobId) { return undefined; } const job = this.jobs.get(jobId); if (!job) { this.jobsByFingerprint.delete(fingerprint); return undefined; } if (job.status === JobStatus.COMPLETED || job.status === JobStatus.FAILED) { this.jobsByFingerprint.delete(fingerprint); return undefined; } return job; } startTimeoutTimer(jobId) { const job = this.jobs.get(jobId); if (!job || !job.timeoutMs) return; const warningTimeMs = Math.floor(job.timeoutMs * 0.8); const warningTimer = setTimeout(() => { const jobStillRunning = this.jobs.get(jobId); if (jobStillRunning && jobStillRunning.status === JobStatus.RUNNING) { const remainingTimeMs = job.timeoutMs - warningTimeMs; const remainingTimeSec = Math.floor(remainingTimeMs / 1000); logger.warn({ jobId, toolName: job.toolName, warningTimeMs, remainingTimeMs, remainingTimeSec }, `Job approaching timeout - ${remainingTimeSec} seconds remaining`); this.updateJobStatus(jobId, JobStatus.RUNNING, `WARNING: Job approaching timeout - ${remainingTimeSec} seconds remaining`, jobStillRunning.progressPercentage); } }, warningTimeMs); const timer = setTimeout(() => { if (this.isJobTimedOut(jobId)) { logger.warn({ jobId, timeout: job.timeoutMs }, 'Job timeout reached, cancelling job'); this.cancelJob(jobId, 'Job exceeded timeout limit'); if (job.abortController && !job.abortController.signal.aborted) { job.abortController.abort('Timeout exceeded'); } } }, job.timeoutMs); job.timeoutTimer = timer; job.warningTimer = warningTimer; } clearTimeoutTimer(jobId) { const job = this.jobs.get(jobId); if (job) { if (job.warningTimer) { clearTimeout(job.warningTimer); delete job.warningTimer; } if (job.timeoutTimer) { clearTimeout(job.timeoutTimer); delete job.timeoutTimer; } } } getMinimumWaitTime(jobId) { const job = this.jobs.get(jobId); if (!job) { return 0; } if (job.status === JobStatus.COMPLETED || job.status === JobStatus.FAILED) { return 0; } if (!job.lastAccessTime) { return 0; } const timeSinceLastAccess = Date.now() - job.lastAccessTime; const baseWaitTime = 1000; const accessCount = job.accessCount || 0; const backoffFactor = Math.min(Math.pow(2, Math.floor(accessCount / 3)), 10); const recommendedWaitTime = baseWaitTime * backoffFactor; if (timeSinceLastAccess >= recommendedWaitTime) { return 0; } return recommendedWaitTime - timeSinceLastAccess; } updateJobAccess(jobId) { const job = this.jobs.get(jobId); if (!job) { return; } job.lastAccessTime = Date.now(); job.accessCount = (job.accessCount || 0) + 1; } getJobWithRateLimit(jobId, updateAccess = true) { const job = this.jobs.get(jobId); if (!job) { return { job: undefined, waitTime: 0, shouldWait: false }; } const waitTime = this.getMinimumWaitTime(jobId); const shouldWait = waitTime > 0; if (updateAccess && !shouldWait) { this.updateJobAccess(jobId); } return { job, waitTime, shouldWait }; } createJob(toolName, params) { const fingerprint = this.generateJobFingerprint(toolName, params); const existingJob = this.findExistingActiveJob(fingerprint); if (existingJob) { logger.info({ jobId: existingJob.id, toolName, fingerprint, status: existingJob.status }, `Found existing active job, returning existing job ID instead of creating duplicate.`); return existingJob.id; } const jobId = randomUUID(); const now = Date.now(); const timeoutOperation = this.timeoutConfigManager.getTimeoutOperation(toolName); const customTimeoutMs = this.timeoutConfigManager.getCustomTimeoutMs(toolName); const newJob = { id: jobId, toolName, params, status: JobStatus.PENDING, createdAt: now, updatedAt: now, fingerprint, timeoutOperation, timeoutMs: customTimeoutMs }; this.jobs.set(jobId, newJob); this.jobsByFingerprint.set(fingerprint, jobId); logger.info({ jobId, toolName, fingerprint, paramCount: Object.keys(params).length, timeoutOperation, customTimeoutMs }, `Created new background job with timeout configuration.`); return jobId; } createJobWithId(jobId, toolName, params) { const now = Date.now(); const fingerprint = this.generateJobFingerprint(toolName, params); const timeoutOperation = this.timeoutConfigManager.getTimeoutOperation(toolName); const customTimeoutMs = this.timeoutConfigManager.getCustomTimeoutMs(toolName); const newJob = { id: jobId, toolName, params, status: JobStatus.PENDING, createdAt: now, updatedAt: now, fingerprint, timeoutOperation, timeoutMs: customTimeoutMs }; this.jobs.set(jobId, newJob); this.jobsByFingerprint.set(fingerprint, jobId); logger.info({ jobId, toolName, fingerprint, paramCount: Object.keys(params).length, timeoutOperation, customTimeoutMs }, `Created new background job with specific ID and timeout configuration.`); return jobId; } getJob(jobId, updateAccess = true) { const job = this.jobs.get(jobId); if (job && updateAccess) { this.updateJobAccess(jobId); } return job; } updateJobStatus(jobId, status, progressMessage, progressPercentage, details) { const job = this.jobs.get(jobId); if (!job) { logger.warn({ jobId }, `Attempted to update status for non-existent job.`); return false; } if (job.status === JobStatus.COMPLETED || job.status === JobStatus.FAILED) { logger.warn({ jobId, currentStatus: job.status, newStatus: status }, `Attempted to update status of a finalized job.`); } if (status === JobStatus.RUNNING && job.status !== JobStatus.RUNNING) { job.startedAt = Date.now(); job.abortController = new AbortController(); this.startTimeoutTimer(jobId); logger.debug({ jobId, startedAt: job.startedAt }, 'Job started running, timeout timer started'); } if (status === JobStatus.COMPLETED || status === JobStatus.FAILED) { this.clearTimeoutTimer(jobId); } job.status = status; job.updatedAt = Date.now(); if (progressMessage !== undefined) { job.progressMessage = progressMessage; } if (progressPercentage !== undefined) { job.progressPercentage = progressPercentage; } if (details !== undefined) { job.details = details; } logger.info({ jobId, status, progressMessage, progressPercentage, hasDetails: !!details }, `Updated job status.`); return true; } setJobResult(jobId, result) { const job = this.jobs.get(jobId); if (!job) { logger.warn({ jobId }, `Attempted to set result for non-existent job.`); return false; } if (job.status === JobStatus.COMPLETED || job.status === JobStatus.FAILED) { logger.warn({ jobId, currentStatus: job.status }, `Attempted to set result for an already finalized job.`); } job.result = result; job.status = result.isError ? JobStatus.FAILED : JobStatus.COMPLETED; job.updatedAt = Date.now(); job.progressMessage = result.isError ? 'Job failed' : 'Job completed successfully'; job.progressPercentage = 100; if (job.fingerprint) { this.jobsByFingerprint.delete(job.fingerprint); logger.debug({ jobId, fingerprint: job.fingerprint }, `Cleaned up fingerprint mapping for completed job.`); } logger.info({ jobId, finalStatus: job.status }, `Set final job result.`); return true; } isJobTimedOut(jobId) { const job = this.jobs.get(jobId); if (!job || !job.startedAt || job.status !== JobStatus.RUNNING) { return false; } if (!job.timeoutOperation) { return false; } const timeout = job.timeoutMs || this.timeoutManager.getTimeout(job.timeoutOperation); const elapsed = Date.now() - job.startedAt; const timedOut = elapsed > timeout; if (timedOut) { logger.warn({ jobId, timeoutOperation: job.timeoutOperation, timeout, elapsed, toolName: job.toolName }, 'Job has timed out'); } return timedOut; } getJobAbortSignal(jobId) { const job = this.jobs.get(jobId); return job?.abortController?.signal; } async cancelJob(jobId, reason = 'Job cancelled') { const job = this.jobs.get(jobId); if (!job || job.status !== JobStatus.RUNNING) { logger.warn({ jobId, status: job?.status }, 'Cannot cancel job - not running'); return false; } if (this.executionAdapter) { try { const cancelled = await this.executionAdapter.cancelJobExecution(jobId); if (cancelled) { logger.info({ jobId, reason }, 'Job cancelled via ExecutionCoordinator'); return true; } } catch (error) { logger.warn({ jobId, error }, 'Failed to cancel job via ExecutionCoordinator, falling back to direct cancellation'); } } if (job.abortController) { job.abortController.abort(reason); } this.setJobResult(jobId, { isError: true, content: [{ type: 'text', text: `Job cancelled: ${reason}` }] }); logger.info({ jobId, reason }, 'Job cancelled directly'); return true; } setJobTimeout(jobId, timeoutOperation, customTimeoutMs) { const job = this.jobs.get(jobId); if (!job) { logger.warn({ jobId }, 'Cannot set timeout for non-existent job'); return false; } job.timeoutOperation = timeoutOperation; if (customTimeoutMs !== undefined) { job.timeoutMs = customTimeoutMs; } logger.debug({ jobId, timeoutOperation, customTimeoutMs, defaultTimeout: this.timeoutManager.getTimeout(timeoutOperation) }, 'Job timeout configuration set'); return true; } cleanupOrphanedJobs() { let orphanedCount = 0; const jobsToUpdate = []; for (const [jobId, job] of this.jobs) { if (job.status === JobStatus.RUNNING || job.status === JobStatus.PENDING) { jobsToUpdate.push(jobId); orphanedCount++; job.status = JobStatus.FAILED; job.result = { isError: true, content: [{ type: 'text', text: 'Job was orphaned due to server restart' }] }; job.updatedAt = Date.now(); if (job.fingerprint) { this.jobsByFingerprint.delete(job.fingerprint); } } } if (orphanedCount > 0) { logger.warn({ orphanedCount, totalJobs: this.jobs.size }, `Cleaned up ${orphanedCount} orphaned jobs from previous server session.`); } } cleanupOldJobs(maxAgeMs = 24 * 60 * 60 * 1000) { const now = Date.now(); let cleanedCount = 0; const jobsToDelete = []; for (const [jobId, job] of this.jobs) { if (job.status === JobStatus.COMPLETED || job.status === JobStatus.FAILED) { const jobAge = now - job.updatedAt; if (jobAge > maxAgeMs) { jobsToDelete.push(jobId); cleanedCount++; if (job.fingerprint) { this.jobsByFingerprint.delete(job.fingerprint); } } } } for (const jobId of jobsToDelete) { this.jobs.delete(jobId); } if (cleanedCount > 0) { logger.info({ cleanedCount, maxAgeMs, totalJobsRemaining: this.jobs.size }, `Cleaned up ${cleanedCount} old jobs from memory.`); } return cleanedCount; } clearAllJobs() { this.jobs.clear(); this.jobsByFingerprint.clear(); logger.debug('Cleared all jobs from memory'); } getJobStats() { const now = Date.now(); let pendingJobs = 0; let runningJobs = 0; let completedJobs = 0; let failedJobs = 0; let oldestJobAge = 0; let totalAge = 0; for (const job of this.jobs.values()) { const jobAge = now - job.createdAt; totalAge += jobAge; oldestJobAge = Math.max(oldestJobAge, jobAge); switch (job.status) { case JobStatus.PENDING: pendingJobs++; break; case JobStatus.RUNNING: runningJobs++; break; case JobStatus.COMPLETED: completedJobs++; break; case JobStatus.FAILED: failedJobs++; break; } } return { totalJobs: this.jobs.size, pendingJobs, runningJobs, completedJobs, failedJobs, oldestJobAge, averageJobAge: this.jobs.size > 0 ? totalAge / this.jobs.size : 0 }; } } export const jobManager = JobManager.getInstance(); export { JobExecutionAdapter } from './execution-adapter.js';