import { uniqueNamesGenerator, adjectives, animals, } from 'unique-names-generator'; import { TaskStatus, isTerminalStatus } from './task-state.js'; import type { TaskState, TaskTypeName, Provider, PendingQuestion, } from './task-state.js'; import { TaskStore } from './task-store.js'; import type { TaskHandle } from './task-handle.js'; import type { TaskResult, SessionMetrics } from './task-handle.js'; import { createTaskHandle } from './task-handle-impl.js'; import { TaskFileWriter } from '../services/task-file-writer.js'; // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- /** Maximum lines retained in the in-memory output ring buffer. */ const OUTPUT_RING_BUFFER_MAX = 500; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export interface TaskManagerOptions { /** * Root directory for persistence files. * `undefined` means persistence is disabled (in-memory only). */ persistenceRoot: string | undefined; fileWriter?: TaskFileWriter | undefined; } export interface CreateTaskInput { prompt: string; cwd: string; provider: Provider; taskType: TaskTypeName; model?: string; effort?: 'low' | 'medium' | 'high' | 'xhigh'; timeoutMs?: number; dependsOn?: string[]; labels?: string[]; keepAlive?: number; } // --------------------------------------------------------------------------- // Event listener types // --------------------------------------------------------------------------- export type StatusChangeListener = ( task: TaskState, previousStatus: TaskStatus, ) => void; export type OutputListener = (taskId: string, line: string) => void; // --------------------------------------------------------------------------- // TaskManager // --------------------------------------------------------------------------- /** * Central orchestrator for task lifecycle management. * * Wraps {@link TaskStore} with: * - Human-readable ID generation (adjective-animal-number) * - {@link TaskHandle} creation for provider adapters * - Event bus (status changes, output lines) * - Output ring buffer capping * * Spec reference: §3.1 (layer model), §3.2 (TaskHandle) */ export class TaskManager { private readonly store = new TaskStore(); private readonly handles = new Map(); private readonly abortControllers = new Map(); private readonly abortListeners = new Map void>>(); private readonly statusListeners = new Set(); private readonly outputListeners = new Set(); private readonly createListeners = new Set<(task: TaskState) => void>(); private readonly lastMetaWrite = new Map(); readonly persistenceRoot: string | undefined; readonly fileWriter: TaskFileWriter | undefined; constructor(options: TaskManagerOptions) { this.persistenceRoot = options.persistenceRoot; this.fileWriter = options.fileWriter; } // ------------------------------------------------------------------------- // Task creation // ------------------------------------------------------------------------- /** * Create a new task with a human-readable ID, store it, and return * the initial {@link TaskState}. */ createTask(input: CreateTaskInput): TaskState { const id = this.generateId(); const now = new Date().toISOString(); const task: TaskState = { id, status: TaskStatus.PENDING, provider: input.provider, taskType: input.taskType, prompt: input.prompt, cwd: input.cwd, createdAt: now, updatedAt: now, labels: input.labels ?? [], output: [], pendingQuestions: [], }; // Only set optional properties when defined (exactOptionalPropertyTypes) if (input.model !== undefined) task.model = input.model; if (input.effort !== undefined) task.effort = input.effort; if (input.timeoutMs !== undefined) task.timeoutMs = input.timeoutMs; if (input.dependsOn !== undefined) task.dependsOn = input.dependsOn; if (input.keepAlive !== undefined) task.keepAlive = input.keepAlive; this.store.create(task); // Pre-create the handle so getHandle never returns undefined for a known task const handle = createTaskHandle(this, id); this.handles.set(id, handle); for (const listener of this.createListeners) { listener(task); } return task; } /** * Restore a previously persisted task into the store without generating * a new ID or firing create listeners. Used during startup recovery. */ restoreTask(task: TaskState): void { if (this.store.get(task.id)) return; // already loaded this.store.create(task); const handle = createTaskHandle(this, task.id); this.handles.set(task.id, handle); } // ------------------------------------------------------------------------- // Lookups // ------------------------------------------------------------------------- getTask(id: string): TaskState | undefined { return this.store.get(id); } getBySessionId(sessionId: string): TaskState | undefined { return this.store.getBySessionId(sessionId); } getHandle(id: string): TaskHandle | undefined { return this.handles.get(id); } getAllTasks(): TaskState[] { return this.store.getAll(); } // ------------------------------------------------------------------------- // Mutations (called by TaskHandle implementation) // ------------------------------------------------------------------------- /** * Apply a partial update to a task. * * If the update includes a status change, the FSM is validated by the store. * On successful status change the event bus is notified. */ updateTask(id: string, updates: Partial): TaskState | undefined { const prev = this.store.get(id); if (!prev) return undefined; const previousStatus = prev.status; const result = this.store.update(id, updates); if (result && result.status !== previousStatus) { this.emitStatusChange(result, previousStatus); } if (this.fileWriter && result) { // Throttle meta writes for tokenUsage-only updates to avoid disk thrash // (thread/tokenUsage/updated fires frequently during reasoning). const isTokenUsageOnly = Object.keys(updates).length === 1 && 'tokenUsage' in updates; if (isTokenUsageOnly) { this.refreshMetaPeriodically(id, result); } else { this.fileWriter.writeMeta(result).catch(() => {}); } } return result; } /** * Append a line to the task's in-memory output ring buffer. * Caps at {@link OUTPUT_RING_BUFFER_MAX} lines (oldest lines evicted). */ appendOutput(id: string, line: string): void { const task = this.store.get(id); if (!task) return; task.output.push(line); if (task.output.length > OUTPUT_RING_BUFFER_MAX) { task.output.splice(0, task.output.length - OUTPUT_RING_BUFFER_MAX); } task.lastOutputAt = new Date().toISOString(); task.updatedAt = task.lastOutputAt; for (const listener of this.outputListeners) { listener(id, line); } if (this.fileWriter) { this.fileWriter.appendSummary(id, line).catch(() => {}); this.refreshMetaPeriodically(id, task); } } /** * Record a verbose-only output line. * Updates lastOutputAt and fires the output listener with a prefix, * but does NOT push to the in-memory ring buffer. */ appendOutputFileOnly(id: string, line: string): void { const task = this.store.get(id); if (!task) return; task.lastOutputAt = new Date().toISOString(); task.updatedAt = task.lastOutputAt; for (const listener of this.outputListeners) { listener(id, `[verbose-only] ${line}`); } if (this.fileWriter) { this.fileWriter.appendVerbose(id, line).catch(() => {}); this.refreshMetaPeriodically(id, task); } } /** * Write meta.json at most once every 10 seconds per task. * Keeps disk state fresh without hammering I/O on every delta. */ private refreshMetaPeriodically(id: string, task: TaskState): void { if (!this.fileWriter) return; const lastWrite = this.lastMetaWrite.get(id) ?? 0; if (Date.now() - lastWrite > 10_000) { this.lastMetaWrite.set(id, Date.now()); this.fileWriter.writeMeta(task).catch(() => {}); } } // ------------------------------------------------------------------------- // Abort management // ------------------------------------------------------------------------- registerAbort(id: string, controller: AbortController): void { this.abortControllers.set(id, controller); } unregisterAbort(id: string): void { this.abortControllers.delete(id); } getAbortController(id: string): AbortController | undefined { return this.abortControllers.get(id); } /** * Register a callback for when a task's abort controller fires. * Returns an unsubscribe function. */ onAborted(id: string, cb: () => void): () => void { let listeners = this.abortListeners.get(id); if (!listeners) { listeners = new Set(); this.abortListeners.set(id, listeners); } listeners.add(cb); return () => { listeners!.delete(cb); }; } /** Fire abort listeners for a task. Called when AbortController signals. */ fireAbortListeners(id: string): void { const listeners = this.abortListeners.get(id); if (!listeners) return; for (const cb of listeners) { cb(); } } // ------------------------------------------------------------------------- // Pending question queue // ------------------------------------------------------------------------- queuePendingQuestion(id: string, q: PendingQuestion): void { const task = this.store.get(id); if (!task) return; task.pendingQuestions.push(q); task.updatedAt = new Date().toISOString(); } dequeuePendingQuestion(id: string): PendingQuestion | undefined { const task = this.store.get(id); if (!task) return undefined; const q = task.pendingQuestions.shift(); if (q !== undefined) { task.updatedAt = new Date().toISOString(); } return q; } getPendingQuestions(id: string): readonly PendingQuestion[] { const task = this.store.get(id); if (!task) return []; return task.pendingQuestions; } // ------------------------------------------------------------------------- // Event bus // ------------------------------------------------------------------------- /** * Subscribe to status change events. * Returns an unsubscribe function. */ onStatusChange(cb: StatusChangeListener): () => void { this.statusListeners.add(cb); return () => { this.statusListeners.delete(cb); }; } /** * Subscribe to output line events. * Returns an unsubscribe function. */ onOutput(cb: OutputListener): () => void { this.outputListeners.add(cb); return () => { this.outputListeners.delete(cb); }; } /** * Subscribe to task creation events. * Returns an unsubscribe function. */ onTaskCreate(cb: (task: TaskState) => void): () => void { this.createListeners.add(cb); return () => { this.createListeners.delete(cb); }; } /** * Emit a status change event to all listeners. * Public so that the TaskHandle implementation can call it directly. */ emitStatusChange(task: TaskState, previousStatus: TaskStatus): void { for (const listener of this.statusListeners) { listener(task, previousStatus); } } // ------------------------------------------------------------------------- // Eviction // ------------------------------------------------------------------------- /** * Remove terminal tasks whose keepAlive window has elapsed since completion. * Returns the number of tasks evicted. */ evictExpired(): number { const now = Date.now(); let count = 0; for (const task of this.store.getAll()) { if (!isTerminalStatus(task.status)) continue; if (!task.completedAt) continue; const keepAlive = task.keepAlive ?? 300_000; const completedMs = new Date(task.completedAt).getTime(); if (now - completedMs > keepAlive) { this.store.delete(task.id); this.handles.delete(task.id); this.abortControllers.delete(task.id); this.abortListeners.delete(task.id); count += 1; } } return count; } // ------------------------------------------------------------------------- // Internal helpers // ------------------------------------------------------------------------- private generateId(): string { const name = uniqueNamesGenerator({ dictionaries: [adjectives, animals], separator: '-', length: 2, }); const num = Math.floor(Math.random() * 900) + 100; // 100-999 return `${name}-${num}`; } }