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
JavaScript
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';