UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

545 lines (544 loc) 21.4 kB
/** * TaskManager — Main orchestrator for scheduled and self-running tasks. * * Manages the full task lifecycle: create, schedule, execute, pause, resume, delete. * Auto-selects TaskStore and TaskBackend based on config. * * Usage: * const neurolink = new NeuroLink({ tasks: { backend: "bullmq" } }); * await neurolink.tasks.create({ name: "monitor", prompt: "...", schedule: { type: "interval", every: 60000 } }); */ import { nanoid } from "nanoid"; import { TASK_DEFAULTS, } from "../types/index.js"; import { logger } from "../utils/logger.js"; import { clearWorkerCache } from "./autoresearchTaskExecutor.js"; import { TaskBackendRegistry } from "./backends/taskBackendRegistry.js"; import { TaskError } from "./errors.js"; import { TaskExecutor } from "./taskExecutor.js"; export class TaskManager { neurolink; config; store = null; backend = null; executor = null; initialized = false; initPromise = null; /** In-memory callback registry (not serializable to store) */ callbacks = new Map(); /** Emitter reference — set by NeuroLink on integration */ emitter; constructor(neurolink, config) { this.neurolink = neurolink; this.config = { ...config }; } /** Set the event emitter (called by NeuroLink during integration) */ setEmitter(emitter) { this.emitter = emitter; } // ── Initialization ────────────────────────────────────── async ensureInitialized() { if (this.initialized) { return; } if (this.initPromise) { return this.initPromise; } this.initPromise = this.doInitialize(); await this.initPromise; } async doInitialize() { const backendName = this.config.backend ?? TASK_DEFAULTS.backend; // Create store based on backend if (backendName === "bullmq") { const { RedisTaskStore } = await import("./store/redisTaskStore.js"); this.store = new RedisTaskStore(this.config); } else { const { FileTaskStore } = await import("./store/fileTaskStore.js"); this.store = new FileTaskStore(this.config); } await this.store.initialize(); // Create backend this.backend = await TaskBackendRegistry.create(backendName, this.config); await this.backend.initialize(); // Create executor (pass emitter for autoresearch lifecycle events) this.executor = new TaskExecutor(this.neurolink, this.store, this.emitter); // Re-schedule active tasks from store (handles restarts) await this.rescheduleActiveTasks(); this.initialized = true; logger.info("[TaskManager] Initialized", { backend: backendName, store: this.store.type, }); } getStore() { if (!this.store) { throw TaskError.create("BACKEND_NOT_INITIALIZED", "[TaskManager] Store not initialized. Call initialize() first."); } return this.store; } getBackend() { if (!this.backend) { throw TaskError.create("BACKEND_NOT_INITIALIZED", "[TaskManager] Backend not initialized. Call initialize() first."); } return this.backend; } getExecutor() { if (!this.executor) { throw TaskError.create("BACKEND_NOT_INITIALIZED", "[TaskManager] Executor not initialized. Call initialize() first."); } return this.executor; } // ── Public API ──────────────────────────────────────── async create(definition) { if (this.config.enabled === false) { throw TaskError.create("TASK_DISABLED", "TaskManager is disabled. Set tasks.enabled to true in config."); } await this.ensureInitialized(); const store = this.getStore(); const backend = this.getBackend(); // Enforce maximum task limit to prevent unbounded task creation const maxTasks = this.config.maxTasks ?? TASK_DEFAULTS.maxTasks; const existingTasks = await store.list(); if (existingTasks.length >= maxTasks) { throw TaskError.create("TASK_LIMIT_REACHED", `Task limit reached (${maxTasks}). Delete existing tasks or increase maxTasks config.`); } const now = new Date().toISOString(); // Autoresearch validation const taskType = definition.type ?? "standard"; if (taskType === "autoresearch") { const ar = definition.autoresearch; if (!ar) { throw TaskError.create("TASK_VALIDATION_FAILED", 'Tasks with type "autoresearch" require an autoresearch config.'); } if (!ar.repoPath || !ar.runCommand || !ar.mutablePaths?.length || !ar.metric) { throw TaskError.create("TASK_VALIDATION_FAILED", "Autoresearch config must include repoPath, runCommand, mutablePaths (non-empty), and metric."); } } // Reject autoresearch config on non-autoresearch tasks if (definition.autoresearch && taskType !== "autoresearch") { throw TaskError.create("TASK_VALIDATION_FAILED", 'Tasks with autoresearch config must have type "autoresearch".'); } const task = { id: `task_${nanoid(12)}`, name: definition.name, prompt: definition.prompt, schedule: definition.schedule, mode: definition.mode ?? TASK_DEFAULTS.mode, type: taskType, status: "active", tools: definition.tools ?? TASK_DEFAULTS.tools, timeout: definition.timeout ?? TASK_DEFAULTS.timeout, retry: { maxAttempts: definition.retry?.maxAttempts ?? TASK_DEFAULTS.retry.maxAttempts, backoffMs: definition.retry?.backoffMs ?? [ ...TASK_DEFAULTS.retry.backoffMs, ], }, runCount: 0, createdAt: now, updatedAt: now, // Optional overrides ...(definition.provider ? { provider: definition.provider } : {}), ...(definition.model ? { model: definition.model } : {}), ...(definition.thinkingLevel ? { thinkingLevel: definition.thinkingLevel } : {}), ...(definition.systemPrompt ? { systemPrompt: definition.systemPrompt } : {}), ...(definition.maxTokens ? { maxTokens: definition.maxTokens } : {}), ...(definition.temperature !== undefined ? { temperature: definition.temperature } : {}), ...(definition.maxRuns !== undefined ? { maxRuns: definition.maxRuns } : {}), ...(definition.metadata ? { metadata: definition.metadata } : {}), ...(definition.autoresearch ? { autoresearch: definition.autoresearch } : {}), }; // Generate session ID for continuation mode if (task.mode === "continuation") { task.sessionId = `session_${nanoid(12)}`; } // Save to store await store.save(task); // Register callbacks (in-memory only) if (definition.onSuccess || definition.onError || definition.onComplete) { this.callbacks.set(task.id, { onSuccess: definition.onSuccess, onError: definition.onError, onComplete: definition.onComplete, }); } // Schedule try { await backend.schedule(task, (t) => this.onTaskTick(t)); } catch (err) { this.callbacks.delete(task.id); try { await store.delete(task.id); } catch (cleanupError) { // Deletion failed — task remains persisted as active. Attempt to mark it // failed so it reaches a terminal state and operators can identify it. logger.error("[TaskManager] Failed to clean up task after schedule error — task may remain persisted as active", { taskId: task.id, scheduleError: String(err), cleanupError: String(cleanupError), }); try { await store.update(task.id, { status: "failed" }); } catch (terminalError) { logger.error("[TaskManager] Failed to force task to terminal state — manual cleanup required", { taskId: task.id, error: String(terminalError), }); } } throw err; } this.emit("task:created", task); logger.info("[TaskManager] Task created", { taskId: task.id, name: task.name, schedule: task.schedule.type, mode: task.mode, }); return task; } async get(taskId) { await this.ensureInitialized(); return this.getStore().get(taskId); } async list(filter) { await this.ensureInitialized(); return this.getStore().list(filter); } async update(taskId, updates) { await this.ensureInitialized(); const store = this.getStore(); const backend = this.getBackend(); const existing = await store.get(taskId); if (!existing) { throw TaskError.create("TASK_NOT_FOUND", `Task not found: ${taskId}`); } // Apply allowed scalar updates via whitelist const ALLOWED_UPDATE_FIELDS = [ "name", "prompt", "schedule", "mode", "provider", "model", "systemPrompt", "maxTokens", "temperature", "timeout", "tools", "maxRuns", "metadata", "thinkingLevel", ]; const taskUpdates = {}; for (const field of ALLOWED_UPDATE_FIELDS) { if (updates[field] !== undefined) { taskUpdates[field] = updates[field]; } } const shouldClearHistory = updates.mode !== undefined && updates.mode !== "continuation"; // Special-case: mode changes require sessionId handling if (updates.mode !== undefined) { if (updates.mode === "continuation" && !existing.sessionId) { taskUpdates.sessionId = `session_${nanoid(12)}`; } else if (updates.mode !== "continuation") { taskUpdates.sessionId = undefined; } } const updated = await store.update(taskId, taskUpdates); // Re-schedule if schedule changed and task is active if (updates.schedule && updated.status === "active") { const attemptedSchedule = updated.schedule; await backend.cancel(taskId); try { await backend.schedule(updated, (t) => this.onTaskTick(t)); } catch (error) { await this.restoreScheduledTask(existing, "update schedule rollback"); await this.rollbackTaskUpdate(taskId, existing, error); throw TaskError.create("SCHEDULE_FAILED", `Failed to update schedule for task ${taskId}`, { cause: error instanceof Error ? error : undefined, details: { taskId, previousSchedule: existing.schedule, attemptedSchedule, }, }); } } if (shouldClearHistory) { try { await store.clearHistory(taskId); } catch (error) { logger.warn("[TaskManager] Failed to clear task history after mode update", { taskId, error: String(error), }); } } return updated; } /** Run a task immediately (outside of its schedule) */ async run(taskId) { await this.ensureInitialized(); const task = await this.getStore().get(taskId); if (!task) { throw TaskError.create("TASK_NOT_FOUND", `Task not found: ${taskId}`); } return this.onTaskTick(task); } async pause(taskId) { await this.ensureInitialized(); const store = this.getStore(); const backend = this.getBackend(); const task = await store.get(taskId); if (!task) { throw TaskError.create("TASK_NOT_FOUND", `Task not found: ${taskId}`); } if (task.status !== "active") { throw TaskError.create("INVALID_TASK_STATUS", `Cannot pause task with status: ${task.status}`); } await backend.pause(taskId); let updated; try { updated = await store.update(taskId, { status: "paused" }); } catch (error) { await this.restoreScheduledTask(task, "pause rollback"); throw error; } this.emit("task:paused", updated); return updated; } async resume(taskId) { await this.ensureInitialized(); const store = this.getStore(); const backend = this.getBackend(); const task = await store.get(taskId); if (!task) { throw TaskError.create("TASK_NOT_FOUND", `Task not found: ${taskId}`); } if (task.status !== "paused") { throw TaskError.create("INVALID_TASK_STATUS", `Cannot resume task with status: ${task.status}`); } const updated = await store.update(taskId, { status: "active" }); try { await backend.schedule(updated, (t) => this.onTaskTick(t)); } catch (error) { await this.rollbackTaskUpdate(taskId, task, error); throw TaskError.create("SCHEDULE_FAILED", `Failed to resume task ${taskId}`, { cause: error instanceof Error ? error : undefined, details: { taskId, schedule: task.schedule }, }); } this.emit("task:resumed", updated); return updated; } async delete(taskId) { await this.ensureInitialized(); const backend = this.getBackend(); const store = this.getStore(); await backend.cancel(taskId); await store.delete(taskId); this.callbacks.delete(taskId); this.emit("task:deleted", taskId); } async runs(taskId, options) { await this.ensureInitialized(); return this.getStore().getRuns(taskId, options); } async shutdown() { if (this.backend) { await this.backend.shutdown(); } if (this.store) { await this.store.shutdown(); } this.callbacks.clear(); clearWorkerCache(); this.initialized = false; this.initPromise = null; logger.info("[TaskManager] Shut down"); } /** Check if the backend is healthy */ async isHealthy() { if (!this.backend) { return false; } return this.backend.isHealthy(); } // ── Internal ────────────────────────────────────────── async restoreScheduledTask(task, reason) { if (task.status !== "active") { return; } try { await this.getBackend().schedule(task, (t) => this.onTaskTick(t)); logger.warn("[TaskManager] Restored task schedule after rollback", { taskId: task.id, reason, }); } catch (restoreError) { logger.error("[TaskManager] Failed to restore task schedule during rollback", { taskId: task.id, reason, error: String(restoreError), }); } } async rollbackTaskUpdate(taskId, previousTask, error) { try { return await this.getStore().update(taskId, previousTask); } catch (rollbackError) { logger.error("[TaskManager] Failed to roll back task update — store and in-memory state may be diverged; manual reconciliation required", { taskId, originalError: String(error), rollbackError: String(rollbackError), }); throw rollbackError; } } /** * Called by the backend on each scheduled tick. * Executes the task, updates state, fires callbacks/events. */ async onTaskTick(task) { this.emit("task:started", task); const store = this.getStore(); const backend = this.getBackend(); const executor = this.getExecutor(); // Re-read latest task state (may have been updated/paused since scheduling) const current = await store.get(task.id); if (!current || current.status !== "active") { logger.debug("[TaskManager] Skipping tick for non-active task", { taskId: task.id, status: current?.status, }); return { taskId: task.id, runId: "skipped", status: "error", error: "Task is not active", durationMs: 0, timestamp: new Date().toISOString(), }; } const result = await executor.execute(current); // Log the run await store.appendRun(task.id, result); // Update task tracking const updates = { runCount: current.runCount + 1, lastRunAt: result.timestamp, }; // Check if task should complete if (current.maxRuns && current.runCount + 1 >= current.maxRuns) { updates.status = "completed"; await backend.cancel(task.id); } // Mark successful once tasks as completed if (result.status === "success" && current.schedule.type === "once") { updates.status = "completed"; await backend.cancel(task.id); } // Mark as failed on permanent error if (result.status === "error" && current.schedule.type === "once") { updates.status = "failed"; } await store.update(task.id, updates); // Fire callbacks const cbs = this.callbacks.get(task.id); if (cbs) { try { if (result.status === "success" && cbs.onSuccess) { await cbs.onSuccess(result); } if (result.status === "error" && cbs.onError) { await cbs.onError({ taskId: task.id, runId: result.runId, error: result.error ?? "Unknown error", attempt: 1, // Executor handles retries internally and returns final result maxAttempts: current.retry.maxAttempts, willRetry: false, timestamp: result.timestamp, }); } if (updates.status === "completed" || updates.status === "failed") { const finalTask = await store.get(task.id); if (finalTask && cbs.onComplete) { await cbs.onComplete(finalTask); } } } catch (cbErr) { logger.error("[TaskManager] Callback error", { taskId: task.id, error: String(cbErr), }); } } // Emit events if (result.status === "success") { this.emit("task:completed", result); } else { this.emit("task:failed", result); } return result; } /** * Re-schedule all active tasks from store. * Called on initialization to handle process restarts. */ async rescheduleActiveTasks() { const store = this.getStore(); const backend = this.getBackend(); const activeTasks = await store.list({ status: "active" }); for (const task of activeTasks) { try { await backend.schedule(task, (t) => this.onTaskTick(t)); logger.debug("[TaskManager] Re-scheduled task", { taskId: task.id, name: task.name, }); } catch (err) { logger.error("[TaskManager] Failed to re-schedule task", { taskId: task.id, error: String(err), }); } } if (activeTasks.length > 0) { logger.info("[TaskManager] Re-scheduled active tasks", { count: activeTasks.length, }); } } emit(event, ...args) { this.emitter?.emit(event, ...args); } }