From ff6141ddc10f705169a1138f1e918bbf9459096f Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Sun, 8 Mar 2026 02:24:20 -0300 Subject: [PATCH 1/5] feat: adiciona Cognitive Load Balancer para distribuicao inteligente de tarefas Implementa o modulo de balanceamento de carga cognitiva (Story ORCH-6) que distribui tarefas entre agentes com base em capacidade cognitiva usando algoritmo de afinidade: specialty match (40%), load inverse (30%), processing speed (20%) e success rate (10%). Funcionalidades: - Registro/desregistro de agentes com perfil cognitivo - Submissao e roteamento automatico de tarefas - Politicas de throttle: queue-when-full, reject-when-full, spillover - Rebalanceamento de tarefas entre agentes - Metricas de utilizacao e throughput - Persistencia de metricas em disco - 99 testes unitarios passando --- .../orchestration/cognitive-load-balancer.js | 934 ++++++++++++++ .../orchestration/cognitive-load-balancer.js | 934 ++++++++++++++ .aiox-core/install-manifest.yaml | 108 +- .../cognitive-load-balancer.test.js | 1130 +++++++++++++++++ 4 files changed, 3054 insertions(+), 52 deletions(-) create mode 100644 .aios-core/core/orchestration/cognitive-load-balancer.js create mode 100644 .aiox-core/core/orchestration/cognitive-load-balancer.js create mode 100644 tests/core/orchestration/cognitive-load-balancer.test.js diff --git a/.aios-core/core/orchestration/cognitive-load-balancer.js b/.aios-core/core/orchestration/cognitive-load-balancer.js new file mode 100644 index 000000000..3c2c102c8 --- /dev/null +++ b/.aios-core/core/orchestration/cognitive-load-balancer.js @@ -0,0 +1,934 @@ +/** + * Cognitive Load Balancer + * Story ORCH-6 - Intelligent task distribution based on agent cognitive capacity + * @module aiox-core/orchestration/cognitive-load-balancer + * @version 1.0.0 + */ + +'use strict'; + +const fs = require('fs').promises; +const path = require('path'); +const EventEmitter = require('events'); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONSTANTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +const METRICS_FILENAME = 'load-balancer-metrics.json'; +const METRICS_DIR = '.aiox'; + +/** Default max concurrent tasks per agent */ +const DEFAULT_MAX_LOAD = 100; + +/** Default processing speed multiplier */ +const DEFAULT_PROCESSING_SPEED = 1.0; + +/** Overload threshold percentage */ +const OVERLOAD_THRESHOLD = 85; + +/** Agent status enum */ +const AgentStatus = { + AVAILABLE: 'available', + BUSY: 'busy', + OVERLOADED: 'overloaded', + OFFLINE: 'offline', +}; + +/** Task status enum */ +const TaskStatus = { + QUEUED: 'queued', + ASSIGNED: 'assigned', + IN_PROGRESS: 'in-progress', + COMPLETED: 'completed', + FAILED: 'failed', +}; + +/** Task priority enum */ +const TaskPriority = { + LOW: 'low', + NORMAL: 'normal', + HIGH: 'high', + CRITICAL: 'critical', +}; + +/** Priority weight for scoring */ +const PRIORITY_WEIGHTS = { + [TaskPriority.LOW]: 1, + [TaskPriority.NORMAL]: 2, + [TaskPriority.HIGH]: 4, + [TaskPriority.CRITICAL]: 8, +}; + +/** Throttle policies */ +const ThrottlePolicy = { + QUEUE_WHEN_FULL: 'queue-when-full', + REJECT_WHEN_FULL: 'reject-when-full', + SPILLOVER: 'spillover', +}; + +/** Affinity weight distribution */ +const AFFINITY_WEIGHTS = { + SPECIALTY: 0.4, + LOAD_INVERSE: 0.3, + SPEED: 0.2, + SUCCESS_RATE: 0.1, +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// HELPER FUNCTIONS +// ═══════════════════════════════════════════════════════════════════════════════════ + +/** + * Generate a unique task ID + * @returns {string} Unique task ID + */ +function generateTaskId() { + return `task-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +/** + * Create a default agent profile + * @param {string} agentId - Agent identifier + * @param {Object} overrides - Profile overrides + * @returns {Object} Complete agent profile + */ +function createAgentProfile(agentId, overrides = {}) { + return { + id: agentId, + maxLoad: overrides.maxLoad ?? DEFAULT_MAX_LOAD, + currentLoad: 0, + specialties: overrides.specialties ?? [], + processingSpeed: overrides.processingSpeed ?? DEFAULT_PROCESSING_SPEED, + activeTasks: [], + completedCount: 0, + failedCount: 0, + totalCompletionTime: 0, + avgCompletionTime: 0, + status: AgentStatus.AVAILABLE, + }; +} + +/** + * Create a task object + * @param {Object} taskInput - Task input + * @returns {Object} Normalized task object + */ +function createTask(taskInput) { + return { + id: taskInput.id ?? generateTaskId(), + type: taskInput.type ?? 'general', + priority: taskInput.priority ?? TaskPriority.NORMAL, + complexity: Math.min(10, Math.max(1, taskInput.complexity ?? 5)), + requiredSpecialties: taskInput.requiredSpecialties ?? [], + assignedTo: null, + status: TaskStatus.QUEUED, + submittedAt: Date.now(), + startedAt: null, + completedAt: null, + result: null, + error: null, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// COGNITIVE LOAD BALANCER CLASS +// ═══════════════════════════════════════════════════════════════════════════════════ + +/** + * CognitiveLoadBalancer - Intelligent task distribution based on cognitive capacity + * + * Distributes tasks across agents using an affinity scoring algorithm: + * - Specialty match (40%) - How well agent specialties align with task requirements + * - Load inverse (30%) - Agents with less load score higher + * - Processing speed (20%) - Faster agents score higher + * - Success rate (10%) - Agents with better track records score higher + * + * Supports throttle policies for overload scenarios: + * - queue-when-full: Tasks wait in queue when all agents are at capacity + * - reject-when-full: Tasks are rejected when no agent can accept them + * - spillover: Tasks assigned to least-loaded agent regardless of capacity + * + * @extends EventEmitter + */ +class CognitiveLoadBalancer extends EventEmitter { + /** + * Creates a new CognitiveLoadBalancer instance + * @param {Object} [options={}] - Configuration options + * @param {string} [options.projectRoot] - Project root for metrics persistence + * @param {string} [options.throttlePolicy='queue-when-full'] - Default throttle policy + * @param {number} [options.maxQueueSize=1000] - Maximum queue size + * @param {boolean} [options.persistMetrics=true] - Whether to persist metrics to disk + */ + constructor(options = {}) { + super(); + + this.projectRoot = options.projectRoot ?? process.cwd(); + this.throttlePolicy = options.throttlePolicy ?? ThrottlePolicy.QUEUE_WHEN_FULL; + this.maxQueueSize = options.maxQueueSize ?? 1000; + this.persistMetrics = options.persistMetrics ?? true; + + /** @type {Map} Registered agents */ + this.agents = new Map(); + + /** @type {Map} All tasks (active + completed) */ + this.tasks = new Map(); + + /** @type {Array} Task queue (task IDs in order) */ + this.queue = []; + + /** @type {Object} Runtime metrics */ + this.metrics = { + totalSubmitted: 0, + totalCompleted: 0, + totalFailed: 0, + totalRejected: 0, + totalRebalanced: 0, + startTime: Date.now(), + }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // AGENT MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Register an agent with a cognitive profile + * @param {string} agentId - Unique agent identifier + * @param {Object} [profile={}] - Agent cognitive profile + * @param {number} [profile.maxLoad=100] - Maximum cognitive load capacity + * @param {string[]} [profile.specialties=[]] - List of specialties + * @param {number} [profile.processingSpeed=1.0] - Processing speed multiplier + * @returns {Object} Registered agent profile + * @throws {Error} If agentId is not a non-empty string + */ + registerAgent(agentId, profile = {}) { + if (!agentId || typeof agentId !== 'string') { + throw new Error('agentId must be a non-empty string'); + } + + const agentProfile = createAgentProfile(agentId, profile); + this.agents.set(agentId, agentProfile); + + this.emit('agent:registered', { agentId, profile: agentProfile }); + return agentProfile; + } + + /** + * Unregister an agent and redistribute its tasks + * @param {string} agentId - Agent to unregister + * @returns {string[]} IDs of tasks that were reassigned or queued + * @throws {Error} If agent is not found + */ + unregisterAgent(agentId) { + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent '${agentId}' not found`); + } + + const orphanedTaskIds = [...agent.activeTasks]; + + // Re-queue active tasks + for (const taskId of orphanedTaskIds) { + const task = this.tasks.get(taskId); + if (task) { + task.assignedTo = null; + task.status = TaskStatus.QUEUED; + task.startedAt = null; + this.queue.unshift(taskId); + } + } + + this.agents.delete(agentId); + this.emit('agent:unregistered', { agentId, orphanedTasks: orphanedTaskIds }); + + // Try to process queue after unregistration + this._processQueue(); + + return orphanedTaskIds; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // TASK SUBMISSION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Submit a task for automatic distribution + * @param {Object} taskInput - Task to submit + * @param {string} [taskInput.id] - Task ID (auto-generated if omitted) + * @param {string} [taskInput.type='general'] - Task type + * @param {string} [taskInput.priority='normal'] - Priority level + * @param {number} [taskInput.complexity=5] - Complexity 1-10 + * @param {string[]} [taskInput.requiredSpecialties=[]] - Required specialties + * @returns {Object} Submission result with taskId and assignedTo + */ + submitTask(taskInput) { + if (!taskInput || typeof taskInput !== 'object') { + throw new Error('Task must be a non-null object'); + } + + const task = createTask(taskInput); + this.tasks.set(task.id, task); + this.metrics.totalSubmitted++; + + this.emit('task:submitted', { taskId: task.id, task }); + + // Critical priority tasks bypass queue + if (task.priority === TaskPriority.CRITICAL) { + const agent = this._findOptimalAgent(task); + if (agent) { + this._assignTaskToAgent(task, agent); + return { taskId: task.id, assignedTo: agent.id, status: TaskStatus.ASSIGNED }; + } + // Even critical tasks can be queued if using queue policy + if (this.throttlePolicy === ThrottlePolicy.REJECT_WHEN_FULL) { + task.status = TaskStatus.FAILED; + task.error = 'No available agent for critical task'; + this.metrics.totalRejected++; + this.emit('task:failed', { taskId: task.id, error: task.error }); + return { taskId: task.id, assignedTo: null, status: TaskStatus.FAILED }; + } + } + + // Try to find an optimal agent + const optimalAgent = this._findOptimalAgent(task); + + if (optimalAgent) { + this._assignTaskToAgent(task, optimalAgent); + return { taskId: task.id, assignedTo: optimalAgent.id, status: TaskStatus.ASSIGNED }; + } + + // Handle overflow based on throttle policy + return this._handleOverflow(task); + } + + /** + * Manually assign a task to a specific agent + * @param {string} taskId - Task to assign + * @param {string} agentId - Target agent + * @returns {Object} Assignment result + * @throws {Error} If task or agent not found + */ + assignTask(taskId, agentId) { + const task = this.tasks.get(taskId); + if (!task) { + throw new Error(`Task '${taskId}' not found`); + } + + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent '${agentId}' not found`); + } + + // If task was in queue, remove it + const queueIndex = this.queue.indexOf(taskId); + if (queueIndex !== -1) { + this.queue.splice(queueIndex, 1); + } + + // If task was assigned to another agent, remove it + if (task.assignedTo) { + const prevAgent = this.agents.get(task.assignedTo); + if (prevAgent) { + this._removeTaskFromAgent(prevAgent, taskId); + } + } + + this._assignTaskToAgent(task, agent); + return { taskId, assignedTo: agentId, status: TaskStatus.ASSIGNED }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // TASK COMPLETION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Mark a task as completed and free capacity + * @param {string} taskId - Task to complete + * @param {*} [result=null] - Task result + * @returns {Object} Completion info + * @throws {Error} If task not found + */ + completeTask(taskId, result = null) { + const task = this.tasks.get(taskId); + if (!task) { + throw new Error(`Task '${taskId}' not found`); + } + + task.status = TaskStatus.COMPLETED; + task.completedAt = Date.now(); + task.result = result; + + const agent = task.assignedTo ? this.agents.get(task.assignedTo) : null; + if (agent) { + this._removeTaskFromAgent(agent, taskId); + agent.completedCount++; + const completionTime = task.completedAt - (task.startedAt ?? task.submittedAt); + agent.totalCompletionTime += completionTime; + agent.avgCompletionTime = agent.totalCompletionTime / agent.completedCount; + this._updateAgentStatus(agent); + } + + this.metrics.totalCompleted++; + this.emit('task:completed', { taskId, result, agentId: task.assignedTo }); + + // Try to process queue after freeing capacity + this._processQueue(); + + // Persist metrics + this._persistMetrics(); + + return { + taskId, + agentId: task.assignedTo, + completionTime: task.completedAt - (task.startedAt ?? task.submittedAt), + }; + } + + /** + * Mark a task as failed and free capacity + * @param {string} taskId - Task that failed + * @param {string|Error} [error='Unknown error'] - Error description + * @returns {Object} Failure info + * @throws {Error} If task not found + */ + failTask(taskId, error = 'Unknown error') { + const task = this.tasks.get(taskId); + if (!task) { + throw new Error(`Task '${taskId}' not found`); + } + + const errorMessage = error instanceof Error ? error.message : String(error); + task.status = TaskStatus.FAILED; + task.completedAt = Date.now(); + task.error = errorMessage; + + const agent = task.assignedTo ? this.agents.get(task.assignedTo) : null; + if (agent) { + this._removeTaskFromAgent(agent, taskId); + agent.failedCount++; + this._updateAgentStatus(agent); + } + + this.metrics.totalFailed++; + this.emit('task:failed', { taskId, error: errorMessage, agentId: task.assignedTo }); + + // Try to process queue after freeing capacity + this._processQueue(); + + // Persist metrics + this._persistMetrics(); + + return { + taskId, + agentId: task.assignedTo, + error: errorMessage, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // QUERY METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Get current load percentage for an agent (0-100%) + * @param {string} agentId - Agent to query + * @returns {number} Load percentage + * @throws {Error} If agent not found + */ + getAgentLoad(agentId) { + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent '${agentId}' not found`); + } + + if (agent.maxLoad === 0) return 100; + return Math.min(100, (agent.currentLoad / agent.maxLoad) * 100); + } + + /** + * Find the optimal agent for a task without assigning + * @param {Object} task - Task descriptor + * @returns {Object|null} Best agent info or null if none available + */ + getOptimalAgent(task) { + const normalizedTask = createTask(task); + const agent = this._findOptimalAgent(normalizedTask); + + if (!agent) return null; + + return { + agentId: agent.id, + currentLoad: this.getAgentLoad(agent.id), + affinityScore: this._calculateAffinityScore(agent, normalizedTask), + specialties: agent.specialties, + }; + } + + /** + * Get the current task queue + * @returns {Object[]} Queued tasks with details + */ + getQueue() { + return this.queue.map((taskId) => { + const task = this.tasks.get(taskId); + return task ? { ...task } : null; + }).filter(Boolean); + } + + /** + * Get comprehensive metrics + * @returns {Object} Metrics snapshot + */ + getMetrics() { + const agentUtilization = {}; + for (const [agentId, agent] of this.agents) { + agentUtilization[agentId] = { + load: agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 0, + activeTasks: agent.activeTasks.length, + completedCount: agent.completedCount, + failedCount: agent.failedCount, + avgCompletionTime: agent.avgCompletionTime, + successRate: this._getSuccessRate(agent), + status: agent.status, + }; + } + + const uptime = Date.now() - this.metrics.startTime; + const throughput = uptime > 0 + ? (this.metrics.totalCompleted / (uptime / 1000)) * 60 + : 0; + + return { + totalSubmitted: this.metrics.totalSubmitted, + totalCompleted: this.metrics.totalCompleted, + totalFailed: this.metrics.totalFailed, + totalRejected: this.metrics.totalRejected, + totalRebalanced: this.metrics.totalRebalanced, + queueLength: this.queue.length, + activeAgents: this.agents.size, + throughputPerMinute: Math.round(throughput * 100) / 100, + avgWaitTime: this._calculateAvgWaitTime(), + agentUtilization, + uptime, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // REBALANCING + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Rebalance tasks from overloaded to underloaded agents + * @returns {Object} Rebalance summary + */ + rebalance() { + const movements = []; + const overloaded = []; + const underloaded = []; + + // Categorize agents + for (const [, agent] of this.agents) { + if (agent.status === AgentStatus.OFFLINE) continue; + + const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; + if (loadPct > OVERLOAD_THRESHOLD) { + overloaded.push(agent); + } else if (loadPct < 50) { + underloaded.push(agent); + } + } + + if (overloaded.length === 0 || underloaded.length === 0) { + return { movements: [], overloadedCount: overloaded.length, underloadedCount: underloaded.length }; + } + + // Sort underloaded by available capacity (descending) + underloaded.sort((a, b) => { + const capA = a.maxLoad - a.currentLoad; + const capB = b.maxLoad - b.currentLoad; + return capB - capA; + }); + + // Move tasks from overloaded to underloaded + for (const source of overloaded) { + const tasksToMove = [...source.activeTasks]; + + for (const taskId of tasksToMove) { + const task = this.tasks.get(taskId); + if (!task) continue; + + // Find best underloaded target + const target = this._findBestRebalanceTarget(task, underloaded, source.id); + if (!target) continue; + + // Check if source is still overloaded + const sourceLoad = source.maxLoad > 0 ? (source.currentLoad / source.maxLoad) * 100 : 100; + if (sourceLoad <= OVERLOAD_THRESHOLD) break; + + // Move task + this._removeTaskFromAgent(source, taskId); + this._assignTaskToAgent(task, target); + + movements.push({ + taskId, + from: source.id, + to: target.id, + }); + + this.metrics.totalRebalanced++; + this.emit('task:rebalanced', { taskId, from: source.id, to: target.id }); + } + } + + // Update all agent statuses + for (const [, agent] of this.agents) { + this._updateAgentStatus(agent); + } + + return { + movements, + overloadedCount: overloaded.length, + underloadedCount: underloaded.length, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // THROTTLE POLICY + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Set throttle policy for overload scenarios + * @param {string} policy - Policy: 'queue-when-full', 'reject-when-full', 'spillover' + * @throws {Error} If policy is invalid + */ + setThrottlePolicy(policy) { + const validPolicies = Object.values(ThrottlePolicy); + if (!validPolicies.includes(policy)) { + throw new Error(`Invalid throttle policy '${policy}'. Valid: ${validPolicies.join(', ')}`); + } + this.throttlePolicy = policy; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // INTERNAL METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Calculate affinity score for an agent-task pair + * @param {Object} agent - Agent profile + * @param {Object} task - Task object + * @returns {number} Affinity score 0-1 + * @private + */ + _calculateAffinityScore(agent, task) { + // Specialty match (40%) + let specialtyScore = 0; + if (task.requiredSpecialties.length > 0 && agent.specialties.length > 0) { + const matches = task.requiredSpecialties.filter( + (s) => agent.specialties.includes(s) + ).length; + specialtyScore = matches / task.requiredSpecialties.length; + } else if (task.requiredSpecialties.length === 0) { + specialtyScore = 0.5; // Neutral when no specialties required + } + + // Load inverse (30%) - Less load = higher score + const loadPct = agent.maxLoad > 0 ? agent.currentLoad / agent.maxLoad : 1; + const loadScore = 1 - loadPct; + + // Processing speed (20%) + const speedScore = Math.min(1, agent.processingSpeed / 2.0); + + // Success rate (10%) + const successRate = this._getSuccessRate(agent); + + return ( + specialtyScore * AFFINITY_WEIGHTS.SPECIALTY + + loadScore * AFFINITY_WEIGHTS.LOAD_INVERSE + + speedScore * AFFINITY_WEIGHTS.SPEED + + successRate * AFFINITY_WEIGHTS.SUCCESS_RATE + ); + } + + /** + * Get success rate for an agent + * @param {Object} agent - Agent profile + * @returns {number} Success rate 0-1 + * @private + */ + _getSuccessRate(agent) { + const total = agent.completedCount + agent.failedCount; + if (total === 0) return 1; // Benefit of the doubt for new agents + return agent.completedCount / total; + } + + /** + * Find optimal agent for a task + * @param {Object} task - Task to assign + * @returns {Object|null} Best agent or null + * @private + */ + _findOptimalAgent(task) { + let bestAgent = null; + let bestScore = -1; + + for (const [, agent] of this.agents) { + if (agent.status === AgentStatus.OFFLINE) continue; + + // Check capacity (unless spillover policy) + if (this.throttlePolicy !== ThrottlePolicy.SPILLOVER) { + const loadAfter = agent.currentLoad + task.complexity; + if (loadAfter > agent.maxLoad) continue; + } + + const score = this._calculateAffinityScore(agent, task); + if (score > bestScore) { + bestScore = score; + bestAgent = agent; + } + } + + return bestAgent; + } + + /** + * Find best rebalance target from underloaded agents + * @param {Object} task - Task to move + * @param {Object[]} candidates - Underloaded agents + * @param {string} excludeId - Agent to exclude (source) + * @returns {Object|null} Best target agent + * @private + */ + _findBestRebalanceTarget(task, candidates, excludeId) { + let bestTarget = null; + let bestScore = -1; + + for (const candidate of candidates) { + if (candidate.id === excludeId) continue; + + const loadAfter = candidate.currentLoad + task.complexity; + if (loadAfter > candidate.maxLoad) continue; + + const score = this._calculateAffinityScore(candidate, task); + if (score > bestScore) { + bestScore = score; + bestTarget = candidate; + } + } + + return bestTarget; + } + + /** + * Assign a task to an agent (internal) + * @param {Object} task - Task object + * @param {Object} agent - Agent profile + * @private + */ + _assignTaskToAgent(task, agent) { + task.assignedTo = agent.id; + task.status = TaskStatus.ASSIGNED; + task.startedAt = Date.now(); + + agent.activeTasks.push(task.id); + agent.currentLoad += task.complexity; + + this._updateAgentStatus(agent); + this.emit('task:assigned', { taskId: task.id, agentId: agent.id }); + + // Check if agent became overloaded + const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; + if (loadPct >= OVERLOAD_THRESHOLD) { + this.emit('agent:overloaded', { agentId: agent.id, load: loadPct }); + } + } + + /** + * Remove a task from an agent's active list + * @param {Object} agent - Agent profile + * @param {string} taskId - Task to remove + * @private + */ + _removeTaskFromAgent(agent, taskId) { + const idx = agent.activeTasks.indexOf(taskId); + if (idx !== -1) { + agent.activeTasks.splice(idx, 1); + } + + const task = this.tasks.get(taskId); + if (task) { + agent.currentLoad = Math.max(0, agent.currentLoad - task.complexity); + } + + this._updateAgentStatus(agent); + + // Check if agent became available again + const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; + if (loadPct < OVERLOAD_THRESHOLD && agent.status !== AgentStatus.OFFLINE) { + this.emit('agent:available', { agentId: agent.id, load: loadPct }); + } + } + + /** + * Update agent status based on current load + * @param {Object} agent - Agent profile + * @private + */ + _updateAgentStatus(agent) { + if (agent.status === AgentStatus.OFFLINE) return; + + const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; + + if (loadPct >= OVERLOAD_THRESHOLD) { + agent.status = AgentStatus.OVERLOADED; + } else if (agent.activeTasks.length > 0) { + agent.status = AgentStatus.BUSY; + } else { + agent.status = AgentStatus.AVAILABLE; + } + } + + /** + * Handle overflow when no agent can accept the task + * @param {Object} task - Task to handle + * @returns {Object} Handling result + * @private + */ + _handleOverflow(task) { + switch (this.throttlePolicy) { + case ThrottlePolicy.QUEUE_WHEN_FULL: { + if (this.queue.length >= this.maxQueueSize) { + task.status = TaskStatus.FAILED; + task.error = 'Queue is full'; + this.metrics.totalRejected++; + this.emit('queue:full', { taskId: task.id, queueSize: this.queue.length }); + this.emit('task:failed', { taskId: task.id, error: task.error }); + return { taskId: task.id, assignedTo: null, status: TaskStatus.FAILED }; + } + this.queue.push(task.id); + return { taskId: task.id, assignedTo: null, status: TaskStatus.QUEUED }; + } + + case ThrottlePolicy.REJECT_WHEN_FULL: { + task.status = TaskStatus.FAILED; + task.error = 'All agents at capacity'; + this.metrics.totalRejected++; + this.emit('task:failed', { taskId: task.id, error: task.error }); + return { taskId: task.id, assignedTo: null, status: TaskStatus.FAILED }; + } + + case ThrottlePolicy.SPILLOVER: { + // Force assign to least loaded agent + const leastLoaded = this._findLeastLoadedAgent(); + if (leastLoaded) { + this._assignTaskToAgent(task, leastLoaded); + return { taskId: task.id, assignedTo: leastLoaded.id, status: TaskStatus.ASSIGNED }; + } + // No agents at all — queue it + this.queue.push(task.id); + return { taskId: task.id, assignedTo: null, status: TaskStatus.QUEUED }; + } + + default: + this.queue.push(task.id); + return { taskId: task.id, assignedTo: null, status: TaskStatus.QUEUED }; + } + } + + /** + * Find the least loaded agent (for spillover policy) + * @returns {Object|null} Least loaded agent + * @private + */ + _findLeastLoadedAgent() { + let bestAgent = null; + let lowestLoad = Infinity; + + for (const [, agent] of this.agents) { + if (agent.status === AgentStatus.OFFLINE) continue; + + const loadPct = agent.maxLoad > 0 ? agent.currentLoad / agent.maxLoad : 1; + if (loadPct < lowestLoad) { + lowestLoad = loadPct; + bestAgent = agent; + } + } + + return bestAgent; + } + + /** + * Process queued tasks, assigning to available agents + * @private + */ + _processQueue() { + if (this.queue.length === 0) return; + + const remaining = []; + + for (const taskId of this.queue) { + const task = this.tasks.get(taskId); + if (!task || task.status !== TaskStatus.QUEUED) continue; + + const agent = this._findOptimalAgent(task); + if (agent) { + this._assignTaskToAgent(task, agent); + } else { + remaining.push(taskId); + } + } + + this.queue = remaining; + } + + /** + * Calculate average wait time for completed tasks + * @returns {number} Average wait time in ms + * @private + */ + _calculateAvgWaitTime() { + let totalWait = 0; + let count = 0; + + for (const [, task] of this.tasks) { + if (task.startedAt && task.submittedAt) { + totalWait += task.startedAt - task.submittedAt; + count++; + } + } + + return count > 0 ? Math.round(totalWait / count) : 0; + } + + /** + * Persist metrics to disk + * @private + */ + async _persistMetrics() { + if (!this.persistMetrics) return; + + try { + const metricsDir = path.join(this.projectRoot, METRICS_DIR); + const metricsPath = path.join(metricsDir, METRICS_FILENAME); + + await fs.mkdir(metricsDir, { recursive: true }); + await fs.writeFile(metricsPath, JSON.stringify(this.getMetrics(), null, 2), 'utf8'); + } catch { + // Silently ignore persistence errors in production + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// EXPORTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +module.exports = CognitiveLoadBalancer; +module.exports.CognitiveLoadBalancer = CognitiveLoadBalancer; +module.exports.AgentStatus = AgentStatus; +module.exports.TaskStatus = TaskStatus; +module.exports.TaskPriority = TaskPriority; +module.exports.ThrottlePolicy = ThrottlePolicy; +module.exports.AFFINITY_WEIGHTS = AFFINITY_WEIGHTS; +module.exports.OVERLOAD_THRESHOLD = OVERLOAD_THRESHOLD; diff --git a/.aiox-core/core/orchestration/cognitive-load-balancer.js b/.aiox-core/core/orchestration/cognitive-load-balancer.js new file mode 100644 index 000000000..3c2c102c8 --- /dev/null +++ b/.aiox-core/core/orchestration/cognitive-load-balancer.js @@ -0,0 +1,934 @@ +/** + * Cognitive Load Balancer + * Story ORCH-6 - Intelligent task distribution based on agent cognitive capacity + * @module aiox-core/orchestration/cognitive-load-balancer + * @version 1.0.0 + */ + +'use strict'; + +const fs = require('fs').promises; +const path = require('path'); +const EventEmitter = require('events'); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONSTANTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +const METRICS_FILENAME = 'load-balancer-metrics.json'; +const METRICS_DIR = '.aiox'; + +/** Default max concurrent tasks per agent */ +const DEFAULT_MAX_LOAD = 100; + +/** Default processing speed multiplier */ +const DEFAULT_PROCESSING_SPEED = 1.0; + +/** Overload threshold percentage */ +const OVERLOAD_THRESHOLD = 85; + +/** Agent status enum */ +const AgentStatus = { + AVAILABLE: 'available', + BUSY: 'busy', + OVERLOADED: 'overloaded', + OFFLINE: 'offline', +}; + +/** Task status enum */ +const TaskStatus = { + QUEUED: 'queued', + ASSIGNED: 'assigned', + IN_PROGRESS: 'in-progress', + COMPLETED: 'completed', + FAILED: 'failed', +}; + +/** Task priority enum */ +const TaskPriority = { + LOW: 'low', + NORMAL: 'normal', + HIGH: 'high', + CRITICAL: 'critical', +}; + +/** Priority weight for scoring */ +const PRIORITY_WEIGHTS = { + [TaskPriority.LOW]: 1, + [TaskPriority.NORMAL]: 2, + [TaskPriority.HIGH]: 4, + [TaskPriority.CRITICAL]: 8, +}; + +/** Throttle policies */ +const ThrottlePolicy = { + QUEUE_WHEN_FULL: 'queue-when-full', + REJECT_WHEN_FULL: 'reject-when-full', + SPILLOVER: 'spillover', +}; + +/** Affinity weight distribution */ +const AFFINITY_WEIGHTS = { + SPECIALTY: 0.4, + LOAD_INVERSE: 0.3, + SPEED: 0.2, + SUCCESS_RATE: 0.1, +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// HELPER FUNCTIONS +// ═══════════════════════════════════════════════════════════════════════════════════ + +/** + * Generate a unique task ID + * @returns {string} Unique task ID + */ +function generateTaskId() { + return `task-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +/** + * Create a default agent profile + * @param {string} agentId - Agent identifier + * @param {Object} overrides - Profile overrides + * @returns {Object} Complete agent profile + */ +function createAgentProfile(agentId, overrides = {}) { + return { + id: agentId, + maxLoad: overrides.maxLoad ?? DEFAULT_MAX_LOAD, + currentLoad: 0, + specialties: overrides.specialties ?? [], + processingSpeed: overrides.processingSpeed ?? DEFAULT_PROCESSING_SPEED, + activeTasks: [], + completedCount: 0, + failedCount: 0, + totalCompletionTime: 0, + avgCompletionTime: 0, + status: AgentStatus.AVAILABLE, + }; +} + +/** + * Create a task object + * @param {Object} taskInput - Task input + * @returns {Object} Normalized task object + */ +function createTask(taskInput) { + return { + id: taskInput.id ?? generateTaskId(), + type: taskInput.type ?? 'general', + priority: taskInput.priority ?? TaskPriority.NORMAL, + complexity: Math.min(10, Math.max(1, taskInput.complexity ?? 5)), + requiredSpecialties: taskInput.requiredSpecialties ?? [], + assignedTo: null, + status: TaskStatus.QUEUED, + submittedAt: Date.now(), + startedAt: null, + completedAt: null, + result: null, + error: null, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// COGNITIVE LOAD BALANCER CLASS +// ═══════════════════════════════════════════════════════════════════════════════════ + +/** + * CognitiveLoadBalancer - Intelligent task distribution based on cognitive capacity + * + * Distributes tasks across agents using an affinity scoring algorithm: + * - Specialty match (40%) - How well agent specialties align with task requirements + * - Load inverse (30%) - Agents with less load score higher + * - Processing speed (20%) - Faster agents score higher + * - Success rate (10%) - Agents with better track records score higher + * + * Supports throttle policies for overload scenarios: + * - queue-when-full: Tasks wait in queue when all agents are at capacity + * - reject-when-full: Tasks are rejected when no agent can accept them + * - spillover: Tasks assigned to least-loaded agent regardless of capacity + * + * @extends EventEmitter + */ +class CognitiveLoadBalancer extends EventEmitter { + /** + * Creates a new CognitiveLoadBalancer instance + * @param {Object} [options={}] - Configuration options + * @param {string} [options.projectRoot] - Project root for metrics persistence + * @param {string} [options.throttlePolicy='queue-when-full'] - Default throttle policy + * @param {number} [options.maxQueueSize=1000] - Maximum queue size + * @param {boolean} [options.persistMetrics=true] - Whether to persist metrics to disk + */ + constructor(options = {}) { + super(); + + this.projectRoot = options.projectRoot ?? process.cwd(); + this.throttlePolicy = options.throttlePolicy ?? ThrottlePolicy.QUEUE_WHEN_FULL; + this.maxQueueSize = options.maxQueueSize ?? 1000; + this.persistMetrics = options.persistMetrics ?? true; + + /** @type {Map} Registered agents */ + this.agents = new Map(); + + /** @type {Map} All tasks (active + completed) */ + this.tasks = new Map(); + + /** @type {Array} Task queue (task IDs in order) */ + this.queue = []; + + /** @type {Object} Runtime metrics */ + this.metrics = { + totalSubmitted: 0, + totalCompleted: 0, + totalFailed: 0, + totalRejected: 0, + totalRebalanced: 0, + startTime: Date.now(), + }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // AGENT MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Register an agent with a cognitive profile + * @param {string} agentId - Unique agent identifier + * @param {Object} [profile={}] - Agent cognitive profile + * @param {number} [profile.maxLoad=100] - Maximum cognitive load capacity + * @param {string[]} [profile.specialties=[]] - List of specialties + * @param {number} [profile.processingSpeed=1.0] - Processing speed multiplier + * @returns {Object} Registered agent profile + * @throws {Error} If agentId is not a non-empty string + */ + registerAgent(agentId, profile = {}) { + if (!agentId || typeof agentId !== 'string') { + throw new Error('agentId must be a non-empty string'); + } + + const agentProfile = createAgentProfile(agentId, profile); + this.agents.set(agentId, agentProfile); + + this.emit('agent:registered', { agentId, profile: agentProfile }); + return agentProfile; + } + + /** + * Unregister an agent and redistribute its tasks + * @param {string} agentId - Agent to unregister + * @returns {string[]} IDs of tasks that were reassigned or queued + * @throws {Error} If agent is not found + */ + unregisterAgent(agentId) { + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent '${agentId}' not found`); + } + + const orphanedTaskIds = [...agent.activeTasks]; + + // Re-queue active tasks + for (const taskId of orphanedTaskIds) { + const task = this.tasks.get(taskId); + if (task) { + task.assignedTo = null; + task.status = TaskStatus.QUEUED; + task.startedAt = null; + this.queue.unshift(taskId); + } + } + + this.agents.delete(agentId); + this.emit('agent:unregistered', { agentId, orphanedTasks: orphanedTaskIds }); + + // Try to process queue after unregistration + this._processQueue(); + + return orphanedTaskIds; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // TASK SUBMISSION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Submit a task for automatic distribution + * @param {Object} taskInput - Task to submit + * @param {string} [taskInput.id] - Task ID (auto-generated if omitted) + * @param {string} [taskInput.type='general'] - Task type + * @param {string} [taskInput.priority='normal'] - Priority level + * @param {number} [taskInput.complexity=5] - Complexity 1-10 + * @param {string[]} [taskInput.requiredSpecialties=[]] - Required specialties + * @returns {Object} Submission result with taskId and assignedTo + */ + submitTask(taskInput) { + if (!taskInput || typeof taskInput !== 'object') { + throw new Error('Task must be a non-null object'); + } + + const task = createTask(taskInput); + this.tasks.set(task.id, task); + this.metrics.totalSubmitted++; + + this.emit('task:submitted', { taskId: task.id, task }); + + // Critical priority tasks bypass queue + if (task.priority === TaskPriority.CRITICAL) { + const agent = this._findOptimalAgent(task); + if (agent) { + this._assignTaskToAgent(task, agent); + return { taskId: task.id, assignedTo: agent.id, status: TaskStatus.ASSIGNED }; + } + // Even critical tasks can be queued if using queue policy + if (this.throttlePolicy === ThrottlePolicy.REJECT_WHEN_FULL) { + task.status = TaskStatus.FAILED; + task.error = 'No available agent for critical task'; + this.metrics.totalRejected++; + this.emit('task:failed', { taskId: task.id, error: task.error }); + return { taskId: task.id, assignedTo: null, status: TaskStatus.FAILED }; + } + } + + // Try to find an optimal agent + const optimalAgent = this._findOptimalAgent(task); + + if (optimalAgent) { + this._assignTaskToAgent(task, optimalAgent); + return { taskId: task.id, assignedTo: optimalAgent.id, status: TaskStatus.ASSIGNED }; + } + + // Handle overflow based on throttle policy + return this._handleOverflow(task); + } + + /** + * Manually assign a task to a specific agent + * @param {string} taskId - Task to assign + * @param {string} agentId - Target agent + * @returns {Object} Assignment result + * @throws {Error} If task or agent not found + */ + assignTask(taskId, agentId) { + const task = this.tasks.get(taskId); + if (!task) { + throw new Error(`Task '${taskId}' not found`); + } + + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent '${agentId}' not found`); + } + + // If task was in queue, remove it + const queueIndex = this.queue.indexOf(taskId); + if (queueIndex !== -1) { + this.queue.splice(queueIndex, 1); + } + + // If task was assigned to another agent, remove it + if (task.assignedTo) { + const prevAgent = this.agents.get(task.assignedTo); + if (prevAgent) { + this._removeTaskFromAgent(prevAgent, taskId); + } + } + + this._assignTaskToAgent(task, agent); + return { taskId, assignedTo: agentId, status: TaskStatus.ASSIGNED }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // TASK COMPLETION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Mark a task as completed and free capacity + * @param {string} taskId - Task to complete + * @param {*} [result=null] - Task result + * @returns {Object} Completion info + * @throws {Error} If task not found + */ + completeTask(taskId, result = null) { + const task = this.tasks.get(taskId); + if (!task) { + throw new Error(`Task '${taskId}' not found`); + } + + task.status = TaskStatus.COMPLETED; + task.completedAt = Date.now(); + task.result = result; + + const agent = task.assignedTo ? this.agents.get(task.assignedTo) : null; + if (agent) { + this._removeTaskFromAgent(agent, taskId); + agent.completedCount++; + const completionTime = task.completedAt - (task.startedAt ?? task.submittedAt); + agent.totalCompletionTime += completionTime; + agent.avgCompletionTime = agent.totalCompletionTime / agent.completedCount; + this._updateAgentStatus(agent); + } + + this.metrics.totalCompleted++; + this.emit('task:completed', { taskId, result, agentId: task.assignedTo }); + + // Try to process queue after freeing capacity + this._processQueue(); + + // Persist metrics + this._persistMetrics(); + + return { + taskId, + agentId: task.assignedTo, + completionTime: task.completedAt - (task.startedAt ?? task.submittedAt), + }; + } + + /** + * Mark a task as failed and free capacity + * @param {string} taskId - Task that failed + * @param {string|Error} [error='Unknown error'] - Error description + * @returns {Object} Failure info + * @throws {Error} If task not found + */ + failTask(taskId, error = 'Unknown error') { + const task = this.tasks.get(taskId); + if (!task) { + throw new Error(`Task '${taskId}' not found`); + } + + const errorMessage = error instanceof Error ? error.message : String(error); + task.status = TaskStatus.FAILED; + task.completedAt = Date.now(); + task.error = errorMessage; + + const agent = task.assignedTo ? this.agents.get(task.assignedTo) : null; + if (agent) { + this._removeTaskFromAgent(agent, taskId); + agent.failedCount++; + this._updateAgentStatus(agent); + } + + this.metrics.totalFailed++; + this.emit('task:failed', { taskId, error: errorMessage, agentId: task.assignedTo }); + + // Try to process queue after freeing capacity + this._processQueue(); + + // Persist metrics + this._persistMetrics(); + + return { + taskId, + agentId: task.assignedTo, + error: errorMessage, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // QUERY METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Get current load percentage for an agent (0-100%) + * @param {string} agentId - Agent to query + * @returns {number} Load percentage + * @throws {Error} If agent not found + */ + getAgentLoad(agentId) { + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent '${agentId}' not found`); + } + + if (agent.maxLoad === 0) return 100; + return Math.min(100, (agent.currentLoad / agent.maxLoad) * 100); + } + + /** + * Find the optimal agent for a task without assigning + * @param {Object} task - Task descriptor + * @returns {Object|null} Best agent info or null if none available + */ + getOptimalAgent(task) { + const normalizedTask = createTask(task); + const agent = this._findOptimalAgent(normalizedTask); + + if (!agent) return null; + + return { + agentId: agent.id, + currentLoad: this.getAgentLoad(agent.id), + affinityScore: this._calculateAffinityScore(agent, normalizedTask), + specialties: agent.specialties, + }; + } + + /** + * Get the current task queue + * @returns {Object[]} Queued tasks with details + */ + getQueue() { + return this.queue.map((taskId) => { + const task = this.tasks.get(taskId); + return task ? { ...task } : null; + }).filter(Boolean); + } + + /** + * Get comprehensive metrics + * @returns {Object} Metrics snapshot + */ + getMetrics() { + const agentUtilization = {}; + for (const [agentId, agent] of this.agents) { + agentUtilization[agentId] = { + load: agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 0, + activeTasks: agent.activeTasks.length, + completedCount: agent.completedCount, + failedCount: agent.failedCount, + avgCompletionTime: agent.avgCompletionTime, + successRate: this._getSuccessRate(agent), + status: agent.status, + }; + } + + const uptime = Date.now() - this.metrics.startTime; + const throughput = uptime > 0 + ? (this.metrics.totalCompleted / (uptime / 1000)) * 60 + : 0; + + return { + totalSubmitted: this.metrics.totalSubmitted, + totalCompleted: this.metrics.totalCompleted, + totalFailed: this.metrics.totalFailed, + totalRejected: this.metrics.totalRejected, + totalRebalanced: this.metrics.totalRebalanced, + queueLength: this.queue.length, + activeAgents: this.agents.size, + throughputPerMinute: Math.round(throughput * 100) / 100, + avgWaitTime: this._calculateAvgWaitTime(), + agentUtilization, + uptime, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // REBALANCING + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Rebalance tasks from overloaded to underloaded agents + * @returns {Object} Rebalance summary + */ + rebalance() { + const movements = []; + const overloaded = []; + const underloaded = []; + + // Categorize agents + for (const [, agent] of this.agents) { + if (agent.status === AgentStatus.OFFLINE) continue; + + const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; + if (loadPct > OVERLOAD_THRESHOLD) { + overloaded.push(agent); + } else if (loadPct < 50) { + underloaded.push(agent); + } + } + + if (overloaded.length === 0 || underloaded.length === 0) { + return { movements: [], overloadedCount: overloaded.length, underloadedCount: underloaded.length }; + } + + // Sort underloaded by available capacity (descending) + underloaded.sort((a, b) => { + const capA = a.maxLoad - a.currentLoad; + const capB = b.maxLoad - b.currentLoad; + return capB - capA; + }); + + // Move tasks from overloaded to underloaded + for (const source of overloaded) { + const tasksToMove = [...source.activeTasks]; + + for (const taskId of tasksToMove) { + const task = this.tasks.get(taskId); + if (!task) continue; + + // Find best underloaded target + const target = this._findBestRebalanceTarget(task, underloaded, source.id); + if (!target) continue; + + // Check if source is still overloaded + const sourceLoad = source.maxLoad > 0 ? (source.currentLoad / source.maxLoad) * 100 : 100; + if (sourceLoad <= OVERLOAD_THRESHOLD) break; + + // Move task + this._removeTaskFromAgent(source, taskId); + this._assignTaskToAgent(task, target); + + movements.push({ + taskId, + from: source.id, + to: target.id, + }); + + this.metrics.totalRebalanced++; + this.emit('task:rebalanced', { taskId, from: source.id, to: target.id }); + } + } + + // Update all agent statuses + for (const [, agent] of this.agents) { + this._updateAgentStatus(agent); + } + + return { + movements, + overloadedCount: overloaded.length, + underloadedCount: underloaded.length, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // THROTTLE POLICY + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Set throttle policy for overload scenarios + * @param {string} policy - Policy: 'queue-when-full', 'reject-when-full', 'spillover' + * @throws {Error} If policy is invalid + */ + setThrottlePolicy(policy) { + const validPolicies = Object.values(ThrottlePolicy); + if (!validPolicies.includes(policy)) { + throw new Error(`Invalid throttle policy '${policy}'. Valid: ${validPolicies.join(', ')}`); + } + this.throttlePolicy = policy; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // INTERNAL METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Calculate affinity score for an agent-task pair + * @param {Object} agent - Agent profile + * @param {Object} task - Task object + * @returns {number} Affinity score 0-1 + * @private + */ + _calculateAffinityScore(agent, task) { + // Specialty match (40%) + let specialtyScore = 0; + if (task.requiredSpecialties.length > 0 && agent.specialties.length > 0) { + const matches = task.requiredSpecialties.filter( + (s) => agent.specialties.includes(s) + ).length; + specialtyScore = matches / task.requiredSpecialties.length; + } else if (task.requiredSpecialties.length === 0) { + specialtyScore = 0.5; // Neutral when no specialties required + } + + // Load inverse (30%) - Less load = higher score + const loadPct = agent.maxLoad > 0 ? agent.currentLoad / agent.maxLoad : 1; + const loadScore = 1 - loadPct; + + // Processing speed (20%) + const speedScore = Math.min(1, agent.processingSpeed / 2.0); + + // Success rate (10%) + const successRate = this._getSuccessRate(agent); + + return ( + specialtyScore * AFFINITY_WEIGHTS.SPECIALTY + + loadScore * AFFINITY_WEIGHTS.LOAD_INVERSE + + speedScore * AFFINITY_WEIGHTS.SPEED + + successRate * AFFINITY_WEIGHTS.SUCCESS_RATE + ); + } + + /** + * Get success rate for an agent + * @param {Object} agent - Agent profile + * @returns {number} Success rate 0-1 + * @private + */ + _getSuccessRate(agent) { + const total = agent.completedCount + agent.failedCount; + if (total === 0) return 1; // Benefit of the doubt for new agents + return agent.completedCount / total; + } + + /** + * Find optimal agent for a task + * @param {Object} task - Task to assign + * @returns {Object|null} Best agent or null + * @private + */ + _findOptimalAgent(task) { + let bestAgent = null; + let bestScore = -1; + + for (const [, agent] of this.agents) { + if (agent.status === AgentStatus.OFFLINE) continue; + + // Check capacity (unless spillover policy) + if (this.throttlePolicy !== ThrottlePolicy.SPILLOVER) { + const loadAfter = agent.currentLoad + task.complexity; + if (loadAfter > agent.maxLoad) continue; + } + + const score = this._calculateAffinityScore(agent, task); + if (score > bestScore) { + bestScore = score; + bestAgent = agent; + } + } + + return bestAgent; + } + + /** + * Find best rebalance target from underloaded agents + * @param {Object} task - Task to move + * @param {Object[]} candidates - Underloaded agents + * @param {string} excludeId - Agent to exclude (source) + * @returns {Object|null} Best target agent + * @private + */ + _findBestRebalanceTarget(task, candidates, excludeId) { + let bestTarget = null; + let bestScore = -1; + + for (const candidate of candidates) { + if (candidate.id === excludeId) continue; + + const loadAfter = candidate.currentLoad + task.complexity; + if (loadAfter > candidate.maxLoad) continue; + + const score = this._calculateAffinityScore(candidate, task); + if (score > bestScore) { + bestScore = score; + bestTarget = candidate; + } + } + + return bestTarget; + } + + /** + * Assign a task to an agent (internal) + * @param {Object} task - Task object + * @param {Object} agent - Agent profile + * @private + */ + _assignTaskToAgent(task, agent) { + task.assignedTo = agent.id; + task.status = TaskStatus.ASSIGNED; + task.startedAt = Date.now(); + + agent.activeTasks.push(task.id); + agent.currentLoad += task.complexity; + + this._updateAgentStatus(agent); + this.emit('task:assigned', { taskId: task.id, agentId: agent.id }); + + // Check if agent became overloaded + const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; + if (loadPct >= OVERLOAD_THRESHOLD) { + this.emit('agent:overloaded', { agentId: agent.id, load: loadPct }); + } + } + + /** + * Remove a task from an agent's active list + * @param {Object} agent - Agent profile + * @param {string} taskId - Task to remove + * @private + */ + _removeTaskFromAgent(agent, taskId) { + const idx = agent.activeTasks.indexOf(taskId); + if (idx !== -1) { + agent.activeTasks.splice(idx, 1); + } + + const task = this.tasks.get(taskId); + if (task) { + agent.currentLoad = Math.max(0, agent.currentLoad - task.complexity); + } + + this._updateAgentStatus(agent); + + // Check if agent became available again + const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; + if (loadPct < OVERLOAD_THRESHOLD && agent.status !== AgentStatus.OFFLINE) { + this.emit('agent:available', { agentId: agent.id, load: loadPct }); + } + } + + /** + * Update agent status based on current load + * @param {Object} agent - Agent profile + * @private + */ + _updateAgentStatus(agent) { + if (agent.status === AgentStatus.OFFLINE) return; + + const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; + + if (loadPct >= OVERLOAD_THRESHOLD) { + agent.status = AgentStatus.OVERLOADED; + } else if (agent.activeTasks.length > 0) { + agent.status = AgentStatus.BUSY; + } else { + agent.status = AgentStatus.AVAILABLE; + } + } + + /** + * Handle overflow when no agent can accept the task + * @param {Object} task - Task to handle + * @returns {Object} Handling result + * @private + */ + _handleOverflow(task) { + switch (this.throttlePolicy) { + case ThrottlePolicy.QUEUE_WHEN_FULL: { + if (this.queue.length >= this.maxQueueSize) { + task.status = TaskStatus.FAILED; + task.error = 'Queue is full'; + this.metrics.totalRejected++; + this.emit('queue:full', { taskId: task.id, queueSize: this.queue.length }); + this.emit('task:failed', { taskId: task.id, error: task.error }); + return { taskId: task.id, assignedTo: null, status: TaskStatus.FAILED }; + } + this.queue.push(task.id); + return { taskId: task.id, assignedTo: null, status: TaskStatus.QUEUED }; + } + + case ThrottlePolicy.REJECT_WHEN_FULL: { + task.status = TaskStatus.FAILED; + task.error = 'All agents at capacity'; + this.metrics.totalRejected++; + this.emit('task:failed', { taskId: task.id, error: task.error }); + return { taskId: task.id, assignedTo: null, status: TaskStatus.FAILED }; + } + + case ThrottlePolicy.SPILLOVER: { + // Force assign to least loaded agent + const leastLoaded = this._findLeastLoadedAgent(); + if (leastLoaded) { + this._assignTaskToAgent(task, leastLoaded); + return { taskId: task.id, assignedTo: leastLoaded.id, status: TaskStatus.ASSIGNED }; + } + // No agents at all — queue it + this.queue.push(task.id); + return { taskId: task.id, assignedTo: null, status: TaskStatus.QUEUED }; + } + + default: + this.queue.push(task.id); + return { taskId: task.id, assignedTo: null, status: TaskStatus.QUEUED }; + } + } + + /** + * Find the least loaded agent (for spillover policy) + * @returns {Object|null} Least loaded agent + * @private + */ + _findLeastLoadedAgent() { + let bestAgent = null; + let lowestLoad = Infinity; + + for (const [, agent] of this.agents) { + if (agent.status === AgentStatus.OFFLINE) continue; + + const loadPct = agent.maxLoad > 0 ? agent.currentLoad / agent.maxLoad : 1; + if (loadPct < lowestLoad) { + lowestLoad = loadPct; + bestAgent = agent; + } + } + + return bestAgent; + } + + /** + * Process queued tasks, assigning to available agents + * @private + */ + _processQueue() { + if (this.queue.length === 0) return; + + const remaining = []; + + for (const taskId of this.queue) { + const task = this.tasks.get(taskId); + if (!task || task.status !== TaskStatus.QUEUED) continue; + + const agent = this._findOptimalAgent(task); + if (agent) { + this._assignTaskToAgent(task, agent); + } else { + remaining.push(taskId); + } + } + + this.queue = remaining; + } + + /** + * Calculate average wait time for completed tasks + * @returns {number} Average wait time in ms + * @private + */ + _calculateAvgWaitTime() { + let totalWait = 0; + let count = 0; + + for (const [, task] of this.tasks) { + if (task.startedAt && task.submittedAt) { + totalWait += task.startedAt - task.submittedAt; + count++; + } + } + + return count > 0 ? Math.round(totalWait / count) : 0; + } + + /** + * Persist metrics to disk + * @private + */ + async _persistMetrics() { + if (!this.persistMetrics) return; + + try { + const metricsDir = path.join(this.projectRoot, METRICS_DIR); + const metricsPath = path.join(metricsDir, METRICS_FILENAME); + + await fs.mkdir(metricsDir, { recursive: true }); + await fs.writeFile(metricsPath, JSON.stringify(this.getMetrics(), null, 2), 'utf8'); + } catch { + // Silently ignore persistence errors in production + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// EXPORTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +module.exports = CognitiveLoadBalancer; +module.exports.CognitiveLoadBalancer = CognitiveLoadBalancer; +module.exports.AgentStatus = AgentStatus; +module.exports.TaskStatus = TaskStatus; +module.exports.TaskPriority = TaskPriority; +module.exports.ThrottlePolicy = ThrottlePolicy; +module.exports.AFFINITY_WEIGHTS = AFFINITY_WEIGHTS; +module.exports.OVERLOAD_THRESHOLD = OVERLOAD_THRESHOLD; diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 2516e4c29..f95778f30 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-10T17:08:07.160Z" +generated_at: "2026-03-11T02:22:49.781Z" generator: scripts/generate-install-manifest.js -file_count: 1089 +file_count: 1090 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -828,6 +828,10 @@ files: hash: sha256:cedd09f6938b3e1e0e19c06f4763de00281ddb31529d16ab9e4f74d194a3bd3b type: core size: 19293 + - path: core/orchestration/cognitive-load-balancer.js + hash: sha256:1b1903400ef45fe55b1cd02a0a824fd4a17fdfe368bf319ce2951ca59fee734b + type: core + size: 32804 - path: core/orchestration/condition-evaluator.js hash: sha256:8bf565cf56194340ff4e1d642647150775277bce649411d0338faa2c96106745 type: core @@ -2583,19 +2587,19 @@ files: - path: development/templates/service-template/__tests__/index.test.ts.hbs hash: sha256:4617c189e75ab362d4ef2cabcc3ccce3480f914fd915af550469c17d1b68a4fe type: template - size: 9810 + size: 9573 - path: development/templates/service-template/client.ts.hbs hash: sha256:f342c60695fe611192002bdb8c04b3a0dbce6345b7fa39834ea1898f71689198 type: template - size: 12213 + size: 11810 - path: development/templates/service-template/errors.ts.hbs hash: sha256:e0be40d8be19b71b26e35778eadffb20198e7ca88e9d140db9da1bfe12de01ec type: template - size: 5395 + size: 5213 - path: development/templates/service-template/index.ts.hbs hash: sha256:d44012d54b76ab98356c7163d257ca939f7fed122f10fecf896fe1e7e206d10a type: template - size: 3206 + size: 3086 - path: development/templates/service-template/jest.config.js hash: sha256:1681bfd7fbc0d330d3487d3427515847c4d57ef300833f573af59e0ad69ed159 type: template @@ -2603,11 +2607,11 @@ files: - path: development/templates/service-template/package.json.hbs hash: sha256:d89d35f56992ee95c2ceddf17fa1d455c18007a4d24af914ba83cf4abc38bca9 type: template - size: 2314 + size: 2227 - path: development/templates/service-template/README.md.hbs hash: sha256:2c3dd4c2bf6df56b9b6db439977be7e1cc35820438c0e023140eccf6ccd227a0 type: template - size: 3584 + size: 3426 - path: development/templates/service-template/tsconfig.json hash: sha256:8b465fcbdd45c4d6821ba99aea62f2bd7998b1bca8de80486a1525e77d43c9a1 type: template @@ -2615,7 +2619,7 @@ files: - path: development/templates/service-template/types.ts.hbs hash: sha256:3e52e0195003be8cd1225a3f27f4d040686c8b8c7762f71b41055f04cd1b841b type: template - size: 2661 + size: 2516 - path: development/templates/squad-template/agents/example-agent.yaml hash: sha256:824a1b349965e5d4ae85458c231b78260dc65497da75dada25b271f2cabbbe67 type: agent @@ -2623,7 +2627,7 @@ files: - path: development/templates/squad-template/LICENSE hash: sha256:ff7017aa403270cf2c440f5ccb4240d0b08e54d8bf8a0424d34166e8f3e10138 type: template - size: 1092 + size: 1071 - path: development/templates/squad-template/package.json hash: sha256:8f68627a0d74e49f94ae382d0c2b56ecb5889d00f3095966c742fb5afaf363db type: template @@ -3367,11 +3371,11 @@ files: - path: infrastructure/templates/aiox-sync.yaml.template hash: sha256:0040ad8a9e25716a28631b102c9448b72fd72e84f992c3926eb97e9e514744bb type: template - size: 8567 + size: 8385 - path: infrastructure/templates/coderabbit.yaml.template hash: sha256:91a4a76bbc40767a4072fb6a87c480902bb800cfb0a11e9fc1b3183d8f7f3a80 type: template - size: 8321 + size: 8042 - path: infrastructure/templates/core-config/core-config-brownfield.tmpl.yaml hash: sha256:9bdb0c0e09c765c991f9f142921f7f8e2c0d0ada717f41254161465dc0622d02 type: template @@ -3383,11 +3387,11 @@ files: - path: infrastructure/templates/github-workflows/ci.yml.template hash: sha256:acbfa2a8a84141fd6a6b205eac74719772f01c221c0afe22ce951356f06a605d type: template - size: 5089 + size: 4920 - path: infrastructure/templates/github-workflows/pr-automation.yml.template hash: sha256:c236077b4567965a917e48df9a91cc42153ff97b00a9021c41a7e28179be9d0f type: template - size: 10939 + size: 10609 - path: infrastructure/templates/github-workflows/README.md hash: sha256:6b7b5cb32c28b3e562c81a96e2573ea61849b138c93ccac6e93c3adac26cadb5 type: template @@ -3395,23 +3399,23 @@ files: - path: infrastructure/templates/github-workflows/release.yml.template hash: sha256:b771145e61a254a88dc6cca07869e4ece8229ce18be87132f59489cdf9a66ec6 type: template - size: 6791 + size: 6595 - path: infrastructure/templates/gitignore/gitignore-aiox-base.tmpl hash: sha256:9088975ee2bf4d88e23db6ac3ea5d27cccdc72b03db44450300e2f872b02e935 type: template - size: 851 + size: 788 - path: infrastructure/templates/gitignore/gitignore-brownfield-merge.tmpl hash: sha256:ce4291a3cf5677050c9dafa320809e6b0ca5db7e7f7da0382d2396e32016a989 type: template - size: 506 + size: 488 - path: infrastructure/templates/gitignore/gitignore-node.tmpl hash: sha256:5179f78de7483274f5d7182569229088c71934db1fd37a63a40b3c6b815c9c8e type: template - size: 1036 + size: 951 - path: infrastructure/templates/gitignore/gitignore-python.tmpl hash: sha256:d7aac0b1e6e340b774a372a9102b4379722588449ca82ac468cf77804bbc1e55 type: template - size: 1725 + size: 1580 - path: infrastructure/templates/project-docs/coding-standards-tmpl.md hash: sha256:377acf85463df8ac9923fc59d7cfeba68a82f8353b99948ea1d28688e88bc4a9 type: template @@ -3507,43 +3511,43 @@ files: - path: monitor/hooks/lib/__init__.py hash: sha256:bfab6ee249c52f412c02502479da649b69d044938acaa6ab0aa39dafe6dee9bf type: monitor - size: 30 + size: 29 - path: monitor/hooks/lib/enrich.py hash: sha256:20dfa73b4b20d7a767e52c3ec90919709c4447c6e230902ba797833fc6ddc22c type: monitor - size: 1702 + size: 1644 - path: monitor/hooks/lib/send_event.py hash: sha256:59d61311f718fb373a5cf85fd7a01c23a4fd727e8e022ad6930bba533ef4615d type: monitor - size: 1237 + size: 1190 - path: monitor/hooks/notification.py hash: sha256:8a1a6ce0ff2b542014de177006093b9caec9b594e938a343dc6bd62df2504f22 type: monitor - size: 528 + size: 499 - path: monitor/hooks/post_tool_use.py hash: sha256:47dbe37073d432c55657647fc5b907ddb56efa859d5c3205e8362aa916d55434 type: monitor - size: 1185 + size: 1140 - path: monitor/hooks/pre_compact.py hash: sha256:f287cf45e83deed6f1bc0e30bd9348dfa1bf08ad770c5e58bb34e3feb210b30b type: monitor - size: 529 + size: 500 - path: monitor/hooks/pre_tool_use.py hash: sha256:a4d1d3ffdae9349e26a383c67c9137effff7d164ac45b2c87eea9fa1ab0d6d98 type: monitor - size: 1021 + size: 981 - path: monitor/hooks/stop.py hash: sha256:edb382f0cf46281a11a8588bc20eafa7aa2b5cc3f4ad775d71b3d20a7cfab385 type: monitor - size: 519 + size: 490 - path: monitor/hooks/subagent_stop.py hash: sha256:fa5357309247c71551dba0a19f28dd09bebde749db033d6657203b50929c0a42 type: monitor - size: 541 + size: 512 - path: monitor/hooks/user_prompt_submit.py hash: sha256:af57dca79ef55cdf274432f4abb4c20a9778b95e107ca148f47ace14782c5828 type: monitor - size: 856 + size: 818 - path: package.json hash: sha256:9fdf0dcee2dcec6c0643634ee384ba181ad077dcff1267d8807434d4cb4809c7 type: other @@ -3691,7 +3695,7 @@ files: - path: product/templates/adr.hbs hash: sha256:d68653cae9e64414ad4f58ea941b6c6e337c5324c2c7247043eca1461a652d10 type: template - size: 2337 + size: 2212 - path: product/templates/agent-template.yaml hash: sha256:98676fcc493c0d5f09264dcc52fcc2cf1129f9a195824ecb4c2ec035c2515121 type: template @@ -3743,7 +3747,7 @@ files: - path: product/templates/dbdr.hbs hash: sha256:5a2781ffaa3da9fc663667b5a63a70b7edfc478ed14cad02fc6ed237ff216315 type: template - size: 4380 + size: 4139 - path: product/templates/design-story-tmpl.yaml hash: sha256:2bfefc11ae2bcfc679dbd924c58f8b764fa23538c14cb25344d6edef41968f29 type: template @@ -3807,7 +3811,7 @@ files: - path: product/templates/epic.hbs hash: sha256:dcbcc26f6dd8f3782b3ef17aee049b689f1d6d92931615c3df9513eca0de2ef7 type: template - size: 4080 + size: 3868 - path: product/templates/eslintrc-security.json hash: sha256:657d40117261d6a52083984d29f9f88e79040926a64aa4c2058a602bfe91e0d5 type: template @@ -3915,7 +3919,7 @@ files: - path: product/templates/pmdr.hbs hash: sha256:d529cebbb562faa82c70477ece70de7cda871eaa6896f2962b48b2a8b67b1cbe type: template - size: 3425 + size: 3239 - path: product/templates/prd-tmpl.yaml hash: sha256:25c239f40e05f24aee1986601a98865188dbe3ea00a705028efc3adad6d420f3 type: template @@ -3923,11 +3927,11 @@ files: - path: product/templates/prd-v2.0.hbs hash: sha256:21a20ef5333a85a11f5326d35714e7939b51bab22bd6e28d49bacab755763bea type: template - size: 4728 + size: 4512 - path: product/templates/prd.hbs hash: sha256:4a1a030a5388c6a8bf2ce6ea85e54cae6cf1fe64f1bb2af7f17d349d3c24bf1d type: template - size: 3626 + size: 3425 - path: product/templates/project-brief-tmpl.yaml hash: sha256:b8d388268c24dc5018f48a87036d591b11cb122fafe9b59c17809b06ea5d9d58 type: template @@ -3975,7 +3979,7 @@ files: - path: product/templates/story.hbs hash: sha256:3f0ac8b39907634a2b53f43079afc33663eee76f46e680d318ff253e0befc2c4 type: template - size: 5846 + size: 5583 - path: product/templates/task-execution-report.md hash: sha256:e0f08a3e199234f3d2207ba8f435786b7d8e1b36174f46cb82fc3666b9a9309e type: template @@ -3987,67 +3991,67 @@ files: - path: product/templates/task.hbs hash: sha256:621e987e142c455cd290dc85d990ab860faa0221f66cf1f57ac296b076889ea5 type: template - size: 2875 + size: 2705 - path: product/templates/tmpl-comment-on-examples.sql hash: sha256:254002c3fbc63cfcc5848b1d4b15822ce240bf5f57e6a1c8bb984e797edc2691 type: template - size: 6373 + size: 6215 - path: product/templates/tmpl-migration-script.sql hash: sha256:44ef63ea475526d21a11e3c667c9fdb78a9fddace80fdbaa2312b7f2724fbbb5 type: template - size: 3038 + size: 2947 - path: product/templates/tmpl-rls-granular-policies.sql hash: sha256:36c2fd8c6d9eebb5d164acb0fb0c87bc384d389264b4429ce21e77e06318f5f3 type: template - size: 3426 + size: 3322 - path: product/templates/tmpl-rls-kiss-policy.sql hash: sha256:5210d37fce62e5a9a00e8d5366f5f75653cd518be73fbf96333ed8a6712453c7 type: template - size: 309 + size: 299 - path: product/templates/tmpl-rls-roles.sql hash: sha256:2d032a608a8e87440c3a430c7d69ddf9393d8813d8d4129270f640dd847425c3 type: template - size: 4727 + size: 4592 - path: product/templates/tmpl-rls-simple.sql hash: sha256:f67af0fa1cdd2f2af9eab31575ac3656d82457421208fd9ccb8b57ca9785275e type: template - size: 2992 + size: 2915 - path: product/templates/tmpl-rls-tenant.sql hash: sha256:36629ed87a2c72311809cc3fb96298b6f38716bba35bc56c550ac39d3321757a type: template - size: 5130 + size: 4978 - path: product/templates/tmpl-rollback-script.sql hash: sha256:8b84046a98f1163faf7350322f43831447617c5a63a94c88c1a71b49804e022b type: template - size: 2734 + size: 2657 - path: product/templates/tmpl-seed-data.sql hash: sha256:a65e73298f46cd6a8e700f29b9d8d26e769e12a57751a943a63fd0fe15768615 type: template - size: 5716 + size: 5576 - path: product/templates/tmpl-smoke-test.sql hash: sha256:aee7e48bb6d9c093769dee215cacc9769939501914e20e5ea8435b25fad10f3c type: template - size: 739 + size: 723 - path: product/templates/tmpl-staging-copy-merge.sql hash: sha256:55988caeb47cc04261665ba7a37f4caa2aa5fac2e776fdbc5964e0587af24450 type: template - size: 4220 + size: 4081 - path: product/templates/tmpl-stored-proc.sql hash: sha256:2b205ff99dc0adfade6047a4d79f5b50109e50ceb45386e5c886437692c7a2a3 type: template - size: 3979 + size: 3839 - path: product/templates/tmpl-trigger.sql hash: sha256:93abdc92e1b475d1370094e69a9d1b18afd804da6acb768b878355c798bd8e0e type: template - size: 5424 + size: 5272 - path: product/templates/tmpl-view-materialized.sql hash: sha256:47935510f03d4ad9b2200748e65441ce6c2d6a7c74750395eca6831d77c48e91 type: template - size: 4496 + size: 4363 - path: product/templates/tmpl-view.sql hash: sha256:22557b076003a856b32397f05fa44245a126521de907058a95e14dd02da67aff type: template - size: 5093 + size: 4916 - path: product/templates/token-exports-css-tmpl.css hash: sha256:d937b8d61cdc9e5b10fdff871c6cb41c9f756004d060d671e0ae26624a047f62 type: template diff --git a/tests/core/orchestration/cognitive-load-balancer.test.js b/tests/core/orchestration/cognitive-load-balancer.test.js new file mode 100644 index 000000000..72068c03f --- /dev/null +++ b/tests/core/orchestration/cognitive-load-balancer.test.js @@ -0,0 +1,1130 @@ +/** + * Cognitive Load Balancer Tests + * + * Story ORCH-6 - Intelligent task distribution based on agent cognitive capacity + * + * Tests the core functionality of the CognitiveLoadBalancer class including: + * - Agent registration and unregistration + * - Task submission and routing + * - Affinity scoring algorithm + * - Throttle policies + * - Rebalancing + * - Metrics and queue management + * + * @version 1.0.0 + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const CognitiveLoadBalancer = require('../../../.aiox-core/core/orchestration/cognitive-load-balancer'); +const { + AgentStatus, + TaskStatus, + TaskPriority, + ThrottlePolicy, + AFFINITY_WEIGHTS, + OVERLOAD_THRESHOLD, +} = CognitiveLoadBalancer; + +describe('CognitiveLoadBalancer', () => { + let balancer; + let tempDir; + + beforeEach(() => { + tempDir = path.join(os.tmpdir(), `clb-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + fs.mkdirSync(tempDir, { recursive: true }); + + balancer = new CognitiveLoadBalancer({ + projectRoot: tempDir, + persistMetrics: false, + }); + }); + + afterEach(() => { + balancer.removeAllListeners(); + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('Constructor', () => { + it('should create instance with default options', () => { + const b = new CognitiveLoadBalancer(); + expect(b.throttlePolicy).toBe(ThrottlePolicy.QUEUE_WHEN_FULL); + expect(b.maxQueueSize).toBe(1000); + expect(b.agents.size).toBe(0); + expect(b.tasks.size).toBe(0); + expect(b.queue).toEqual([]); + }); + + it('should accept custom options using nullish coalescing', () => { + const b = new CognitiveLoadBalancer({ + projectRoot: '/custom/path', + throttlePolicy: ThrottlePolicy.REJECT_WHEN_FULL, + maxQueueSize: 50, + persistMetrics: false, + }); + expect(b.projectRoot).toBe('/custom/path'); + expect(b.throttlePolicy).toBe(ThrottlePolicy.REJECT_WHEN_FULL); + expect(b.maxQueueSize).toBe(50); + expect(b.persistMetrics).toBe(false); + }); + + it('should extend EventEmitter', () => { + expect(balancer).toBeInstanceOf(require('events').EventEmitter); + }); + + it('should initialize metrics with startTime', () => { + expect(balancer.metrics.startTime).toBeLessThanOrEqual(Date.now()); + expect(balancer.metrics.totalSubmitted).toBe(0); + expect(balancer.metrics.totalCompleted).toBe(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // AGENT REGISTRATION + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('registerAgent', () => { + it('should register agent with default profile', () => { + const profile = balancer.registerAgent('agent-1'); + expect(profile.id).toBe('agent-1'); + expect(profile.maxLoad).toBe(100); + expect(profile.currentLoad).toBe(0); + expect(profile.specialties).toEqual([]); + expect(profile.processingSpeed).toBe(1.0); + expect(profile.status).toBe(AgentStatus.AVAILABLE); + expect(profile.activeTasks).toEqual([]); + expect(profile.completedCount).toBe(0); + expect(profile.failedCount).toBe(0); + }); + + it('should register agent with custom profile', () => { + const profile = balancer.registerAgent('agent-2', { + maxLoad: 50, + specialties: ['frontend', 'testing'], + processingSpeed: 1.5, + }); + expect(profile.maxLoad).toBe(50); + expect(profile.specialties).toEqual(['frontend', 'testing']); + expect(profile.processingSpeed).toBe(1.5); + }); + + it('should emit agent:registered event', () => { + const handler = jest.fn(); + balancer.on('agent:registered', handler); + + balancer.registerAgent('agent-3'); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-3' }) + ); + }); + + it('should throw on empty agentId', () => { + expect(() => balancer.registerAgent('')).toThrow('agentId must be a non-empty string'); + }); + + it('should throw on non-string agentId', () => { + expect(() => balancer.registerAgent(null)).toThrow('agentId must be a non-empty string'); + expect(() => balancer.registerAgent(123)).toThrow('agentId must be a non-empty string'); + }); + + it('should overwrite existing agent on re-registration', () => { + balancer.registerAgent('agent-1', { maxLoad: 50 }); + balancer.registerAgent('agent-1', { maxLoad: 200 }); + expect(balancer.agents.get('agent-1').maxLoad).toBe(200); + }); + }); + + describe('unregisterAgent', () => { + it('should unregister agent and return orphaned task IDs', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 5 }); + + const orphaned = balancer.unregisterAgent('agent-1'); + expect(orphaned).toContain('task-1'); + expect(balancer.agents.has('agent-1')).toBe(false); + }); + + it('should re-queue orphaned tasks', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 5 }); + + balancer.unregisterAgent('agent-1'); + + const task = balancer.tasks.get('task-1'); + expect(task.status).toBe(TaskStatus.QUEUED); + expect(task.assignedTo).toBeNull(); + expect(balancer.queue).toContain('task-1'); + }); + + it('should throw on unknown agent', () => { + expect(() => balancer.unregisterAgent('unknown')).toThrow("Agent 'unknown' not found"); + }); + + it('should reassign orphaned tasks to remaining agents', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.registerAgent('agent-2', { maxLoad: 100 }); + + balancer.submitTask({ id: 'task-1', complexity: 5 }); + const task = balancer.tasks.get('task-1'); + // Force assign to agent-1 + if (task.assignedTo !== 'agent-1') { + balancer.assignTask('task-1', 'agent-1'); + } + + balancer.unregisterAgent('agent-1'); + + // Task should be reassigned to agent-2 via queue processing + const updatedTask = balancer.tasks.get('task-1'); + expect(updatedTask.assignedTo).toBe('agent-2'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // TASK SUBMISSION + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('submitTask', () => { + beforeEach(() => { + balancer.registerAgent('agent-1', { maxLoad: 100, specialties: ['backend'] }); + }); + + it('should submit and auto-assign a task', () => { + const result = balancer.submitTask({ type: 'coding', complexity: 5 }); + expect(result.assignedTo).toBe('agent-1'); + expect(result.status).toBe(TaskStatus.ASSIGNED); + expect(result.taskId).toBeDefined(); + }); + + it('should auto-generate task ID when omitted', () => { + const result = balancer.submitTask({ type: 'coding', complexity: 3 }); + expect(result.taskId).toMatch(/^task-/); + }); + + it('should use provided task ID', () => { + const result = balancer.submitTask({ id: 'my-task-1', complexity: 3 }); + expect(result.taskId).toBe('my-task-1'); + }); + + it('should emit task:submitted event', () => { + const handler = jest.fn(); + balancer.on('task:submitted', handler); + + balancer.submitTask({ id: 'evt-task', complexity: 3 }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ taskId: 'evt-task' }) + ); + }); + + it('should emit task:assigned event when assigned', () => { + const handler = jest.fn(); + balancer.on('task:assigned', handler); + + balancer.submitTask({ id: 'asgn-task', complexity: 3 }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ taskId: 'asgn-task', agentId: 'agent-1' }) + ); + }); + + it('should throw on non-object task input', () => { + expect(() => balancer.submitTask(null)).toThrow('Task must be a non-null object'); + expect(() => balancer.submitTask('string')).toThrow('Task must be a non-null object'); + }); + + it('should clamp complexity to range 1-10', () => { + balancer.submitTask({ id: 'low', complexity: -5 }); + balancer.submitTask({ id: 'high', complexity: 999 }); + + expect(balancer.tasks.get('low').complexity).toBe(1); + expect(balancer.tasks.get('high').complexity).toBe(10); + }); + + it('should default complexity to 5', () => { + balancer.submitTask({ id: 'default-cplx' }); + expect(balancer.tasks.get('default-cplx').complexity).toBe(5); + }); + + it('should queue task when all agents at capacity', () => { + balancer.registerAgent('small-agent', { maxLoad: 5 }); + // Fill agent-1 + for (let i = 0; i < 20; i++) { + balancer.submitTask({ id: `fill-${i}`, complexity: 5 }); + } + + const result = balancer.submitTask({ id: 'overflow-task', complexity: 10 }); + // Should be queued (or assigned to small-agent depending on capacity) + expect([TaskStatus.QUEUED, TaskStatus.ASSIGNED]).toContain(result.status); + }); + + it('should increment totalSubmitted metric', () => { + balancer.submitTask({ complexity: 3 }); + balancer.submitTask({ complexity: 3 }); + balancer.submitTask({ complexity: 3 }); + + expect(balancer.metrics.totalSubmitted).toBe(3); + }); + }); + + describe('submitTask - Priority handling', () => { + it('should handle critical priority tasks by bypassing queue', () => { + balancer.registerAgent('fast-agent', { maxLoad: 100 }); + + // Fill agent partially + balancer.submitTask({ id: 'normal-1', complexity: 5, priority: TaskPriority.NORMAL }); + + const result = balancer.submitTask({ + id: 'critical-1', + complexity: 3, + priority: TaskPriority.CRITICAL, + }); + + expect(result.status).toBe(TaskStatus.ASSIGNED); + expect(result.assignedTo).toBe('fast-agent'); + }); + + it('should reject critical tasks under reject-when-full policy when no agent available', () => { + balancer.setThrottlePolicy(ThrottlePolicy.REJECT_WHEN_FULL); + // No agents registered + + const result = balancer.submitTask({ + id: 'critical-no-agent', + complexity: 3, + priority: TaskPriority.CRITICAL, + }); + + expect(result.status).toBe(TaskStatus.FAILED); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // MANUAL ASSIGNMENT + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('assignTask', () => { + it('should manually assign task to specific agent', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.registerAgent('agent-2', { maxLoad: 100 }); + + balancer.submitTask({ id: 'task-1', complexity: 5 }); + + const result = balancer.assignTask('task-1', 'agent-2'); + expect(result.assignedTo).toBe('agent-2'); + + const task = balancer.tasks.get('task-1'); + expect(task.assignedTo).toBe('agent-2'); + }); + + it('should remove task from queue on manual assignment', () => { + // Create a task that goes to queue (no agents) + balancer.submitTask({ id: 'queued-task', complexity: 5 }); + expect(balancer.queue).toContain('queued-task'); + + // Now register an agent and manually assign + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.assignTask('queued-task', 'agent-1'); + + expect(balancer.queue).not.toContain('queued-task'); + }); + + it('should throw on unknown task', () => { + balancer.registerAgent('agent-1'); + expect(() => balancer.assignTask('unknown', 'agent-1')).toThrow("Task 'unknown' not found"); + }); + + it('should throw on unknown agent', () => { + balancer.registerAgent('agent-1'); + balancer.submitTask({ id: 'task-1', complexity: 5 }); + expect(() => balancer.assignTask('task-1', 'unknown')).toThrow("Agent 'unknown' not found"); + }); + + it('should move task from one agent to another', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.registerAgent('agent-2', { maxLoad: 100 }); + + balancer.submitTask({ id: 'task-1', complexity: 5 }); + + // Ensure it's on agent-1 + balancer.assignTask('task-1', 'agent-1'); + expect(balancer.agents.get('agent-1').activeTasks).toContain('task-1'); + + // Move to agent-2 + balancer.assignTask('task-1', 'agent-2'); + expect(balancer.agents.get('agent-1').activeTasks).not.toContain('task-1'); + expect(balancer.agents.get('agent-2').activeTasks).toContain('task-1'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // TASK COMPLETION + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('completeTask', () => { + beforeEach(() => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + }); + + it('should mark task as completed', () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + balancer.completeTask('task-1', { output: 'done' }); + + const task = balancer.tasks.get('task-1'); + expect(task.status).toBe(TaskStatus.COMPLETED); + expect(task.result).toEqual({ output: 'done' }); + expect(task.completedAt).toBeDefined(); + }); + + it('should free agent capacity', () => { + balancer.submitTask({ id: 'task-1', complexity: 8 }); + expect(balancer.agents.get('agent-1').currentLoad).toBe(8); + + balancer.completeTask('task-1'); + expect(balancer.agents.get('agent-1').currentLoad).toBe(0); + }); + + it('should update agent completion stats', () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + balancer.completeTask('task-1'); + + const agent = balancer.agents.get('agent-1'); + expect(agent.completedCount).toBe(1); + expect(agent.avgCompletionTime).toBeGreaterThanOrEqual(0); + }); + + it('should emit task:completed event', () => { + const handler = jest.fn(); + balancer.on('task:completed', handler); + + balancer.submitTask({ id: 'task-1', complexity: 5 }); + balancer.completeTask('task-1', 'result-data'); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: 'task-1', + result: 'result-data', + agentId: 'agent-1', + }) + ); + }); + + it('should increment totalCompleted metric', () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + balancer.completeTask('task-1'); + + expect(balancer.metrics.totalCompleted).toBe(1); + }); + + it('should throw on unknown task', () => { + expect(() => balancer.completeTask('unknown')).toThrow("Task 'unknown' not found"); + }); + + it('should process queue after completing task', () => { + // Fill agent to capacity + balancer.registerAgent('agent-full', { maxLoad: 10 }); + balancer.submitTask({ id: 'fill-1', complexity: 5 }); + balancer.submitTask({ id: 'fill-2', complexity: 5 }); + + // This should be queued (agent-1 may have space but agent-full is full) + balancer.submitTask({ id: 'waiting', complexity: 8 }); + + // Complete a task to free capacity + balancer.completeTask('fill-1'); + + // Check that queue processing attempted + expect(balancer.metrics.totalCompleted).toBe(1); + }); + + it('should return completion time info', () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + const result = balancer.completeTask('task-1'); + + expect(result.taskId).toBe('task-1'); + expect(result.agentId).toBe('agent-1'); + expect(result.completionTime).toBeGreaterThanOrEqual(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // TASK FAILURE + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('failTask', () => { + beforeEach(() => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + }); + + it('should mark task as failed', () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + balancer.failTask('task-1', 'Something went wrong'); + + const task = balancer.tasks.get('task-1'); + expect(task.status).toBe(TaskStatus.FAILED); + expect(task.error).toBe('Something went wrong'); + }); + + it('should accept Error objects', () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + balancer.failTask('task-1', new Error('Detailed error')); + + const task = balancer.tasks.get('task-1'); + expect(task.error).toBe('Detailed error'); + }); + + it('should free agent capacity', () => { + balancer.submitTask({ id: 'task-1', complexity: 7 }); + expect(balancer.agents.get('agent-1').currentLoad).toBe(7); + + balancer.failTask('task-1', 'fail'); + expect(balancer.agents.get('agent-1').currentLoad).toBe(0); + }); + + it('should update agent failure stats', () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + balancer.failTask('task-1', 'error'); + + expect(balancer.agents.get('agent-1').failedCount).toBe(1); + }); + + it('should emit task:failed event', () => { + const handler = jest.fn(); + balancer.on('task:failed', handler); + + balancer.submitTask({ id: 'task-1', complexity: 5 }); + balancer.failTask('task-1', 'test error'); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: 'task-1', + error: 'test error', + agentId: 'agent-1', + }) + ); + }); + + it('should throw on unknown task', () => { + expect(() => balancer.failTask('unknown')).toThrow("Task 'unknown' not found"); + }); + + it('should use default error message when none provided', () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + balancer.failTask('task-1'); + + expect(balancer.tasks.get('task-1').error).toBe('Unknown error'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // AGENT LOAD QUERIES + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('getAgentLoad', () => { + it('should return 0% for empty agent', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + expect(balancer.getAgentLoad('agent-1')).toBe(0); + }); + + it('should return correct load percentage', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 10 }); + // complexity 10 on maxLoad 100 = 10% + expect(balancer.getAgentLoad('agent-1')).toBe(10); + }); + + it('should cap at 100%', () => { + balancer.registerAgent('agent-1', { maxLoad: 10 }); + balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); + balancer.submitTask({ id: 'task-1', complexity: 10 }); + balancer.submitTask({ id: 'task-2', complexity: 10 }); + + expect(balancer.getAgentLoad('agent-1')).toBe(100); + }); + + it('should return 100% for agent with maxLoad 0', () => { + balancer.registerAgent('agent-zero', { maxLoad: 0 }); + expect(balancer.getAgentLoad('agent-zero')).toBe(100); + }); + + it('should throw on unknown agent', () => { + expect(() => balancer.getAgentLoad('unknown')).toThrow("Agent 'unknown' not found"); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // OPTIMAL AGENT + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('getOptimalAgent', () => { + it('should return null when no agents registered', () => { + const result = balancer.getOptimalAgent({ type: 'coding', complexity: 5 }); + expect(result).toBeNull(); + }); + + it('should return agent info without assigning', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + + const result = balancer.getOptimalAgent({ type: 'coding', complexity: 5 }); + expect(result.agentId).toBe('agent-1'); + expect(result.currentLoad).toBe(0); + expect(result.affinityScore).toBeGreaterThan(0); + expect(result.specialties).toEqual([]); + + // Verify no task was created or assigned + expect(balancer.agents.get('agent-1').activeTasks.length).toBe(0); + }); + + it('should prefer agent with matching specialties', () => { + balancer.registerAgent('generalist', { maxLoad: 100, specialties: [] }); + balancer.registerAgent('specialist', { maxLoad: 100, specialties: ['testing'] }); + + const result = balancer.getOptimalAgent({ + type: 'test', + complexity: 5, + requiredSpecialties: ['testing'], + }); + + expect(result.agentId).toBe('specialist'); + }); + + it('should factor in processing speed', () => { + balancer.registerAgent('slow', { maxLoad: 100, processingSpeed: 0.5 }); + balancer.registerAgent('fast', { maxLoad: 100, processingSpeed: 2.0 }); + + const result = balancer.getOptimalAgent({ type: 'coding', complexity: 5 }); + expect(result.agentId).toBe('fast'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // AFFINITY SCORING + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('Affinity scoring algorithm', () => { + it('should weight specialty match at 40%', () => { + expect(AFFINITY_WEIGHTS.SPECIALTY).toBe(0.4); + }); + + it('should weight load inverse at 30%', () => { + expect(AFFINITY_WEIGHTS.LOAD_INVERSE).toBe(0.3); + }); + + it('should weight speed at 20%', () => { + expect(AFFINITY_WEIGHTS.SPEED).toBe(0.2); + }); + + it('should weight success rate at 10%', () => { + expect(AFFINITY_WEIGHTS.SUCCESS_RATE).toBe(0.1); + }); + + it('should prefer less loaded agents for equal specialty match', () => { + balancer.registerAgent('loaded', { maxLoad: 100, specialties: ['backend'] }); + balancer.registerAgent('free', { maxLoad: 100, specialties: ['backend'] }); + + // Load up the first agent + balancer.submitTask({ id: 'load-1', complexity: 8 }); + balancer.assignTask('load-1', 'loaded'); + balancer.submitTask({ id: 'load-2', complexity: 8 }); + balancer.assignTask('load-2', 'loaded'); + + const result = balancer.getOptimalAgent({ + type: 'backend', + complexity: 5, + requiredSpecialties: ['backend'], + }); + + expect(result.agentId).toBe('free'); + }); + + it('should give new agents benefit of the doubt for success rate', () => { + balancer.registerAgent('new-agent', { maxLoad: 100 }); + const agent = balancer.agents.get('new-agent'); + + // Internal method access for testing + const successRate = balancer._getSuccessRate(agent); + expect(successRate).toBe(1); // Perfect score for untested agents + }); + + it('should calculate correct success rate with history', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + + // 3 completed, 1 failed = 75% success + balancer.submitTask({ id: 't1', complexity: 2 }); + balancer.completeTask('t1'); + balancer.submitTask({ id: 't2', complexity: 2 }); + balancer.completeTask('t2'); + balancer.submitTask({ id: 't3', complexity: 2 }); + balancer.completeTask('t3'); + balancer.submitTask({ id: 't4', complexity: 2 }); + balancer.failTask('t4', 'error'); + + const agent = balancer.agents.get('agent-1'); + expect(balancer._getSuccessRate(agent)).toBe(0.75); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // THROTTLE POLICIES + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('Throttle policies', () => { + describe('setThrottlePolicy', () => { + it('should accept valid policies', () => { + balancer.setThrottlePolicy(ThrottlePolicy.QUEUE_WHEN_FULL); + expect(balancer.throttlePolicy).toBe('queue-when-full'); + + balancer.setThrottlePolicy(ThrottlePolicy.REJECT_WHEN_FULL); + expect(balancer.throttlePolicy).toBe('reject-when-full'); + + balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); + expect(balancer.throttlePolicy).toBe('spillover'); + }); + + it('should throw on invalid policy', () => { + expect(() => balancer.setThrottlePolicy('invalid')).toThrow("Invalid throttle policy 'invalid'"); + }); + }); + + describe('queue-when-full', () => { + it('should queue tasks when agents are full', () => { + balancer.setThrottlePolicy(ThrottlePolicy.QUEUE_WHEN_FULL); + balancer.registerAgent('small', { maxLoad: 5 }); + + balancer.submitTask({ id: 'fill', complexity: 5 }); + const result = balancer.submitTask({ id: 'overflow', complexity: 5 }); + + expect(result.status).toBe(TaskStatus.QUEUED); + expect(balancer.queue).toContain('overflow'); + }); + + it('should reject when queue is full', () => { + balancer = new CognitiveLoadBalancer({ + projectRoot: tempDir, + persistMetrics: false, + maxQueueSize: 1, + }); + balancer.setThrottlePolicy(ThrottlePolicy.QUEUE_WHEN_FULL); + + // No agents, first task goes to queue + balancer.submitTask({ id: 'queue-1', complexity: 5 }); + // Second task should fail because queue is full + const handler = jest.fn(); + balancer.on('queue:full', handler); + + const result = balancer.submitTask({ id: 'queue-2', complexity: 5 }); + expect(result.status).toBe(TaskStatus.FAILED); + expect(handler).toHaveBeenCalled(); + }); + }); + + describe('reject-when-full', () => { + it('should reject tasks when no agent can accept', () => { + balancer.setThrottlePolicy(ThrottlePolicy.REJECT_WHEN_FULL); + balancer.registerAgent('tiny', { maxLoad: 3 }); + + balancer.submitTask({ id: 'fill', complexity: 3 }); + const result = balancer.submitTask({ id: 'rejected', complexity: 3 }); + + expect(result.status).toBe(TaskStatus.FAILED); + expect(balancer.metrics.totalRejected).toBeGreaterThan(0); + }); + }); + + describe('spillover', () => { + it('should assign to least loaded agent even when over capacity', () => { + balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); + balancer.registerAgent('agent-1', { maxLoad: 5 }); + + balancer.submitTask({ id: 'fill', complexity: 5 }); + const result = balancer.submitTask({ id: 'spill', complexity: 5 }); + + expect(result.status).toBe(TaskStatus.ASSIGNED); + expect(result.assignedTo).toBe('agent-1'); + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // REBALANCING + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('rebalance', () => { + it('should return empty movements when no overloaded agents', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 5 }); + + const result = balancer.rebalance(); + expect(result.movements).toEqual([]); + }); + + it('should return empty movements when no underloaded agents', () => { + balancer.registerAgent('agent-1', { maxLoad: 10 }); + balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); + + // Overload the agent + for (let i = 0; i < 10; i++) { + balancer.submitTask({ id: `heavy-${i}`, complexity: 5 }); + } + + const result = balancer.rebalance(); + expect(result.movements).toEqual([]); + }); + + it('should move tasks from overloaded to underloaded agents', () => { + balancer.registerAgent('overloaded', { maxLoad: 10 }); + balancer.registerAgent('idle', { maxLoad: 100 }); + balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); + + // Fill overloaded agent + for (let i = 0; i < 5; i++) { + balancer.submitTask({ id: `task-${i}`, complexity: 3 }); + balancer.assignTask(`task-${i}`, 'overloaded'); + } + + const result = balancer.rebalance(); + expect(result.movements.length).toBeGreaterThan(0); + expect(result.movements[0].from).toBe('overloaded'); + expect(result.movements[0].to).toBe('idle'); + }); + + it('should emit task:rebalanced events', () => { + const handler = jest.fn(); + balancer.on('task:rebalanced', handler); + + balancer.registerAgent('overloaded', { maxLoad: 10 }); + balancer.registerAgent('idle', { maxLoad: 100 }); + balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); + + for (let i = 0; i < 5; i++) { + balancer.submitTask({ id: `rb-${i}`, complexity: 3 }); + balancer.assignTask(`rb-${i}`, 'overloaded'); + } + + balancer.rebalance(); + expect(handler).toHaveBeenCalled(); + }); + + it('should increment totalRebalanced metric', () => { + balancer.registerAgent('over', { maxLoad: 10 }); + balancer.registerAgent('under', { maxLoad: 100 }); + balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); + + for (let i = 0; i < 5; i++) { + balancer.submitTask({ id: `rebal-${i}`, complexity: 3 }); + balancer.assignTask(`rebal-${i}`, 'over'); + } + + balancer.rebalance(); + expect(balancer.metrics.totalRebalanced).toBeGreaterThan(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // QUEUE MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('getQueue', () => { + it('should return empty array initially', () => { + expect(balancer.getQueue()).toEqual([]); + }); + + it('should return queued tasks with details', () => { + // No agents - tasks go to queue + balancer.submitTask({ id: 'q1', type: 'code', complexity: 5 }); + balancer.submitTask({ id: 'q2', type: 'test', complexity: 3 }); + + const queue = balancer.getQueue(); + expect(queue).toHaveLength(2); + expect(queue[0].id).toBe('q1'); + expect(queue[1].id).toBe('q2'); + expect(queue[0].status).toBe(TaskStatus.QUEUED); + }); + + it('should drain queue when agent becomes available', () => { + // Tasks go to queue first + balancer.submitTask({ id: 'wait-1', complexity: 5 }); + expect(balancer.queue).toHaveLength(1); + + // Register agent - queue should process + balancer.registerAgent('new-agent', { maxLoad: 100 }); + + // Submit another task that triggers queue processing + balancer.submitTask({ id: 'trigger', complexity: 3 }); + + // wait-1 may still be in queue since registerAgent doesn't auto-process + // But the trigger task should be assigned + const task = balancer.tasks.get('trigger'); + expect(task.assignedTo).toBe('new-agent'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // METRICS + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('getMetrics', () => { + it('should return comprehensive metrics snapshot', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 5 }); + balancer.completeTask('task-1'); + + const metrics = balancer.getMetrics(); + + expect(metrics.totalSubmitted).toBe(1); + expect(metrics.totalCompleted).toBe(1); + expect(metrics.totalFailed).toBe(0); + expect(metrics.totalRejected).toBe(0); + expect(metrics.queueLength).toBe(0); + expect(metrics.activeAgents).toBe(1); + expect(metrics.throughputPerMinute).toBeGreaterThanOrEqual(0); + expect(metrics.avgWaitTime).toBeGreaterThanOrEqual(0); + expect(metrics.uptime).toBeGreaterThanOrEqual(0); + expect(metrics.agentUtilization).toBeDefined(); + expect(metrics.agentUtilization['agent-1']).toBeDefined(); + }); + + it('should include per-agent utilization', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 10 }); + + const metrics = balancer.getMetrics(); + const agentMetrics = metrics.agentUtilization['agent-1']; + + expect(agentMetrics.load).toBe(10); + expect(agentMetrics.activeTasks).toBe(1); + expect(agentMetrics.status).toBeDefined(); + }); + + it('should track agent success rate in metrics', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 't1', complexity: 2 }); + balancer.completeTask('t1'); + balancer.submitTask({ id: 't2', complexity: 2 }); + balancer.failTask('t2', 'error'); + + const metrics = balancer.getMetrics(); + expect(metrics.agentUtilization['agent-1'].successRate).toBe(0.5); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // AGENT STATUS + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('Agent status transitions', () => { + it('should start as available', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + expect(balancer.agents.get('agent-1').status).toBe(AgentStatus.AVAILABLE); + }); + + it('should transition to busy when tasks assigned', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 5 }); + + expect(balancer.agents.get('agent-1').status).toBe(AgentStatus.BUSY); + }); + + it('should transition to overloaded at threshold', () => { + balancer.registerAgent('agent-1', { maxLoad: 10 }); + balancer.submitTask({ id: 'task-1', complexity: 9 }); // 90% load > 85% threshold + + expect(balancer.agents.get('agent-1').status).toBe(AgentStatus.OVERLOADED); + }); + + it('should transition back to available when tasks complete', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 5 }); + + expect(balancer.agents.get('agent-1').status).toBe(AgentStatus.BUSY); + + balancer.completeTask('task-1'); + expect(balancer.agents.get('agent-1').status).toBe(AgentStatus.AVAILABLE); + }); + + it('should emit agent:overloaded event', () => { + const handler = jest.fn(); + balancer.on('agent:overloaded', handler); + + balancer.registerAgent('agent-1', { maxLoad: 10 }); + balancer.submitTask({ id: 'task-1', complexity: 9 }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-1' }) + ); + }); + + it('should emit agent:available event when load drops', () => { + const handler = jest.fn(); + balancer.on('agent:available', handler); + + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 90 }); + balancer.completeTask('task-1'); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-1' }) + ); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // PERSISTENCE + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('Metrics persistence', () => { + it('should persist metrics to disk when enabled', async () => { + const persistBalancer = new CognitiveLoadBalancer({ + projectRoot: tempDir, + persistMetrics: true, + }); + + persistBalancer.registerAgent('agent-1', { maxLoad: 100 }); + persistBalancer.submitTask({ id: 'task-1', complexity: 5 }); + await persistBalancer.completeTask('task-1'); + + // Allow async file write to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + const metricsPath = path.join(tempDir, '.aiox', 'load-balancer-metrics.json'); + const exists = fs.existsSync(metricsPath); + expect(exists).toBe(true); + + if (exists) { + const content = JSON.parse(fs.readFileSync(metricsPath, 'utf8')); + expect(content.totalCompleted).toBe(1); + } + + persistBalancer.removeAllListeners(); + }); + + it('should not persist when persistMetrics is false', async () => { + balancer.submitTask({ id: 'no-persist', complexity: 5 }); + // Register agent so the task gets assigned then completed + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'assigned', complexity: 5 }); + await balancer.completeTask('assigned'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metricsPath = path.join(tempDir, '.aiox', 'load-balancer-metrics.json'); + expect(fs.existsSync(metricsPath)).toBe(false); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // EXPORTS + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('Module exports', () => { + it('should export CognitiveLoadBalancer as default and named', () => { + expect(CognitiveLoadBalancer).toBeDefined(); + expect(CognitiveLoadBalancer.CognitiveLoadBalancer).toBe(CognitiveLoadBalancer); + }); + + it('should export AgentStatus enum', () => { + expect(AgentStatus.AVAILABLE).toBe('available'); + expect(AgentStatus.BUSY).toBe('busy'); + expect(AgentStatus.OVERLOADED).toBe('overloaded'); + expect(AgentStatus.OFFLINE).toBe('offline'); + }); + + it('should export TaskStatus enum', () => { + expect(TaskStatus.QUEUED).toBe('queued'); + expect(TaskStatus.ASSIGNED).toBe('assigned'); + expect(TaskStatus.COMPLETED).toBe('completed'); + expect(TaskStatus.FAILED).toBe('failed'); + }); + + it('should export TaskPriority enum', () => { + expect(TaskPriority.LOW).toBe('low'); + expect(TaskPriority.NORMAL).toBe('normal'); + expect(TaskPriority.HIGH).toBe('high'); + expect(TaskPriority.CRITICAL).toBe('critical'); + }); + + it('should export ThrottlePolicy enum', () => { + expect(ThrottlePolicy.QUEUE_WHEN_FULL).toBe('queue-when-full'); + expect(ThrottlePolicy.REJECT_WHEN_FULL).toBe('reject-when-full'); + expect(ThrottlePolicy.SPILLOVER).toBe('spillover'); + }); + + it('should export AFFINITY_WEIGHTS', () => { + const total = AFFINITY_WEIGHTS.SPECIALTY + AFFINITY_WEIGHTS.LOAD_INVERSE + + AFFINITY_WEIGHTS.SPEED + AFFINITY_WEIGHTS.SUCCESS_RATE; + expect(total).toBeCloseTo(1.0); + }); + + it('should export OVERLOAD_THRESHOLD', () => { + expect(OVERLOAD_THRESHOLD).toBe(85); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // EDGE CASES + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('Edge cases', () => { + it('should handle submitting tasks with no agents registered', () => { + const result = balancer.submitTask({ id: 'orphan', complexity: 5 }); + expect(result.status).toBe(TaskStatus.QUEUED); + }); + + it('should handle multiple agents with same specialty', () => { + balancer.registerAgent('a1', { maxLoad: 100, specialties: ['js'] }); + balancer.registerAgent('a2', { maxLoad: 100, specialties: ['js'] }); + + const result = balancer.submitTask({ + id: 'js-task', + complexity: 5, + requiredSpecialties: ['js'], + }); + + expect(result.status).toBe(TaskStatus.ASSIGNED); + expect(['a1', 'a2']).toContain(result.assignedTo); + }); + + it('should handle task with no required specialties', () => { + balancer.registerAgent('a1', { maxLoad: 100, specialties: ['niche'] }); + + const result = balancer.submitTask({ id: 'generic', complexity: 5 }); + expect(result.status).toBe(TaskStatus.ASSIGNED); + }); + + it('should handle completing task that has no agent', () => { + // Create task that goes to queue (no agents) + balancer.submitTask({ id: 'no-agent-task', complexity: 5 }); + + // Manually change status to simulate edge case + const task = balancer.tasks.get('no-agent-task'); + task.status = TaskStatus.ASSIGNED; + + const result = balancer.completeTask('no-agent-task'); + expect(result.taskId).toBe('no-agent-task'); + }); + + it('should handle rapid fire task submission', () => { + balancer.registerAgent('worker', { maxLoad: 1000 }); + + const results = []; + for (let i = 0; i < 100; i++) { + results.push(balancer.submitTask({ complexity: 1 })); + } + + const assigned = results.filter((r) => r.status === TaskStatus.ASSIGNED).length; + expect(assigned).toBe(100); + expect(balancer.metrics.totalSubmitted).toBe(100); + }); + }); +}); From 9b8e091d0506ead6473d49768f312db835c7743f Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Sun, 8 Mar 2026 02:25:12 -0300 Subject: [PATCH 2/5] feat: adiciona modulo Swarm Intelligence para colaboracao multi-agente Implementa inteligencia emergente via coordenacao de enxame (ORCH-5): - Criacao e gestao de swarms com configuracao de votacao - Sistema de decisao coletiva com 4 estrategias (majority, weighted, unanimous, quorum) - Base de conhecimento compartilhado com busca por topico/tags - Eleicao de lider (most-capable, highest-reputation, round-robin) - Metricas de saude e reputacao dinamica de agentes - Persistencia em .aiox/swarms.json - 83 testes cobrindo todos os metodos, estrategias e edge cases --- .../core/orchestration/swarm-intelligence.js | 1004 +++++++++++++++++ .../core/orchestration/swarm-intelligence.js | 1004 +++++++++++++++++ .../orchestration/swarm-intelligence.test.js | 947 ++++++++++++++++ 3 files changed, 2955 insertions(+) create mode 100644 .aios-core/core/orchestration/swarm-intelligence.js create mode 100644 .aiox-core/core/orchestration/swarm-intelligence.js create mode 100644 tests/core/orchestration/swarm-intelligence.test.js diff --git a/.aios-core/core/orchestration/swarm-intelligence.js b/.aios-core/core/orchestration/swarm-intelligence.js new file mode 100644 index 000000000..171e710dc --- /dev/null +++ b/.aios-core/core/orchestration/swarm-intelligence.js @@ -0,0 +1,1004 @@ +/** + * Agent Swarm Intelligence + * Story ORCH-5 - Emergent intelligence from multi-agent collaboration + * @module aiox-core/orchestration/swarm-intelligence + * @version 1.0.0 + */ + +'use strict'; + +const { EventEmitter } = require('events'); +const fs = require('fs').promises; +const path = require('path'); +const crypto = require('crypto'); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CONSTANTS +// ═══════════════════════════════════════════════════════════════════════════════ + +const PERSISTENCE_DIR = '.aiox'; +const PERSISTENCE_FILE = 'swarms.json'; + +const VOTING_STRATEGIES = { + MAJORITY: 'majority', + WEIGHTED: 'weighted', + UNANIMOUS: 'unanimous', + QUORUM: 'quorum', +}; + +const PROPOSAL_STATUS = { + PENDING: 'pending', + APPROVED: 'approved', + REJECTED: 'rejected', + EXPIRED: 'expired', +}; + +const SWARM_STATUS = { + ACTIVE: 'active', + DISSOLVED: 'dissolved', +}; + +const LEADER_CRITERIA = { + MOST_CAPABLE: 'most-capable', + HIGHEST_REPUTATION: 'highest-reputation', + ROUND_ROBIN: 'round-robin', +}; + +const VOTE_OPTIONS = { + APPROVE: 'approve', + REJECT: 'reject', + ABSTAIN: 'abstain', +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Generate a unique identifier + * @returns {string} UUID-like identifier + */ +function generateId() { + return crypto.randomBytes(8).toString('hex'); +} + +/** + * Validate confidence value is between 0 and 1 + * @param {number} confidence + * @returns {boolean} + */ +function isValidConfidence(confidence) { + return typeof confidence === 'number' && confidence >= 0 && confidence <= 1; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// SWARM INTELLIGENCE +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * SwarmIntelligence - Emergent intelligence from multi-agent collaboration + * + * Provides swarm creation, agent coordination, collective decision-making + * via voting strategies, shared knowledge management, and leader election. + * + * @extends EventEmitter + */ +class SwarmIntelligence extends EventEmitter { + /** + * Creates a new SwarmIntelligence instance + * @param {string} projectRoot - Project root directory for persistence + * @param {Object} [options] - Configuration options + * @param {boolean} [options.debug=false] - Enable debug logging + * @param {boolean} [options.persist=true] - Enable persistence to disk + */ + constructor(projectRoot, options = {}) { + super(); + + if (!projectRoot || typeof projectRoot !== 'string') { + throw new Error('projectRoot is required and must be a string'); + } + + this.projectRoot = projectRoot; + this.options = { + debug: options.debug ?? false, + persist: options.persist ?? true, + }; + + /** @type {Map} Active swarms indexed by ID */ + this.swarms = new Map(); + + /** @type {Object} Global statistics */ + this._stats = { + swarmsCreated: 0, + swarmsDissolved: 0, + proposalsCreated: 0, + proposalsResolved: 0, + knowledgeShared: 0, + leadersElected: 0, + totalVotes: 0, + }; + + /** @type {number} Round-robin index tracking per swarm */ + this._roundRobinIndex = new Map(); + + this._persistPath = path.join(projectRoot, PERSISTENCE_DIR, PERSISTENCE_FILE); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SWARM MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Create a named swarm with configuration + * @param {string} name - Swarm name + * @param {Object} [config] - Swarm configuration + * @param {number} [config.minAgents=2] - Minimum agents required + * @param {number} [config.maxAgents=50] - Maximum agents allowed + * @param {number} [config.consensusThreshold=0.6] - Consensus threshold (0-1) + * @param {string} [config.votingStrategy='majority'] - Voting strategy + * @returns {Object} Created swarm + */ + createSwarm(name, config = {}) { + if (!name || typeof name !== 'string') { + throw new Error('Swarm name is required and must be a string'); + } + + const votingStrategy = config.votingStrategy ?? VOTING_STRATEGIES.MAJORITY; + if (!Object.values(VOTING_STRATEGIES).includes(votingStrategy)) { + throw new Error(`Invalid voting strategy: ${votingStrategy}. Must be one of: ${Object.values(VOTING_STRATEGIES).join(', ')}`); + } + + const consensusThreshold = config.consensusThreshold ?? 0.6; + if (typeof consensusThreshold !== 'number' || consensusThreshold < 0 || consensusThreshold > 1) { + throw new Error('consensusThreshold must be a number between 0 and 1'); + } + + const minAgents = config.minAgents ?? 2; + const maxAgents = config.maxAgents ?? 50; + + if (minAgents < 1) { + throw new Error('minAgents must be at least 1'); + } + if (maxAgents < minAgents) { + throw new Error('maxAgents must be >= minAgents'); + } + + const id = generateId(); + const swarm = { + id, + name, + agents: new Map(), + proposals: [], + knowledgeBase: [], + leader: null, + config: { + minAgents, + maxAgents, + consensusThreshold, + votingStrategy, + }, + createdAt: new Date().toISOString(), + status: SWARM_STATUS.ACTIVE, + }; + + this.swarms.set(id, swarm); + this._stats.swarmsCreated++; + this._roundRobinIndex.set(id, 0); + + this.emit('swarm:created', { swarmId: id, name, config: swarm.config }); + this._log(`Swarm created: ${name} (${id})`); + this._persistAsync(); + + return swarm; + } + + /** + * Agent joins a swarm with declared capabilities + * @param {string} swarmId - Swarm identifier + * @param {string} agentId - Agent identifier + * @param {string[]} [capabilities=[]] - Agent capabilities + * @returns {Object} Updated swarm + */ + joinSwarm(swarmId, agentId, capabilities = []) { + const swarm = this._getActiveSwarm(swarmId); + + if (!agentId || typeof agentId !== 'string') { + throw new Error('agentId is required and must be a string'); + } + + if (swarm.agents.has(agentId)) { + throw new Error(`Agent ${agentId} is already a member of swarm ${swarmId}`); + } + + if (swarm.agents.size >= swarm.config.maxAgents) { + throw new Error(`Swarm ${swarmId} has reached maximum capacity (${swarm.config.maxAgents})`); + } + + const agent = { + id: agentId, + capabilities: Array.isArray(capabilities) ? [...capabilities] : [], + joinedAt: new Date().toISOString(), + reputation: 1.0, + votesCount: 0, + }; + + swarm.agents.set(agentId, agent); + + this.emit('swarm:joined', { swarmId, agentId, capabilities: agent.capabilities }); + this._log(`Agent ${agentId} joined swarm ${swarmId}`); + this._persistAsync(); + + return swarm; + } + + /** + * Agent leaves a swarm + * @param {string} swarmId - Swarm identifier + * @param {string} agentId - Agent identifier + * @returns {Object} Updated swarm + */ + leaveSwarm(swarmId, agentId) { + const swarm = this._getActiveSwarm(swarmId); + + if (!swarm.agents.has(agentId)) { + throw new Error(`Agent ${agentId} is not a member of swarm ${swarmId}`); + } + + swarm.agents.delete(agentId); + + // If the leader left, clear leadership + if (swarm.leader === agentId) { + swarm.leader = null; + } + + this.emit('swarm:left', { swarmId, agentId }); + this._log(`Agent ${agentId} left swarm ${swarmId}`); + this._persistAsync(); + + return swarm; + } + + /** + * Dissolve a swarm + * @param {string} swarmId - Swarm identifier + * @returns {Object} Dissolved swarm summary + */ + dissolveSwarm(swarmId) { + const swarm = this._getActiveSwarm(swarmId); + + swarm.status = SWARM_STATUS.DISSOLVED; + swarm.dissolvedAt = new Date().toISOString(); + this._stats.swarmsDissolved++; + + const summary = { + id: swarm.id, + name: swarm.name, + agentCount: swarm.agents.size, + proposalCount: swarm.proposals.length, + knowledgeCount: swarm.knowledgeBase.length, + dissolvedAt: swarm.dissolvedAt, + }; + + this.emit('swarm:dissolved', { swarmId, summary }); + this._log(`Swarm dissolved: ${swarm.name} (${swarmId})`); + this._persistAsync(); + + return summary; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DECISION MAKING + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Submit a proposal for collective voting + * @param {string} swarmId - Swarm identifier + * @param {Object} proposal - Proposal details + * @param {string} proposal.description - Proposal description + * @param {string} proposal.proposedBy - Agent ID proposing + * @param {string} [proposal.type='general'] - Proposal type + * @param {number} [proposal.deadlineMs=300000] - Deadline in milliseconds (default 5 min) + * @returns {Object} Created proposal + */ + proposeDecision(swarmId, proposal) { + const swarm = this._getActiveSwarm(swarmId); + + if (!proposal || typeof proposal !== 'object') { + throw new Error('proposal is required and must be an object'); + } + if (!proposal.description || typeof proposal.description !== 'string') { + throw new Error('proposal.description is required'); + } + if (!proposal.proposedBy || typeof proposal.proposedBy !== 'string') { + throw new Error('proposal.proposedBy is required'); + } + if (!swarm.agents.has(proposal.proposedBy)) { + throw new Error(`Agent ${proposal.proposedBy} is not a member of swarm ${swarmId}`); + } + + const id = generateId(); + const deadlineMs = proposal.deadlineMs ?? 300000; + const created = { + id, + swarmId, + proposedBy: proposal.proposedBy, + description: proposal.description, + type: proposal.type ?? 'general', + votes: new Map(), + status: PROPOSAL_STATUS.PENDING, + createdAt: new Date().toISOString(), + deadline: new Date(Date.now() + deadlineMs).toISOString(), + }; + + swarm.proposals.push(created); + this._stats.proposalsCreated++; + + this.emit('proposal:created', { swarmId, proposalId: id, proposedBy: proposal.proposedBy }); + this._log(`Proposal created in swarm ${swarmId}: ${proposal.description}`); + this._persistAsync(); + + return created; + } + + /** + * Cast a vote on a proposal + * @param {string} swarmId - Swarm identifier + * @param {string} proposalId - Proposal identifier + * @param {string} agentId - Voting agent ID + * @param {string} voteValue - Vote: 'approve', 'reject', or 'abstain' + * @param {number} [confidence=1.0] - Confidence level (0-1) + * @returns {Object} Updated proposal + */ + vote(swarmId, proposalId, agentId, voteValue, confidence = 1.0) { + const swarm = this._getActiveSwarm(swarmId); + const proposal = this._getPendingProposal(swarm, proposalId); + + if (!swarm.agents.has(agentId)) { + throw new Error(`Agent ${agentId} is not a member of swarm ${swarmId}`); + } + + if (!Object.values(VOTE_OPTIONS).includes(voteValue)) { + throw new Error(`Invalid vote: ${voteValue}. Must be one of: ${Object.values(VOTE_OPTIONS).join(', ')}`); + } + + if (!isValidConfidence(confidence)) { + throw new Error('confidence must be a number between 0 and 1'); + } + + if (proposal.votes.has(agentId)) { + throw new Error(`Agent ${agentId} has already voted on proposal ${proposalId}`); + } + + // Check deadline + if (new Date(proposal.deadline) < new Date()) { + proposal.status = PROPOSAL_STATUS.EXPIRED; + throw new Error(`Proposal ${proposalId} has expired`); + } + + proposal.votes.set(agentId, { + agentId, + vote: voteValue, + confidence, + timestamp: new Date().toISOString(), + }); + + // Update agent stats + const agent = swarm.agents.get(agentId); + agent.votesCount++; + this._stats.totalVotes++; + + this.emit('proposal:voted', { swarmId, proposalId, agentId, vote: voteValue, confidence }); + this._log(`Agent ${agentId} voted '${voteValue}' on proposal ${proposalId} (confidence: ${confidence})`); + this._persistAsync(); + + return proposal; + } + + /** + * Resolve a proposal based on votes and configured strategy + * @param {string} swarmId - Swarm identifier + * @param {string} proposalId - Proposal identifier + * @returns {Object} Resolution result + */ + resolveProposal(swarmId, proposalId) { + const swarm = this._getActiveSwarm(swarmId); + const proposal = this._getProposal(swarm, proposalId); + + if (proposal.status !== PROPOSAL_STATUS.PENDING) { + throw new Error(`Proposal ${proposalId} is already resolved (status: ${proposal.status})`); + } + + const result = this._applyVotingStrategy(swarm, proposal); + + proposal.status = result.approved ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.REJECTED; + proposal.resolvedAt = new Date().toISOString(); + proposal.resolution = result; + this._stats.proposalsResolved++; + + // Update reputation based on alignment with result + this._updateReputations(swarm, proposal, result.approved); + + this.emit('proposal:resolved', { + swarmId, + proposalId, + status: proposal.status, + result, + }); + this._log(`Proposal ${proposalId} resolved: ${proposal.status}`); + this._persistAsync(); + + return { + proposalId, + status: proposal.status, + ...result, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // VOTING STRATEGIES + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Apply the configured voting strategy to determine outcome + * @param {Object} swarm - Swarm object + * @param {Object} proposal - Proposal object + * @returns {Object} Strategy result { approved, approveCount, rejectCount, abstainCount, details } + * @private + */ + _applyVotingStrategy(swarm, proposal) { + const votes = Array.from(proposal.votes.values()); + const approveVotes = votes.filter((v) => v.vote === VOTE_OPTIONS.APPROVE); + const rejectVotes = votes.filter((v) => v.vote === VOTE_OPTIONS.REJECT); + const abstainVotes = votes.filter((v) => v.vote === VOTE_OPTIONS.ABSTAIN); + + const base = { + approveCount: approveVotes.length, + rejectCount: rejectVotes.length, + abstainCount: abstainVotes.length, + totalVotes: votes.length, + totalAgents: swarm.agents.size, + }; + + switch (swarm.config.votingStrategy) { + case VOTING_STRATEGIES.MAJORITY: + return this._majorityStrategy(base); + + case VOTING_STRATEGIES.WEIGHTED: + return this._weightedStrategy(swarm, approveVotes, rejectVotes, base); + + case VOTING_STRATEGIES.UNANIMOUS: + return this._unanimousStrategy(base); + + case VOTING_STRATEGIES.QUORUM: + return this._quorumStrategy(swarm, base); + + default: + return this._majorityStrategy(base); + } + } + + /** + * Majority: simple majority of non-abstain votes + * @private + */ + _majorityStrategy(base) { + const nonAbstain = base.approveCount + base.rejectCount; + const approved = nonAbstain > 0 && base.approveCount > nonAbstain / 2; + return { ...base, approved, strategy: VOTING_STRATEGIES.MAJORITY }; + } + + /** + * Weighted: votes weighted by agent reputation and confidence + * @private + */ + _weightedStrategy(swarm, approveVotes, rejectVotes, base) { + let approveWeight = 0; + let rejectWeight = 0; + + for (const v of approveVotes) { + const agent = swarm.agents.get(v.agentId); + const reputation = agent ? agent.reputation : 1.0; + approveWeight += v.confidence * reputation; + } + + for (const v of rejectVotes) { + const agent = swarm.agents.get(v.agentId); + const reputation = agent ? agent.reputation : 1.0; + rejectWeight += v.confidence * reputation; + } + + const totalWeight = approveWeight + rejectWeight; + const approved = totalWeight > 0 && approveWeight > totalWeight / 2; + + return { + ...base, + approved, + approveWeight: Math.round(approveWeight * 100) / 100, + rejectWeight: Math.round(rejectWeight * 100) / 100, + strategy: VOTING_STRATEGIES.WEIGHTED, + }; + } + + /** + * Unanimous: all non-abstain votes must approve + * @private + */ + _unanimousStrategy(base) { + const nonAbstain = base.approveCount + base.rejectCount; + const approved = nonAbstain > 0 && base.rejectCount === 0; + return { ...base, approved, strategy: VOTING_STRATEGIES.UNANIMOUS }; + } + + /** + * Quorum: requires consensusThreshold proportion of total agents to approve + * @private + */ + _quorumStrategy(swarm, base) { + const quorumRequired = Math.ceil(swarm.agents.size * swarm.config.consensusThreshold); + const hasQuorum = base.totalVotes >= quorumRequired; + const approved = hasQuorum && base.approveCount > base.rejectCount; + + return { + ...base, + approved, + quorumRequired, + hasQuorum, + strategy: VOTING_STRATEGIES.QUORUM, + }; + } + + /** + * Update agent reputations based on vote alignment with the final result + * Agents who voted with the majority gain reputation, others lose slightly + * @param {Object} swarm - Swarm object + * @param {Object} proposal - Resolved proposal + * @param {boolean} approved - Whether the proposal was approved + * @private + */ + _updateReputations(swarm, proposal, approved) { + for (const [agentId, voteData] of proposal.votes) { + const agent = swarm.agents.get(agentId); + if (!agent) continue; + + const alignedWithResult = + (approved && voteData.vote === VOTE_OPTIONS.APPROVE) || + (!approved && voteData.vote === VOTE_OPTIONS.REJECT); + + if (voteData.vote === VOTE_OPTIONS.ABSTAIN) { + // Abstaining has no reputation effect + continue; + } + + if (alignedWithResult) { + agent.reputation = Math.min(2.0, agent.reputation + 0.05); + } else { + agent.reputation = Math.max(0.1, agent.reputation - 0.03); + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // KNOWLEDGE MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Share a knowledge artifact with the swarm + * @param {string} swarmId - Swarm identifier + * @param {string} agentId - Sharing agent ID + * @param {Object} knowledge - Knowledge artifact + * @param {string} knowledge.topic - Knowledge topic + * @param {*} knowledge.content - Knowledge content + * @param {string[]} [knowledge.tags=[]] - Searchable tags + * @returns {Object} Created knowledge entry + */ + shareKnowledge(swarmId, agentId, knowledge) { + const swarm = this._getActiveSwarm(swarmId); + + if (!swarm.agents.has(agentId)) { + throw new Error(`Agent ${agentId} is not a member of swarm ${swarmId}`); + } + + if (!knowledge || typeof knowledge !== 'object') { + throw new Error('knowledge is required and must be an object'); + } + if (!knowledge.topic || typeof knowledge.topic !== 'string') { + throw new Error('knowledge.topic is required'); + } + if (knowledge.content === undefined || knowledge.content === null) { + throw new Error('knowledge.content is required'); + } + + const id = generateId(); + const entry = { + id, + sharedBy: agentId, + topic: knowledge.topic, + content: knowledge.content, + tags: Array.isArray(knowledge.tags) ? [...knowledge.tags] : [], + timestamp: new Date().toISOString(), + citations: 0, + }; + + swarm.knowledgeBase.push(entry); + this._stats.knowledgeShared++; + + this.emit('knowledge:shared', { swarmId, agentId, knowledgeId: id, topic: knowledge.topic }); + this._log(`Knowledge shared in swarm ${swarmId}: ${knowledge.topic}`); + this._persistAsync(); + + return entry; + } + + /** + * Query the collective knowledge base of a swarm + * @param {string} swarmId - Swarm identifier + * @param {Object} query - Query parameters + * @param {string} [query.topic] - Filter by topic (substring match) + * @param {string[]} [query.tags] - Filter by tags (any match) + * @param {string} [query.sharedBy] - Filter by agent + * @param {number} [query.limit=10] - Max results + * @returns {Object[]} Matching knowledge entries + */ + queryKnowledge(swarmId, query = {}) { + const swarm = this._getActiveSwarm(swarmId); + let results = [...swarm.knowledgeBase]; + + if (query.topic) { + const topicLower = query.topic.toLowerCase(); + results = results.filter((k) => k.topic.toLowerCase().includes(topicLower)); + } + + if (query.tags && Array.isArray(query.tags) && query.tags.length > 0) { + const queryTags = query.tags.map((t) => t.toLowerCase()); + results = results.filter((k) => + k.tags.some((tag) => queryTags.includes(tag.toLowerCase())) + ); + } + + if (query.sharedBy) { + results = results.filter((k) => k.sharedBy === query.sharedBy); + } + + const limit = query.limit ?? 10; + + // Increment citation count for returned results + const limited = results.slice(0, limit); + for (const entry of limited) { + const original = swarm.knowledgeBase.find((k) => k.id === entry.id); + if (original) { + original.citations++; + } + } + + return limited; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LEADER ELECTION + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Elect a leader for the swarm based on criterion + * @param {string} swarmId - Swarm identifier + * @param {string} [criterion='most-capable'] - Election criterion + * @returns {Object} Election result { leaderId, criterion, agentDetails } + */ + electLeader(swarmId, criterion = LEADER_CRITERIA.MOST_CAPABLE) { + const swarm = this._getActiveSwarm(swarmId); + + if (swarm.agents.size === 0) { + throw new Error(`Swarm ${swarmId} has no agents to elect a leader from`); + } + + const validCriteria = Object.values(LEADER_CRITERIA); + if (!validCriteria.includes(criterion)) { + throw new Error(`Invalid criterion: ${criterion}. Must be one of: ${validCriteria.join(', ')}`); + } + + let leaderId; + const agents = Array.from(swarm.agents.entries()); + + switch (criterion) { + case LEADER_CRITERIA.MOST_CAPABLE: { + // Agent with most capabilities wins + let maxCapabilities = -1; + for (const [id, agent] of agents) { + if (agent.capabilities.length > maxCapabilities) { + maxCapabilities = agent.capabilities.length; + leaderId = id; + } + } + break; + } + + case LEADER_CRITERIA.HIGHEST_REPUTATION: { + // Agent with highest reputation wins + let maxReputation = -1; + for (const [id, agent] of agents) { + if (agent.reputation > maxReputation) { + maxReputation = agent.reputation; + leaderId = id; + } + } + break; + } + + case LEADER_CRITERIA.ROUND_ROBIN: { + // Rotate through agents in insertion order + const agentIds = Array.from(swarm.agents.keys()); + const currentIndex = this._roundRobinIndex.get(swarmId) ?? 0; + leaderId = agentIds[currentIndex % agentIds.length]; + this._roundRobinIndex.set(swarmId, (currentIndex + 1) % agentIds.length); + break; + } + } + + swarm.leader = leaderId; + this._stats.leadersElected++; + + const leaderAgent = swarm.agents.get(leaderId); + const result = { + leaderId, + criterion, + agentDetails: { ...leaderAgent }, + }; + + this.emit('leader:elected', { swarmId, leaderId, criterion }); + this._log(`Leader elected in swarm ${swarmId}: ${leaderId} (criterion: ${criterion})`); + this._persistAsync(); + + return result; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // HEALTH & STATS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Get health metrics for a swarm + * @param {string} swarmId - Swarm identifier + * @returns {Object} Health metrics + */ + getSwarmHealth(swarmId) { + const swarm = this._getSwarm(swarmId); + + const agentCount = swarm.agents.size; + const pendingProposals = swarm.proposals.filter((p) => p.status === PROPOSAL_STATUS.PENDING).length; + const resolvedProposals = swarm.proposals.filter( + (p) => p.status === PROPOSAL_STATUS.APPROVED || p.status === PROPOSAL_STATUS.REJECTED + ).length; + + const agents = Array.from(swarm.agents.values()); + const avgReputation = agents.length > 0 + ? Math.round((agents.reduce((sum, a) => sum + a.reputation, 0) / agents.length) * 100) / 100 + : 0; + + const hasLeader = swarm.leader !== null; + const meetsMinAgents = agentCount >= swarm.config.minAgents; + + // Health score: 0-100 + let healthScore = 0; + if (swarm.status === SWARM_STATUS.ACTIVE) { + healthScore += 30; // Base for being active + if (meetsMinAgents) healthScore += 25; + if (hasLeader) healthScore += 15; + if (avgReputation >= 1.0) healthScore += 15; + if (resolvedProposals > 0) healthScore += 15; + } + + return { + swarmId: swarm.id, + name: swarm.name, + status: swarm.status, + agentCount, + minAgents: swarm.config.minAgents, + maxAgents: swarm.config.maxAgents, + meetsMinAgents, + hasLeader, + leader: swarm.leader, + pendingProposals, + resolvedProposals, + knowledgeEntries: swarm.knowledgeBase.length, + avgReputation, + healthScore, + createdAt: swarm.createdAt, + }; + } + + /** + * Get global statistics across all swarms + * @returns {Object} Global stats + */ + getStats() { + const activeSwarms = Array.from(this.swarms.values()).filter( + (s) => s.status === SWARM_STATUS.ACTIVE + ).length; + + const totalAgents = Array.from(this.swarms.values()).reduce( + (sum, s) => sum + s.agents.size, 0 + ); + + return { + ...this._stats, + activeSwarms, + totalSwarms: this.swarms.size, + totalAgents, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PERSISTENCE + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Persist state to disk asynchronously (fire-and-forget) + * @private + */ + _persistAsync() { + if (!this.options.persist) return; + + this._saveToDisk().catch((err) => { + this._log(`Persistence error: ${err.message}`); + }); + } + + /** + * Save current state to disk + * @returns {Promise} + * @private + */ + async _saveToDisk() { + const dir = path.dirname(this._persistPath); + await fs.mkdir(dir, { recursive: true }); + + const data = { + version: '1.0.0', + savedAt: new Date().toISOString(), + stats: this._stats, + swarms: this._serializeSwarms(), + }; + + await fs.writeFile(this._persistPath, JSON.stringify(data, null, 2), 'utf8'); + } + + /** + * Load state from disk + * @returns {Promise} Whether state was loaded + */ + async loadFromDisk() { + try { + const raw = await fs.readFile(this._persistPath, 'utf8'); + const data = JSON.parse(raw); + + if (data.stats) { + this._stats = { ...this._stats, ...data.stats }; + } + + if (data.swarms && Array.isArray(data.swarms)) { + for (const s of data.swarms) { + const swarm = { + ...s, + agents: new Map(Object.entries(s.agents ?? {})), + proposals: (s.proposals ?? []).map((p) => ({ + ...p, + votes: new Map(Object.entries(p.votes ?? {})), + })), + knowledgeBase: s.knowledgeBase ?? [], + }; + this.swarms.set(swarm.id, swarm); + } + } + + this._log('State loaded from disk'); + return true; + } catch { + // File doesn't exist or is corrupted — start fresh + return false; + } + } + + /** + * Serialize swarms for JSON persistence (Maps to plain objects) + * @returns {Object[]} + * @private + */ + _serializeSwarms() { + const result = []; + for (const [, swarm] of this.swarms) { + result.push({ + ...swarm, + agents: Object.fromEntries(swarm.agents), + proposals: swarm.proposals.map((p) => ({ + ...p, + votes: Object.fromEntries(p.votes), + })), + }); + } + return result; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INTERNAL HELPERS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Get a swarm by ID (any status) + * @param {string} swarmId + * @returns {Object} + * @private + */ + _getSwarm(swarmId) { + const swarm = this.swarms.get(swarmId); + if (!swarm) { + throw new Error(`Swarm not found: ${swarmId}`); + } + return swarm; + } + + /** + * Get an active swarm by ID + * @param {string} swarmId + * @returns {Object} + * @private + */ + _getActiveSwarm(swarmId) { + const swarm = this._getSwarm(swarmId); + if (swarm.status !== SWARM_STATUS.ACTIVE) { + throw new Error(`Swarm ${swarmId} is not active (status: ${swarm.status})`); + } + return swarm; + } + + /** + * Get a proposal from a swarm + * @param {Object} swarm + * @param {string} proposalId + * @returns {Object} + * @private + */ + _getProposal(swarm, proposalId) { + const proposal = swarm.proposals.find((p) => p.id === proposalId); + if (!proposal) { + throw new Error(`Proposal not found: ${proposalId}`); + } + return proposal; + } + + /** + * Get a pending proposal from a swarm + * @param {Object} swarm + * @param {string} proposalId + * @returns {Object} + * @private + */ + _getPendingProposal(swarm, proposalId) { + const proposal = this._getProposal(swarm, proposalId); + if (proposal.status !== PROPOSAL_STATUS.PENDING) { + throw new Error(`Proposal ${proposalId} is not pending (status: ${proposal.status})`); + } + return proposal; + } + + /** + * Debug logging + * @param {string} message + * @private + */ + _log(message) { + if (this.options.debug) { + console.log(`[SwarmIntelligence] ${message}`); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// EXPORTS +// ═══════════════════════════════════════════════════════════════════════════════ + +module.exports = SwarmIntelligence; +module.exports.SwarmIntelligence = SwarmIntelligence; +module.exports.VOTING_STRATEGIES = VOTING_STRATEGIES; +module.exports.PROPOSAL_STATUS = PROPOSAL_STATUS; +module.exports.SWARM_STATUS = SWARM_STATUS; +module.exports.LEADER_CRITERIA = LEADER_CRITERIA; +module.exports.VOTE_OPTIONS = VOTE_OPTIONS; diff --git a/.aiox-core/core/orchestration/swarm-intelligence.js b/.aiox-core/core/orchestration/swarm-intelligence.js new file mode 100644 index 000000000..171e710dc --- /dev/null +++ b/.aiox-core/core/orchestration/swarm-intelligence.js @@ -0,0 +1,1004 @@ +/** + * Agent Swarm Intelligence + * Story ORCH-5 - Emergent intelligence from multi-agent collaboration + * @module aiox-core/orchestration/swarm-intelligence + * @version 1.0.0 + */ + +'use strict'; + +const { EventEmitter } = require('events'); +const fs = require('fs').promises; +const path = require('path'); +const crypto = require('crypto'); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CONSTANTS +// ═══════════════════════════════════════════════════════════════════════════════ + +const PERSISTENCE_DIR = '.aiox'; +const PERSISTENCE_FILE = 'swarms.json'; + +const VOTING_STRATEGIES = { + MAJORITY: 'majority', + WEIGHTED: 'weighted', + UNANIMOUS: 'unanimous', + QUORUM: 'quorum', +}; + +const PROPOSAL_STATUS = { + PENDING: 'pending', + APPROVED: 'approved', + REJECTED: 'rejected', + EXPIRED: 'expired', +}; + +const SWARM_STATUS = { + ACTIVE: 'active', + DISSOLVED: 'dissolved', +}; + +const LEADER_CRITERIA = { + MOST_CAPABLE: 'most-capable', + HIGHEST_REPUTATION: 'highest-reputation', + ROUND_ROBIN: 'round-robin', +}; + +const VOTE_OPTIONS = { + APPROVE: 'approve', + REJECT: 'reject', + ABSTAIN: 'abstain', +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Generate a unique identifier + * @returns {string} UUID-like identifier + */ +function generateId() { + return crypto.randomBytes(8).toString('hex'); +} + +/** + * Validate confidence value is between 0 and 1 + * @param {number} confidence + * @returns {boolean} + */ +function isValidConfidence(confidence) { + return typeof confidence === 'number' && confidence >= 0 && confidence <= 1; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// SWARM INTELLIGENCE +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * SwarmIntelligence - Emergent intelligence from multi-agent collaboration + * + * Provides swarm creation, agent coordination, collective decision-making + * via voting strategies, shared knowledge management, and leader election. + * + * @extends EventEmitter + */ +class SwarmIntelligence extends EventEmitter { + /** + * Creates a new SwarmIntelligence instance + * @param {string} projectRoot - Project root directory for persistence + * @param {Object} [options] - Configuration options + * @param {boolean} [options.debug=false] - Enable debug logging + * @param {boolean} [options.persist=true] - Enable persistence to disk + */ + constructor(projectRoot, options = {}) { + super(); + + if (!projectRoot || typeof projectRoot !== 'string') { + throw new Error('projectRoot is required and must be a string'); + } + + this.projectRoot = projectRoot; + this.options = { + debug: options.debug ?? false, + persist: options.persist ?? true, + }; + + /** @type {Map} Active swarms indexed by ID */ + this.swarms = new Map(); + + /** @type {Object} Global statistics */ + this._stats = { + swarmsCreated: 0, + swarmsDissolved: 0, + proposalsCreated: 0, + proposalsResolved: 0, + knowledgeShared: 0, + leadersElected: 0, + totalVotes: 0, + }; + + /** @type {number} Round-robin index tracking per swarm */ + this._roundRobinIndex = new Map(); + + this._persistPath = path.join(projectRoot, PERSISTENCE_DIR, PERSISTENCE_FILE); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SWARM MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Create a named swarm with configuration + * @param {string} name - Swarm name + * @param {Object} [config] - Swarm configuration + * @param {number} [config.minAgents=2] - Minimum agents required + * @param {number} [config.maxAgents=50] - Maximum agents allowed + * @param {number} [config.consensusThreshold=0.6] - Consensus threshold (0-1) + * @param {string} [config.votingStrategy='majority'] - Voting strategy + * @returns {Object} Created swarm + */ + createSwarm(name, config = {}) { + if (!name || typeof name !== 'string') { + throw new Error('Swarm name is required and must be a string'); + } + + const votingStrategy = config.votingStrategy ?? VOTING_STRATEGIES.MAJORITY; + if (!Object.values(VOTING_STRATEGIES).includes(votingStrategy)) { + throw new Error(`Invalid voting strategy: ${votingStrategy}. Must be one of: ${Object.values(VOTING_STRATEGIES).join(', ')}`); + } + + const consensusThreshold = config.consensusThreshold ?? 0.6; + if (typeof consensusThreshold !== 'number' || consensusThreshold < 0 || consensusThreshold > 1) { + throw new Error('consensusThreshold must be a number between 0 and 1'); + } + + const minAgents = config.minAgents ?? 2; + const maxAgents = config.maxAgents ?? 50; + + if (minAgents < 1) { + throw new Error('minAgents must be at least 1'); + } + if (maxAgents < minAgents) { + throw new Error('maxAgents must be >= minAgents'); + } + + const id = generateId(); + const swarm = { + id, + name, + agents: new Map(), + proposals: [], + knowledgeBase: [], + leader: null, + config: { + minAgents, + maxAgents, + consensusThreshold, + votingStrategy, + }, + createdAt: new Date().toISOString(), + status: SWARM_STATUS.ACTIVE, + }; + + this.swarms.set(id, swarm); + this._stats.swarmsCreated++; + this._roundRobinIndex.set(id, 0); + + this.emit('swarm:created', { swarmId: id, name, config: swarm.config }); + this._log(`Swarm created: ${name} (${id})`); + this._persistAsync(); + + return swarm; + } + + /** + * Agent joins a swarm with declared capabilities + * @param {string} swarmId - Swarm identifier + * @param {string} agentId - Agent identifier + * @param {string[]} [capabilities=[]] - Agent capabilities + * @returns {Object} Updated swarm + */ + joinSwarm(swarmId, agentId, capabilities = []) { + const swarm = this._getActiveSwarm(swarmId); + + if (!agentId || typeof agentId !== 'string') { + throw new Error('agentId is required and must be a string'); + } + + if (swarm.agents.has(agentId)) { + throw new Error(`Agent ${agentId} is already a member of swarm ${swarmId}`); + } + + if (swarm.agents.size >= swarm.config.maxAgents) { + throw new Error(`Swarm ${swarmId} has reached maximum capacity (${swarm.config.maxAgents})`); + } + + const agent = { + id: agentId, + capabilities: Array.isArray(capabilities) ? [...capabilities] : [], + joinedAt: new Date().toISOString(), + reputation: 1.0, + votesCount: 0, + }; + + swarm.agents.set(agentId, agent); + + this.emit('swarm:joined', { swarmId, agentId, capabilities: agent.capabilities }); + this._log(`Agent ${agentId} joined swarm ${swarmId}`); + this._persistAsync(); + + return swarm; + } + + /** + * Agent leaves a swarm + * @param {string} swarmId - Swarm identifier + * @param {string} agentId - Agent identifier + * @returns {Object} Updated swarm + */ + leaveSwarm(swarmId, agentId) { + const swarm = this._getActiveSwarm(swarmId); + + if (!swarm.agents.has(agentId)) { + throw new Error(`Agent ${agentId} is not a member of swarm ${swarmId}`); + } + + swarm.agents.delete(agentId); + + // If the leader left, clear leadership + if (swarm.leader === agentId) { + swarm.leader = null; + } + + this.emit('swarm:left', { swarmId, agentId }); + this._log(`Agent ${agentId} left swarm ${swarmId}`); + this._persistAsync(); + + return swarm; + } + + /** + * Dissolve a swarm + * @param {string} swarmId - Swarm identifier + * @returns {Object} Dissolved swarm summary + */ + dissolveSwarm(swarmId) { + const swarm = this._getActiveSwarm(swarmId); + + swarm.status = SWARM_STATUS.DISSOLVED; + swarm.dissolvedAt = new Date().toISOString(); + this._stats.swarmsDissolved++; + + const summary = { + id: swarm.id, + name: swarm.name, + agentCount: swarm.agents.size, + proposalCount: swarm.proposals.length, + knowledgeCount: swarm.knowledgeBase.length, + dissolvedAt: swarm.dissolvedAt, + }; + + this.emit('swarm:dissolved', { swarmId, summary }); + this._log(`Swarm dissolved: ${swarm.name} (${swarmId})`); + this._persistAsync(); + + return summary; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DECISION MAKING + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Submit a proposal for collective voting + * @param {string} swarmId - Swarm identifier + * @param {Object} proposal - Proposal details + * @param {string} proposal.description - Proposal description + * @param {string} proposal.proposedBy - Agent ID proposing + * @param {string} [proposal.type='general'] - Proposal type + * @param {number} [proposal.deadlineMs=300000] - Deadline in milliseconds (default 5 min) + * @returns {Object} Created proposal + */ + proposeDecision(swarmId, proposal) { + const swarm = this._getActiveSwarm(swarmId); + + if (!proposal || typeof proposal !== 'object') { + throw new Error('proposal is required and must be an object'); + } + if (!proposal.description || typeof proposal.description !== 'string') { + throw new Error('proposal.description is required'); + } + if (!proposal.proposedBy || typeof proposal.proposedBy !== 'string') { + throw new Error('proposal.proposedBy is required'); + } + if (!swarm.agents.has(proposal.proposedBy)) { + throw new Error(`Agent ${proposal.proposedBy} is not a member of swarm ${swarmId}`); + } + + const id = generateId(); + const deadlineMs = proposal.deadlineMs ?? 300000; + const created = { + id, + swarmId, + proposedBy: proposal.proposedBy, + description: proposal.description, + type: proposal.type ?? 'general', + votes: new Map(), + status: PROPOSAL_STATUS.PENDING, + createdAt: new Date().toISOString(), + deadline: new Date(Date.now() + deadlineMs).toISOString(), + }; + + swarm.proposals.push(created); + this._stats.proposalsCreated++; + + this.emit('proposal:created', { swarmId, proposalId: id, proposedBy: proposal.proposedBy }); + this._log(`Proposal created in swarm ${swarmId}: ${proposal.description}`); + this._persistAsync(); + + return created; + } + + /** + * Cast a vote on a proposal + * @param {string} swarmId - Swarm identifier + * @param {string} proposalId - Proposal identifier + * @param {string} agentId - Voting agent ID + * @param {string} voteValue - Vote: 'approve', 'reject', or 'abstain' + * @param {number} [confidence=1.0] - Confidence level (0-1) + * @returns {Object} Updated proposal + */ + vote(swarmId, proposalId, agentId, voteValue, confidence = 1.0) { + const swarm = this._getActiveSwarm(swarmId); + const proposal = this._getPendingProposal(swarm, proposalId); + + if (!swarm.agents.has(agentId)) { + throw new Error(`Agent ${agentId} is not a member of swarm ${swarmId}`); + } + + if (!Object.values(VOTE_OPTIONS).includes(voteValue)) { + throw new Error(`Invalid vote: ${voteValue}. Must be one of: ${Object.values(VOTE_OPTIONS).join(', ')}`); + } + + if (!isValidConfidence(confidence)) { + throw new Error('confidence must be a number between 0 and 1'); + } + + if (proposal.votes.has(agentId)) { + throw new Error(`Agent ${agentId} has already voted on proposal ${proposalId}`); + } + + // Check deadline + if (new Date(proposal.deadline) < new Date()) { + proposal.status = PROPOSAL_STATUS.EXPIRED; + throw new Error(`Proposal ${proposalId} has expired`); + } + + proposal.votes.set(agentId, { + agentId, + vote: voteValue, + confidence, + timestamp: new Date().toISOString(), + }); + + // Update agent stats + const agent = swarm.agents.get(agentId); + agent.votesCount++; + this._stats.totalVotes++; + + this.emit('proposal:voted', { swarmId, proposalId, agentId, vote: voteValue, confidence }); + this._log(`Agent ${agentId} voted '${voteValue}' on proposal ${proposalId} (confidence: ${confidence})`); + this._persistAsync(); + + return proposal; + } + + /** + * Resolve a proposal based on votes and configured strategy + * @param {string} swarmId - Swarm identifier + * @param {string} proposalId - Proposal identifier + * @returns {Object} Resolution result + */ + resolveProposal(swarmId, proposalId) { + const swarm = this._getActiveSwarm(swarmId); + const proposal = this._getProposal(swarm, proposalId); + + if (proposal.status !== PROPOSAL_STATUS.PENDING) { + throw new Error(`Proposal ${proposalId} is already resolved (status: ${proposal.status})`); + } + + const result = this._applyVotingStrategy(swarm, proposal); + + proposal.status = result.approved ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.REJECTED; + proposal.resolvedAt = new Date().toISOString(); + proposal.resolution = result; + this._stats.proposalsResolved++; + + // Update reputation based on alignment with result + this._updateReputations(swarm, proposal, result.approved); + + this.emit('proposal:resolved', { + swarmId, + proposalId, + status: proposal.status, + result, + }); + this._log(`Proposal ${proposalId} resolved: ${proposal.status}`); + this._persistAsync(); + + return { + proposalId, + status: proposal.status, + ...result, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // VOTING STRATEGIES + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Apply the configured voting strategy to determine outcome + * @param {Object} swarm - Swarm object + * @param {Object} proposal - Proposal object + * @returns {Object} Strategy result { approved, approveCount, rejectCount, abstainCount, details } + * @private + */ + _applyVotingStrategy(swarm, proposal) { + const votes = Array.from(proposal.votes.values()); + const approveVotes = votes.filter((v) => v.vote === VOTE_OPTIONS.APPROVE); + const rejectVotes = votes.filter((v) => v.vote === VOTE_OPTIONS.REJECT); + const abstainVotes = votes.filter((v) => v.vote === VOTE_OPTIONS.ABSTAIN); + + const base = { + approveCount: approveVotes.length, + rejectCount: rejectVotes.length, + abstainCount: abstainVotes.length, + totalVotes: votes.length, + totalAgents: swarm.agents.size, + }; + + switch (swarm.config.votingStrategy) { + case VOTING_STRATEGIES.MAJORITY: + return this._majorityStrategy(base); + + case VOTING_STRATEGIES.WEIGHTED: + return this._weightedStrategy(swarm, approveVotes, rejectVotes, base); + + case VOTING_STRATEGIES.UNANIMOUS: + return this._unanimousStrategy(base); + + case VOTING_STRATEGIES.QUORUM: + return this._quorumStrategy(swarm, base); + + default: + return this._majorityStrategy(base); + } + } + + /** + * Majority: simple majority of non-abstain votes + * @private + */ + _majorityStrategy(base) { + const nonAbstain = base.approveCount + base.rejectCount; + const approved = nonAbstain > 0 && base.approveCount > nonAbstain / 2; + return { ...base, approved, strategy: VOTING_STRATEGIES.MAJORITY }; + } + + /** + * Weighted: votes weighted by agent reputation and confidence + * @private + */ + _weightedStrategy(swarm, approveVotes, rejectVotes, base) { + let approveWeight = 0; + let rejectWeight = 0; + + for (const v of approveVotes) { + const agent = swarm.agents.get(v.agentId); + const reputation = agent ? agent.reputation : 1.0; + approveWeight += v.confidence * reputation; + } + + for (const v of rejectVotes) { + const agent = swarm.agents.get(v.agentId); + const reputation = agent ? agent.reputation : 1.0; + rejectWeight += v.confidence * reputation; + } + + const totalWeight = approveWeight + rejectWeight; + const approved = totalWeight > 0 && approveWeight > totalWeight / 2; + + return { + ...base, + approved, + approveWeight: Math.round(approveWeight * 100) / 100, + rejectWeight: Math.round(rejectWeight * 100) / 100, + strategy: VOTING_STRATEGIES.WEIGHTED, + }; + } + + /** + * Unanimous: all non-abstain votes must approve + * @private + */ + _unanimousStrategy(base) { + const nonAbstain = base.approveCount + base.rejectCount; + const approved = nonAbstain > 0 && base.rejectCount === 0; + return { ...base, approved, strategy: VOTING_STRATEGIES.UNANIMOUS }; + } + + /** + * Quorum: requires consensusThreshold proportion of total agents to approve + * @private + */ + _quorumStrategy(swarm, base) { + const quorumRequired = Math.ceil(swarm.agents.size * swarm.config.consensusThreshold); + const hasQuorum = base.totalVotes >= quorumRequired; + const approved = hasQuorum && base.approveCount > base.rejectCount; + + return { + ...base, + approved, + quorumRequired, + hasQuorum, + strategy: VOTING_STRATEGIES.QUORUM, + }; + } + + /** + * Update agent reputations based on vote alignment with the final result + * Agents who voted with the majority gain reputation, others lose slightly + * @param {Object} swarm - Swarm object + * @param {Object} proposal - Resolved proposal + * @param {boolean} approved - Whether the proposal was approved + * @private + */ + _updateReputations(swarm, proposal, approved) { + for (const [agentId, voteData] of proposal.votes) { + const agent = swarm.agents.get(agentId); + if (!agent) continue; + + const alignedWithResult = + (approved && voteData.vote === VOTE_OPTIONS.APPROVE) || + (!approved && voteData.vote === VOTE_OPTIONS.REJECT); + + if (voteData.vote === VOTE_OPTIONS.ABSTAIN) { + // Abstaining has no reputation effect + continue; + } + + if (alignedWithResult) { + agent.reputation = Math.min(2.0, agent.reputation + 0.05); + } else { + agent.reputation = Math.max(0.1, agent.reputation - 0.03); + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // KNOWLEDGE MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Share a knowledge artifact with the swarm + * @param {string} swarmId - Swarm identifier + * @param {string} agentId - Sharing agent ID + * @param {Object} knowledge - Knowledge artifact + * @param {string} knowledge.topic - Knowledge topic + * @param {*} knowledge.content - Knowledge content + * @param {string[]} [knowledge.tags=[]] - Searchable tags + * @returns {Object} Created knowledge entry + */ + shareKnowledge(swarmId, agentId, knowledge) { + const swarm = this._getActiveSwarm(swarmId); + + if (!swarm.agents.has(agentId)) { + throw new Error(`Agent ${agentId} is not a member of swarm ${swarmId}`); + } + + if (!knowledge || typeof knowledge !== 'object') { + throw new Error('knowledge is required and must be an object'); + } + if (!knowledge.topic || typeof knowledge.topic !== 'string') { + throw new Error('knowledge.topic is required'); + } + if (knowledge.content === undefined || knowledge.content === null) { + throw new Error('knowledge.content is required'); + } + + const id = generateId(); + const entry = { + id, + sharedBy: agentId, + topic: knowledge.topic, + content: knowledge.content, + tags: Array.isArray(knowledge.tags) ? [...knowledge.tags] : [], + timestamp: new Date().toISOString(), + citations: 0, + }; + + swarm.knowledgeBase.push(entry); + this._stats.knowledgeShared++; + + this.emit('knowledge:shared', { swarmId, agentId, knowledgeId: id, topic: knowledge.topic }); + this._log(`Knowledge shared in swarm ${swarmId}: ${knowledge.topic}`); + this._persistAsync(); + + return entry; + } + + /** + * Query the collective knowledge base of a swarm + * @param {string} swarmId - Swarm identifier + * @param {Object} query - Query parameters + * @param {string} [query.topic] - Filter by topic (substring match) + * @param {string[]} [query.tags] - Filter by tags (any match) + * @param {string} [query.sharedBy] - Filter by agent + * @param {number} [query.limit=10] - Max results + * @returns {Object[]} Matching knowledge entries + */ + queryKnowledge(swarmId, query = {}) { + const swarm = this._getActiveSwarm(swarmId); + let results = [...swarm.knowledgeBase]; + + if (query.topic) { + const topicLower = query.topic.toLowerCase(); + results = results.filter((k) => k.topic.toLowerCase().includes(topicLower)); + } + + if (query.tags && Array.isArray(query.tags) && query.tags.length > 0) { + const queryTags = query.tags.map((t) => t.toLowerCase()); + results = results.filter((k) => + k.tags.some((tag) => queryTags.includes(tag.toLowerCase())) + ); + } + + if (query.sharedBy) { + results = results.filter((k) => k.sharedBy === query.sharedBy); + } + + const limit = query.limit ?? 10; + + // Increment citation count for returned results + const limited = results.slice(0, limit); + for (const entry of limited) { + const original = swarm.knowledgeBase.find((k) => k.id === entry.id); + if (original) { + original.citations++; + } + } + + return limited; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LEADER ELECTION + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Elect a leader for the swarm based on criterion + * @param {string} swarmId - Swarm identifier + * @param {string} [criterion='most-capable'] - Election criterion + * @returns {Object} Election result { leaderId, criterion, agentDetails } + */ + electLeader(swarmId, criterion = LEADER_CRITERIA.MOST_CAPABLE) { + const swarm = this._getActiveSwarm(swarmId); + + if (swarm.agents.size === 0) { + throw new Error(`Swarm ${swarmId} has no agents to elect a leader from`); + } + + const validCriteria = Object.values(LEADER_CRITERIA); + if (!validCriteria.includes(criterion)) { + throw new Error(`Invalid criterion: ${criterion}. Must be one of: ${validCriteria.join(', ')}`); + } + + let leaderId; + const agents = Array.from(swarm.agents.entries()); + + switch (criterion) { + case LEADER_CRITERIA.MOST_CAPABLE: { + // Agent with most capabilities wins + let maxCapabilities = -1; + for (const [id, agent] of agents) { + if (agent.capabilities.length > maxCapabilities) { + maxCapabilities = agent.capabilities.length; + leaderId = id; + } + } + break; + } + + case LEADER_CRITERIA.HIGHEST_REPUTATION: { + // Agent with highest reputation wins + let maxReputation = -1; + for (const [id, agent] of agents) { + if (agent.reputation > maxReputation) { + maxReputation = agent.reputation; + leaderId = id; + } + } + break; + } + + case LEADER_CRITERIA.ROUND_ROBIN: { + // Rotate through agents in insertion order + const agentIds = Array.from(swarm.agents.keys()); + const currentIndex = this._roundRobinIndex.get(swarmId) ?? 0; + leaderId = agentIds[currentIndex % agentIds.length]; + this._roundRobinIndex.set(swarmId, (currentIndex + 1) % agentIds.length); + break; + } + } + + swarm.leader = leaderId; + this._stats.leadersElected++; + + const leaderAgent = swarm.agents.get(leaderId); + const result = { + leaderId, + criterion, + agentDetails: { ...leaderAgent }, + }; + + this.emit('leader:elected', { swarmId, leaderId, criterion }); + this._log(`Leader elected in swarm ${swarmId}: ${leaderId} (criterion: ${criterion})`); + this._persistAsync(); + + return result; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // HEALTH & STATS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Get health metrics for a swarm + * @param {string} swarmId - Swarm identifier + * @returns {Object} Health metrics + */ + getSwarmHealth(swarmId) { + const swarm = this._getSwarm(swarmId); + + const agentCount = swarm.agents.size; + const pendingProposals = swarm.proposals.filter((p) => p.status === PROPOSAL_STATUS.PENDING).length; + const resolvedProposals = swarm.proposals.filter( + (p) => p.status === PROPOSAL_STATUS.APPROVED || p.status === PROPOSAL_STATUS.REJECTED + ).length; + + const agents = Array.from(swarm.agents.values()); + const avgReputation = agents.length > 0 + ? Math.round((agents.reduce((sum, a) => sum + a.reputation, 0) / agents.length) * 100) / 100 + : 0; + + const hasLeader = swarm.leader !== null; + const meetsMinAgents = agentCount >= swarm.config.minAgents; + + // Health score: 0-100 + let healthScore = 0; + if (swarm.status === SWARM_STATUS.ACTIVE) { + healthScore += 30; // Base for being active + if (meetsMinAgents) healthScore += 25; + if (hasLeader) healthScore += 15; + if (avgReputation >= 1.0) healthScore += 15; + if (resolvedProposals > 0) healthScore += 15; + } + + return { + swarmId: swarm.id, + name: swarm.name, + status: swarm.status, + agentCount, + minAgents: swarm.config.minAgents, + maxAgents: swarm.config.maxAgents, + meetsMinAgents, + hasLeader, + leader: swarm.leader, + pendingProposals, + resolvedProposals, + knowledgeEntries: swarm.knowledgeBase.length, + avgReputation, + healthScore, + createdAt: swarm.createdAt, + }; + } + + /** + * Get global statistics across all swarms + * @returns {Object} Global stats + */ + getStats() { + const activeSwarms = Array.from(this.swarms.values()).filter( + (s) => s.status === SWARM_STATUS.ACTIVE + ).length; + + const totalAgents = Array.from(this.swarms.values()).reduce( + (sum, s) => sum + s.agents.size, 0 + ); + + return { + ...this._stats, + activeSwarms, + totalSwarms: this.swarms.size, + totalAgents, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PERSISTENCE + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Persist state to disk asynchronously (fire-and-forget) + * @private + */ + _persistAsync() { + if (!this.options.persist) return; + + this._saveToDisk().catch((err) => { + this._log(`Persistence error: ${err.message}`); + }); + } + + /** + * Save current state to disk + * @returns {Promise} + * @private + */ + async _saveToDisk() { + const dir = path.dirname(this._persistPath); + await fs.mkdir(dir, { recursive: true }); + + const data = { + version: '1.0.0', + savedAt: new Date().toISOString(), + stats: this._stats, + swarms: this._serializeSwarms(), + }; + + await fs.writeFile(this._persistPath, JSON.stringify(data, null, 2), 'utf8'); + } + + /** + * Load state from disk + * @returns {Promise} Whether state was loaded + */ + async loadFromDisk() { + try { + const raw = await fs.readFile(this._persistPath, 'utf8'); + const data = JSON.parse(raw); + + if (data.stats) { + this._stats = { ...this._stats, ...data.stats }; + } + + if (data.swarms && Array.isArray(data.swarms)) { + for (const s of data.swarms) { + const swarm = { + ...s, + agents: new Map(Object.entries(s.agents ?? {})), + proposals: (s.proposals ?? []).map((p) => ({ + ...p, + votes: new Map(Object.entries(p.votes ?? {})), + })), + knowledgeBase: s.knowledgeBase ?? [], + }; + this.swarms.set(swarm.id, swarm); + } + } + + this._log('State loaded from disk'); + return true; + } catch { + // File doesn't exist or is corrupted — start fresh + return false; + } + } + + /** + * Serialize swarms for JSON persistence (Maps to plain objects) + * @returns {Object[]} + * @private + */ + _serializeSwarms() { + const result = []; + for (const [, swarm] of this.swarms) { + result.push({ + ...swarm, + agents: Object.fromEntries(swarm.agents), + proposals: swarm.proposals.map((p) => ({ + ...p, + votes: Object.fromEntries(p.votes), + })), + }); + } + return result; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INTERNAL HELPERS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Get a swarm by ID (any status) + * @param {string} swarmId + * @returns {Object} + * @private + */ + _getSwarm(swarmId) { + const swarm = this.swarms.get(swarmId); + if (!swarm) { + throw new Error(`Swarm not found: ${swarmId}`); + } + return swarm; + } + + /** + * Get an active swarm by ID + * @param {string} swarmId + * @returns {Object} + * @private + */ + _getActiveSwarm(swarmId) { + const swarm = this._getSwarm(swarmId); + if (swarm.status !== SWARM_STATUS.ACTIVE) { + throw new Error(`Swarm ${swarmId} is not active (status: ${swarm.status})`); + } + return swarm; + } + + /** + * Get a proposal from a swarm + * @param {Object} swarm + * @param {string} proposalId + * @returns {Object} + * @private + */ + _getProposal(swarm, proposalId) { + const proposal = swarm.proposals.find((p) => p.id === proposalId); + if (!proposal) { + throw new Error(`Proposal not found: ${proposalId}`); + } + return proposal; + } + + /** + * Get a pending proposal from a swarm + * @param {Object} swarm + * @param {string} proposalId + * @returns {Object} + * @private + */ + _getPendingProposal(swarm, proposalId) { + const proposal = this._getProposal(swarm, proposalId); + if (proposal.status !== PROPOSAL_STATUS.PENDING) { + throw new Error(`Proposal ${proposalId} is not pending (status: ${proposal.status})`); + } + return proposal; + } + + /** + * Debug logging + * @param {string} message + * @private + */ + _log(message) { + if (this.options.debug) { + console.log(`[SwarmIntelligence] ${message}`); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// EXPORTS +// ═══════════════════════════════════════════════════════════════════════════════ + +module.exports = SwarmIntelligence; +module.exports.SwarmIntelligence = SwarmIntelligence; +module.exports.VOTING_STRATEGIES = VOTING_STRATEGIES; +module.exports.PROPOSAL_STATUS = PROPOSAL_STATUS; +module.exports.SWARM_STATUS = SWARM_STATUS; +module.exports.LEADER_CRITERIA = LEADER_CRITERIA; +module.exports.VOTE_OPTIONS = VOTE_OPTIONS; diff --git a/tests/core/orchestration/swarm-intelligence.test.js b/tests/core/orchestration/swarm-intelligence.test.js new file mode 100644 index 000000000..909852eeb --- /dev/null +++ b/tests/core/orchestration/swarm-intelligence.test.js @@ -0,0 +1,947 @@ +/** + * Swarm Intelligence Tests + * Story ORCH-5 - Emergent intelligence from multi-agent collaboration + * + * 40+ test cases covering all methods, voting strategies, edge cases + */ + +const path = require('path'); +const fs = require('fs').promises; + +const SwarmIntelligence = require('../../../.aiox-core/core/orchestration/swarm-intelligence'); +const { + VOTING_STRATEGIES, + PROPOSAL_STATUS, + SWARM_STATUS, + LEADER_CRITERIA, + VOTE_OPTIONS, +} = SwarmIntelligence; + +// Test fixtures +const TEST_PROJECT_ROOT = path.join(__dirname, '../../fixtures/test-project-swarm'); + +describe('SwarmIntelligence', () => { + let si; + + beforeEach(async () => { + try { + await fs.rm(TEST_PROJECT_ROOT, { recursive: true, force: true }); + } catch { + // Ignore if doesn't exist + } + await fs.mkdir(TEST_PROJECT_ROOT, { recursive: true }); + + si = new SwarmIntelligence(TEST_PROJECT_ROOT, { persist: false, debug: false }); + }); + + afterEach(async () => { + si.removeAllListeners(); + try { + await fs.rm(TEST_PROJECT_ROOT, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR + // ═══════════════════════════════════════════════════════════════════════════ + + describe('constructor', () => { + it('should create instance with valid projectRoot', () => { + expect(si).toBeInstanceOf(SwarmIntelligence); + expect(si.projectRoot).toBe(TEST_PROJECT_ROOT); + expect(si.swarms.size).toBe(0); + }); + + it('should throw if projectRoot is missing', () => { + expect(() => new SwarmIntelligence()).toThrow('projectRoot is required'); + }); + + it('should throw if projectRoot is not a string', () => { + expect(() => new SwarmIntelligence(123)).toThrow('projectRoot is required'); + }); + + it('should use ?? for defaults (persist=true, debug=false)', () => { + const instance = new SwarmIntelligence(TEST_PROJECT_ROOT); + expect(instance.options.persist).toBe(true); + expect(instance.options.debug).toBe(false); + }); + + it('should be an EventEmitter', () => { + expect(typeof si.on).toBe('function'); + expect(typeof si.emit).toBe('function'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // SWARM MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════ + + describe('createSwarm', () => { + it('should create a swarm with default config', () => { + const swarm = si.createSwarm('alpha-team'); + expect(swarm.name).toBe('alpha-team'); + expect(swarm.id).toBeDefined(); + expect(swarm.status).toBe(SWARM_STATUS.ACTIVE); + expect(swarm.agents).toBeInstanceOf(Map); + expect(swarm.agents.size).toBe(0); + expect(swarm.config.votingStrategy).toBe(VOTING_STRATEGIES.MAJORITY); + expect(swarm.config.consensusThreshold).toBe(0.6); + expect(swarm.config.minAgents).toBe(2); + expect(swarm.config.maxAgents).toBe(50); + }); + + it('should create swarm with custom config', () => { + const swarm = si.createSwarm('beta-team', { + minAgents: 3, + maxAgents: 10, + consensusThreshold: 0.8, + votingStrategy: 'weighted', + }); + expect(swarm.config.minAgents).toBe(3); + expect(swarm.config.maxAgents).toBe(10); + expect(swarm.config.consensusThreshold).toBe(0.8); + expect(swarm.config.votingStrategy).toBe('weighted'); + }); + + it('should emit swarm:created event', () => { + const handler = jest.fn(); + si.on('swarm:created', handler); + const swarm = si.createSwarm('test-swarm'); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ swarmId: swarm.id, name: 'test-swarm' }) + ); + }); + + it('should throw for missing name', () => { + expect(() => si.createSwarm()).toThrow('Swarm name is required'); + }); + + it('should throw for invalid voting strategy', () => { + expect(() => si.createSwarm('bad', { votingStrategy: 'invalid' })) + .toThrow('Invalid voting strategy'); + }); + + it('should throw for invalid consensusThreshold', () => { + expect(() => si.createSwarm('bad', { consensusThreshold: 1.5 })) + .toThrow('consensusThreshold must be a number between 0 and 1'); + }); + + it('should throw if maxAgents < minAgents', () => { + expect(() => si.createSwarm('bad', { minAgents: 10, maxAgents: 5 })) + .toThrow('maxAgents must be >= minAgents'); + }); + + it('should throw if minAgents < 1', () => { + expect(() => si.createSwarm('bad', { minAgents: 0 })) + .toThrow('minAgents must be at least 1'); + }); + + it('should increment swarmsCreated stat', () => { + si.createSwarm('s1'); + si.createSwarm('s2'); + expect(si.getStats().swarmsCreated).toBe(2); + }); + }); + + describe('joinSwarm', () => { + let swarm; + + beforeEach(() => { + swarm = si.createSwarm('test-swarm', { maxAgents: 3 }); + }); + + it('should add an agent to the swarm', () => { + si.joinSwarm(swarm.id, 'agent-1', ['coding', 'testing']); + expect(swarm.agents.size).toBe(1); + const agent = swarm.agents.get('agent-1'); + expect(agent.capabilities).toEqual(['coding', 'testing']); + expect(agent.reputation).toBe(1.0); + expect(agent.votesCount).toBe(0); + }); + + it('should emit swarm:joined event', () => { + const handler = jest.fn(); + si.on('swarm:joined', handler); + si.joinSwarm(swarm.id, 'agent-1', ['coding']); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ swarmId: swarm.id, agentId: 'agent-1' }) + ); + }); + + it('should throw if agent already joined', () => { + si.joinSwarm(swarm.id, 'agent-1'); + expect(() => si.joinSwarm(swarm.id, 'agent-1')) + .toThrow('already a member'); + }); + + it('should throw if swarm is at max capacity', () => { + si.joinSwarm(swarm.id, 'agent-1'); + si.joinSwarm(swarm.id, 'agent-2'); + si.joinSwarm(swarm.id, 'agent-3'); + expect(() => si.joinSwarm(swarm.id, 'agent-4')) + .toThrow('maximum capacity'); + }); + + it('should throw for invalid agentId', () => { + expect(() => si.joinSwarm(swarm.id, '')) + .toThrow('agentId is required'); + }); + + it('should throw for non-existent swarm', () => { + expect(() => si.joinSwarm('fake-id', 'agent-1')) + .toThrow('Swarm not found'); + }); + }); + + describe('leaveSwarm', () => { + let swarm; + + beforeEach(() => { + swarm = si.createSwarm('test-swarm'); + si.joinSwarm(swarm.id, 'agent-1'); + si.joinSwarm(swarm.id, 'agent-2'); + }); + + it('should remove agent from swarm', () => { + si.leaveSwarm(swarm.id, 'agent-1'); + expect(swarm.agents.size).toBe(1); + expect(swarm.agents.has('agent-1')).toBe(false); + }); + + it('should clear leader if leader leaves', () => { + swarm.leader = 'agent-1'; + si.leaveSwarm(swarm.id, 'agent-1'); + expect(swarm.leader).toBeNull(); + }); + + it('should emit swarm:left event', () => { + const handler = jest.fn(); + si.on('swarm:left', handler); + si.leaveSwarm(swarm.id, 'agent-1'); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ swarmId: swarm.id, agentId: 'agent-1' }) + ); + }); + + it('should throw if agent is not a member', () => { + expect(() => si.leaveSwarm(swarm.id, 'unknown-agent')) + .toThrow('not a member'); + }); + }); + + describe('dissolveSwarm', () => { + it('should dissolve an active swarm', () => { + const swarm = si.createSwarm('ephemeral'); + si.joinSwarm(swarm.id, 'agent-1'); + + const summary = si.dissolveSwarm(swarm.id); + expect(summary.name).toBe('ephemeral'); + expect(summary.agentCount).toBe(1); + expect(swarm.status).toBe(SWARM_STATUS.DISSOLVED); + }); + + it('should emit swarm:dissolved event', () => { + const handler = jest.fn(); + si.on('swarm:dissolved', handler); + const swarm = si.createSwarm('temp'); + si.dissolveSwarm(swarm.id); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ swarmId: swarm.id }) + ); + }); + + it('should prevent operations on dissolved swarm', () => { + const swarm = si.createSwarm('temp'); + si.dissolveSwarm(swarm.id); + expect(() => si.joinSwarm(swarm.id, 'agent-1')) + .toThrow('not active'); + }); + + it('should increment swarmsDissolved stat', () => { + const swarm = si.createSwarm('temp'); + si.dissolveSwarm(swarm.id); + expect(si.getStats().swarmsDissolved).toBe(1); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // DECISION MAKING + // ═══════════════════════════════════════════════════════════════════════════ + + describe('proposeDecision', () => { + let swarm; + + beforeEach(() => { + swarm = si.createSwarm('decision-swarm'); + si.joinSwarm(swarm.id, 'agent-1'); + }); + + it('should create a proposal', () => { + const proposal = si.proposeDecision(swarm.id, { + description: 'Use TypeScript', + proposedBy: 'agent-1', + type: 'architecture', + }); + expect(proposal.id).toBeDefined(); + expect(proposal.description).toBe('Use TypeScript'); + expect(proposal.proposedBy).toBe('agent-1'); + expect(proposal.type).toBe('architecture'); + expect(proposal.status).toBe(PROPOSAL_STATUS.PENDING); + expect(proposal.votes).toBeInstanceOf(Map); + }); + + it('should emit proposal:created event', () => { + const handler = jest.fn(); + si.on('proposal:created', handler); + si.proposeDecision(swarm.id, { + description: 'Test proposal', + proposedBy: 'agent-1', + }); + expect(handler).toHaveBeenCalled(); + }); + + it('should throw if proposer is not a member', () => { + expect(() => si.proposeDecision(swarm.id, { + description: 'Bad proposal', + proposedBy: 'outsider', + })).toThrow('not a member'); + }); + + it('should throw if description is missing', () => { + expect(() => si.proposeDecision(swarm.id, { + proposedBy: 'agent-1', + })).toThrow('description is required'); + }); + + it('should default type to general', () => { + const proposal = si.proposeDecision(swarm.id, { + description: 'A thing', + proposedBy: 'agent-1', + }); + expect(proposal.type).toBe('general'); + }); + }); + + describe('vote', () => { + let swarm; + let proposal; + + beforeEach(() => { + swarm = si.createSwarm('vote-swarm'); + si.joinSwarm(swarm.id, 'agent-1'); + si.joinSwarm(swarm.id, 'agent-2'); + si.joinSwarm(swarm.id, 'agent-3'); + proposal = si.proposeDecision(swarm.id, { + description: 'Should we deploy?', + proposedBy: 'agent-1', + }); + }); + + it('should record a vote', () => { + si.vote(swarm.id, proposal.id, 'agent-1', 'approve', 0.9); + expect(proposal.votes.size).toBe(1); + const v = proposal.votes.get('agent-1'); + expect(v.vote).toBe('approve'); + expect(v.confidence).toBe(0.9); + }); + + it('should emit proposal:voted event', () => { + const handler = jest.fn(); + si.on('proposal:voted', handler); + si.vote(swarm.id, proposal.id, 'agent-1', 'reject', 0.5); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + swarmId: swarm.id, + proposalId: proposal.id, + agentId: 'agent-1', + vote: 'reject', + confidence: 0.5, + }) + ); + }); + + it('should throw if agent already voted', () => { + si.vote(swarm.id, proposal.id, 'agent-1', 'approve'); + expect(() => si.vote(swarm.id, proposal.id, 'agent-1', 'reject')) + .toThrow('already voted'); + }); + + it('should throw for invalid vote value', () => { + expect(() => si.vote(swarm.id, proposal.id, 'agent-1', 'maybe')) + .toThrow('Invalid vote'); + }); + + it('should throw for invalid confidence', () => { + expect(() => si.vote(swarm.id, proposal.id, 'agent-1', 'approve', 1.5)) + .toThrow('confidence must be a number between 0 and 1'); + }); + + it('should throw if agent is not a member', () => { + expect(() => si.vote(swarm.id, proposal.id, 'outsider', 'approve')) + .toThrow('not a member'); + }); + + it('should default confidence to 1.0', () => { + si.vote(swarm.id, proposal.id, 'agent-2', 'approve'); + const v = proposal.votes.get('agent-2'); + expect(v.confidence).toBe(1.0); + }); + + it('should increment agent votesCount', () => { + si.vote(swarm.id, proposal.id, 'agent-1', 'approve'); + expect(swarm.agents.get('agent-1').votesCount).toBe(1); + }); + + it('should reject vote on expired proposal', () => { + // Force deadline to past + proposal.deadline = new Date(Date.now() - 1000).toISOString(); + expect(() => si.vote(swarm.id, proposal.id, 'agent-1', 'approve')) + .toThrow('has expired'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // VOTING STRATEGIES + // ═══════════════════════════════════════════════════════════════════════════ + + describe('resolveProposal - majority strategy', () => { + it('should approve with majority approvals', () => { + const swarm = si.createSwarm('majority-swarm', { votingStrategy: 'majority' }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'approve'); + si.vote(swarm.id, p.id, 'a3', 'reject'); + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.status).toBe(PROPOSAL_STATUS.APPROVED); + expect(result.approved).toBe(true); + expect(result.approveCount).toBe(2); + expect(result.rejectCount).toBe(1); + }); + + it('should reject without majority', () => { + const swarm = si.createSwarm('majority-swarm', { votingStrategy: 'majority' }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'reject'); + si.vote(swarm.id, p.id, 'a3', 'reject'); + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.status).toBe(PROPOSAL_STATUS.REJECTED); + expect(result.approved).toBe(false); + }); + + it('should handle abstain votes (majority of non-abstain)', () => { + const swarm = si.createSwarm('majority-swarm', { votingStrategy: 'majority' }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'abstain'); + si.vote(swarm.id, p.id, 'a3', 'abstain'); + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(true); // 1 approve, 0 reject = majority + }); + }); + + describe('resolveProposal - weighted strategy', () => { + it('should weight votes by confidence and reputation', () => { + const swarm = si.createSwarm('weighted-swarm', { votingStrategy: 'weighted' }); + si.joinSwarm(swarm.id, 'expert', ['ml', 'data-science', 'python']); + si.joinSwarm(swarm.id, 'junior', ['python']); + + // Boost expert reputation + swarm.agents.get('expert').reputation = 2.0; + swarm.agents.get('junior').reputation = 0.5; + + const p = si.proposeDecision(swarm.id, { description: 'use ML', proposedBy: 'expert' }); + si.vote(swarm.id, p.id, 'expert', 'approve', 1.0); // weight = 1.0 * 2.0 = 2.0 + si.vote(swarm.id, p.id, 'junior', 'reject', 0.8); // weight = 0.8 * 0.5 = 0.4 + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(true); + expect(result.approveWeight).toBe(2.0); + expect(result.rejectWeight).toBe(0.4); + }); + + it('should reject when reject weight exceeds approve weight', () => { + const swarm = si.createSwarm('weighted-swarm', { votingStrategy: 'weighted' }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + + swarm.agents.get('a1').reputation = 0.5; + swarm.agents.get('a2').reputation = 2.0; + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve', 0.5); // weight = 0.25 + si.vote(swarm.id, p.id, 'a2', 'reject', 1.0); // weight = 2.0 + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(false); + }); + }); + + describe('resolveProposal - unanimous strategy', () => { + it('should approve when all non-abstain votes approve', () => { + const swarm = si.createSwarm('unanimous-swarm', { votingStrategy: 'unanimous' }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'approve'); + si.vote(swarm.id, p.id, 'a3', 'abstain'); + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(true); + }); + + it('should reject if any non-abstain vote is reject', () => { + const swarm = si.createSwarm('unanimous-swarm', { votingStrategy: 'unanimous' }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'reject'); + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(false); + }); + }); + + describe('resolveProposal - quorum strategy', () => { + it('should approve when quorum met and approves > rejects', () => { + const swarm = si.createSwarm('quorum-swarm', { + votingStrategy: 'quorum', + consensusThreshold: 0.5, + }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + si.joinSwarm(swarm.id, 'a4'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'approve'); + // a3, a4 don't vote — quorum = ceil(4 * 0.5) = 2 — met + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(true); + expect(result.hasQuorum).toBe(true); + }); + + it('should reject when quorum not met', () => { + const swarm = si.createSwarm('quorum-swarm', { + votingStrategy: 'quorum', + consensusThreshold: 0.8, + }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + si.joinSwarm(swarm.id, 'a4'); + si.joinSwarm(swarm.id, 'a5'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'approve'); + // quorum = ceil(5 * 0.8) = 4, only 2 voted + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(false); + expect(result.hasQuorum).toBe(false); + }); + }); + + describe('resolveProposal - common', () => { + it('should emit proposal:resolved event', () => { + const handler = jest.fn(); + si.on('proposal:resolved', handler); + + const swarm = si.createSwarm('resolved-swarm'); + si.joinSwarm(swarm.id, 'a1'); + const p = si.proposeDecision(swarm.id, { description: 't', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.resolveProposal(swarm.id, p.id); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ swarmId: swarm.id, proposalId: p.id }) + ); + }); + + it('should throw if resolving already-resolved proposal', () => { + const swarm = si.createSwarm('resolved-swarm'); + si.joinSwarm(swarm.id, 'a1'); + const p = si.proposeDecision(swarm.id, { description: 't', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.resolveProposal(swarm.id, p.id); + + expect(() => si.resolveProposal(swarm.id, p.id)) + .toThrow('already resolved'); + }); + + it('should update reputations after resolution', () => { + const swarm = si.createSwarm('rep-swarm'); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'reject'); + si.vote(swarm.id, p.id, 'a3', 'approve'); + + const initRepA1 = swarm.agents.get('a1').reputation; + const initRepA2 = swarm.agents.get('a2').reputation; + + si.resolveProposal(swarm.id, p.id); + + // a1 voted approve (aligned with result = approved) => rep goes up + expect(swarm.agents.get('a1').reputation).toBeGreaterThan(initRepA1); + // a2 voted reject (not aligned) => rep goes down + expect(swarm.agents.get('a2').reputation).toBeLessThan(initRepA2); + }); + + it('should not change reputation for abstain votes', () => { + const swarm = si.createSwarm('abstain-rep'); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'abstain'); + + si.resolveProposal(swarm.id, p.id); + + expect(swarm.agents.get('a2').reputation).toBe(1.0); // unchanged + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // KNOWLEDGE MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════ + + describe('shareKnowledge', () => { + let swarm; + + beforeEach(() => { + swarm = si.createSwarm('knowledge-swarm'); + si.joinSwarm(swarm.id, 'agent-1'); + }); + + it('should add knowledge to the swarm', () => { + const entry = si.shareKnowledge(swarm.id, 'agent-1', { + topic: 'deployment', + content: 'Use blue-green deployments for zero downtime', + tags: ['devops', 'deployment'], + }); + expect(entry.id).toBeDefined(); + expect(entry.topic).toBe('deployment'); + expect(entry.sharedBy).toBe('agent-1'); + expect(entry.tags).toEqual(['devops', 'deployment']); + expect(entry.citations).toBe(0); + expect(swarm.knowledgeBase.length).toBe(1); + }); + + it('should emit knowledge:shared event', () => { + const handler = jest.fn(); + si.on('knowledge:shared', handler); + si.shareKnowledge(swarm.id, 'agent-1', { + topic: 'test', + content: 'some content', + }); + expect(handler).toHaveBeenCalled(); + }); + + it('should throw if agent is not a member', () => { + expect(() => si.shareKnowledge(swarm.id, 'outsider', { + topic: 'test', + content: 'data', + })).toThrow('not a member'); + }); + + it('should throw if topic is missing', () => { + expect(() => si.shareKnowledge(swarm.id, 'agent-1', { + content: 'data', + })).toThrow('topic is required'); + }); + + it('should throw if content is null', () => { + expect(() => si.shareKnowledge(swarm.id, 'agent-1', { + topic: 'test', + content: null, + })).toThrow('content is required'); + }); + }); + + describe('queryKnowledge', () => { + let swarm; + + beforeEach(() => { + swarm = si.createSwarm('knowledge-swarm'); + si.joinSwarm(swarm.id, 'agent-1'); + si.joinSwarm(swarm.id, 'agent-2'); + + si.shareKnowledge(swarm.id, 'agent-1', { + topic: 'deployment strategies', + content: 'blue-green', + tags: ['devops', 'ci-cd'], + }); + si.shareKnowledge(swarm.id, 'agent-1', { + topic: 'testing patterns', + content: 'TDD is great', + tags: ['testing', 'quality'], + }); + si.shareKnowledge(swarm.id, 'agent-2', { + topic: 'deployment monitoring', + content: 'prometheus + grafana', + tags: ['devops', 'monitoring'], + }); + }); + + it('should return all knowledge when no filter', () => { + const results = si.queryKnowledge(swarm.id); + expect(results.length).toBe(3); + }); + + it('should filter by topic substring', () => { + const results = si.queryKnowledge(swarm.id, { topic: 'deployment' }); + expect(results.length).toBe(2); + }); + + it('should filter by tags', () => { + const results = si.queryKnowledge(swarm.id, { tags: ['testing'] }); + expect(results.length).toBe(1); + expect(results[0].topic).toBe('testing patterns'); + }); + + it('should filter by sharedBy', () => { + const results = si.queryKnowledge(swarm.id, { sharedBy: 'agent-2' }); + expect(results.length).toBe(1); + }); + + it('should respect limit', () => { + const results = si.queryKnowledge(swarm.id, { limit: 1 }); + expect(results.length).toBe(1); + }); + + it('should increment citations on queried results', () => { + si.queryKnowledge(swarm.id, { topic: 'deployment' }); + const k = swarm.knowledgeBase.find((k) => k.topic === 'deployment strategies'); + expect(k.citations).toBe(1); + }); + + it('should be case-insensitive for topic search', () => { + const results = si.queryKnowledge(swarm.id, { topic: 'DEPLOYMENT' }); + expect(results.length).toBe(2); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // LEADER ELECTION + // ═══════════════════════════════════════════════════════════════════════════ + + describe('electLeader', () => { + let swarm; + + beforeEach(() => { + swarm = si.createSwarm('leader-swarm'); + si.joinSwarm(swarm.id, 'generalist', ['coding']); + si.joinSwarm(swarm.id, 'expert', ['coding', 'testing', 'devops', 'architecture']); + si.joinSwarm(swarm.id, 'mid', ['coding', 'testing']); + }); + + it('should elect most-capable leader (most capabilities)', () => { + const result = si.electLeader(swarm.id, 'most-capable'); + expect(result.leaderId).toBe('expert'); + expect(result.criterion).toBe('most-capable'); + expect(swarm.leader).toBe('expert'); + }); + + it('should elect highest-reputation leader', () => { + swarm.agents.get('generalist').reputation = 1.8; + swarm.agents.get('expert').reputation = 1.2; + swarm.agents.get('mid').reputation = 0.9; + + const result = si.electLeader(swarm.id, 'highest-reputation'); + expect(result.leaderId).toBe('generalist'); + }); + + it('should rotate leader with round-robin', () => { + const first = si.electLeader(swarm.id, 'round-robin'); + const second = si.electLeader(swarm.id, 'round-robin'); + const third = si.electLeader(swarm.id, 'round-robin'); + + // Should cycle through the agents + expect(first.leaderId).not.toBe(second.leaderId); + expect(second.leaderId).not.toBe(third.leaderId); + }); + + it('should emit leader:elected event', () => { + const handler = jest.fn(); + si.on('leader:elected', handler); + si.electLeader(swarm.id); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ swarmId: swarm.id }) + ); + }); + + it('should throw for empty swarm', () => { + const empty = si.createSwarm('empty'); + expect(() => si.electLeader(empty.id)) + .toThrow('no agents'); + }); + + it('should throw for invalid criterion', () => { + expect(() => si.electLeader(swarm.id, 'random')) + .toThrow('Invalid criterion'); + }); + + it('should increment leadersElected stat', () => { + si.electLeader(swarm.id); + expect(si.getStats().leadersElected).toBe(1); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // HEALTH & STATS + // ═══════════════════════════════════════════════════════════════════════════ + + describe('getSwarmHealth', () => { + it('should return health metrics for active swarm', () => { + const swarm = si.createSwarm('health-test', { minAgents: 2 }); + si.joinSwarm(swarm.id, 'a1', ['coding']); + si.joinSwarm(swarm.id, 'a2', ['testing']); + + const health = si.getSwarmHealth(swarm.id); + expect(health.status).toBe(SWARM_STATUS.ACTIVE); + expect(health.agentCount).toBe(2); + expect(health.meetsMinAgents).toBe(true); + expect(health.healthScore).toBeGreaterThan(0); + expect(health.avgReputation).toBe(1.0); + }); + + it('should report dissolved swarm health with score 0', () => { + const swarm = si.createSwarm('temp'); + si.dissolveSwarm(swarm.id); + + const health = si.getSwarmHealth(swarm.id); + expect(health.status).toBe(SWARM_STATUS.DISSOLVED); + expect(health.healthScore).toBe(0); + }); + + it('should reflect leader presence in health', () => { + const swarm = si.createSwarm('leader-health'); + si.joinSwarm(swarm.id, 'a1'); + + const noLeader = si.getSwarmHealth(swarm.id); + si.electLeader(swarm.id); + const withLeader = si.getSwarmHealth(swarm.id); + + expect(withLeader.healthScore).toBeGreaterThan(noLeader.healthScore); + expect(withLeader.hasLeader).toBe(true); + }); + }); + + describe('getStats', () => { + it('should return global statistics', () => { + const stats = si.getStats(); + expect(stats).toHaveProperty('swarmsCreated'); + expect(stats).toHaveProperty('swarmsDissolved'); + expect(stats).toHaveProperty('proposalsCreated'); + expect(stats).toHaveProperty('proposalsResolved'); + expect(stats).toHaveProperty('knowledgeShared'); + expect(stats).toHaveProperty('leadersElected'); + expect(stats).toHaveProperty('totalVotes'); + expect(stats).toHaveProperty('activeSwarms'); + expect(stats).toHaveProperty('totalSwarms'); + expect(stats).toHaveProperty('totalAgents'); + }); + + it('should track stats across multiple swarms', () => { + const s1 = si.createSwarm('s1'); + const s2 = si.createSwarm('s2'); + si.joinSwarm(s1.id, 'a1'); + si.joinSwarm(s2.id, 'a2'); + si.joinSwarm(s2.id, 'a3'); + + const stats = si.getStats(); + expect(stats.swarmsCreated).toBe(2); + expect(stats.activeSwarms).toBe(2); + expect(stats.totalAgents).toBe(3); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // PERSISTENCE + // ═══════════════════════════════════════════════════════════════════════════ + + describe('persistence', () => { + it('should save and load state from disk', async () => { + const persisted = new SwarmIntelligence(TEST_PROJECT_ROOT, { persist: true, debug: false }); + const swarm = persisted.createSwarm('persistent-swarm'); + persisted.joinSwarm(swarm.id, 'agent-1', ['coding']); + persisted.shareKnowledge(swarm.id, 'agent-1', { + topic: 'persistence', + content: 'works!', + }); + + // Wait for async persistence + await persisted._saveToDisk(); + + // Load into fresh instance + const loaded = new SwarmIntelligence(TEST_PROJECT_ROOT, { persist: true, debug: false }); + const success = await loaded.loadFromDisk(); + expect(success).toBe(true); + + const loadedSwarm = loaded.swarms.get(swarm.id); + expect(loadedSwarm).toBeDefined(); + expect(loadedSwarm.name).toBe('persistent-swarm'); + expect(loadedSwarm.agents.get('agent-1')).toBeDefined(); + expect(loadedSwarm.knowledgeBase.length).toBe(1); + }); + + it('should return false when no persisted file exists', async () => { + const fresh = new SwarmIntelligence(path.join(TEST_PROJECT_ROOT, 'nonexistent'), { + persist: true, + debug: false, + }); + const result = await fresh.loadFromDisk(); + expect(result).toBe(false); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // EXPORTS + // ═══════════════════════════════════════════════════════════════════════════ + + describe('exports', () => { + it('should export SwarmIntelligence as default and named', () => { + const mod = require('../../../.aiox-core/core/orchestration/swarm-intelligence'); + expect(mod).toBe(SwarmIntelligence); + expect(mod.SwarmIntelligence).toBe(SwarmIntelligence); + }); + + it('should export constants', () => { + expect(VOTING_STRATEGIES).toBeDefined(); + expect(PROPOSAL_STATUS).toBeDefined(); + expect(SWARM_STATUS).toBeDefined(); + expect(LEADER_CRITERIA).toBeDefined(); + expect(VOTE_OPTIONS).toBeDefined(); + }); + }); +}); From a35f714a5fcb0aeb99925b54e8f44460c806055c Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Sun, 8 Mar 2026 02:56:09 -0300 Subject: [PATCH 3/5] fix: corrige 6 issues do CodeRabbit review no Swarm Intelligence - Converte .aios-core wrapper para re-export do .aiox-core (elimina duplicacao) - Rejeita proposals expiradas antes de aplicar voting strategy em resolveProposal() - Corrige quorum strategy: conta apenas approves para atingir quorum (nao total de votos) - Persiste citation bumps apos queryKnowledge() - Serializa escritas de persistencia com promise chain (_persistAsync) - Diferencia ENOENT de outros erros em loadFromDisk() (rethrow erros reais) - Adiciona 4 novos testes cobrindo todas as correcoes (87 total, todos passando) --- .../core/orchestration/swarm-intelligence.js | 1006 +---------------- .../core/orchestration/swarm-intelligence.js | 48 +- .aiox-core/data/entity-registry.yaml | 20 +- .aiox-core/install-manifest.yaml | 12 +- .../orchestration/swarm-intelligence.test.js | 82 ++ 5 files changed, 151 insertions(+), 1017 deletions(-) diff --git a/.aios-core/core/orchestration/swarm-intelligence.js b/.aios-core/core/orchestration/swarm-intelligence.js index 171e710dc..45e0618c4 100644 --- a/.aios-core/core/orchestration/swarm-intelligence.js +++ b/.aios-core/core/orchestration/swarm-intelligence.js @@ -1,1004 +1,2 @@ -/** - * Agent Swarm Intelligence - * Story ORCH-5 - Emergent intelligence from multi-agent collaboration - * @module aiox-core/orchestration/swarm-intelligence - * @version 1.0.0 - */ - -'use strict'; - -const { EventEmitter } = require('events'); -const fs = require('fs').promises; -const path = require('path'); -const crypto = require('crypto'); - -// ═══════════════════════════════════════════════════════════════════════════════ -// CONSTANTS -// ═══════════════════════════════════════════════════════════════════════════════ - -const PERSISTENCE_DIR = '.aiox'; -const PERSISTENCE_FILE = 'swarms.json'; - -const VOTING_STRATEGIES = { - MAJORITY: 'majority', - WEIGHTED: 'weighted', - UNANIMOUS: 'unanimous', - QUORUM: 'quorum', -}; - -const PROPOSAL_STATUS = { - PENDING: 'pending', - APPROVED: 'approved', - REJECTED: 'rejected', - EXPIRED: 'expired', -}; - -const SWARM_STATUS = { - ACTIVE: 'active', - DISSOLVED: 'dissolved', -}; - -const LEADER_CRITERIA = { - MOST_CAPABLE: 'most-capable', - HIGHEST_REPUTATION: 'highest-reputation', - ROUND_ROBIN: 'round-robin', -}; - -const VOTE_OPTIONS = { - APPROVE: 'approve', - REJECT: 'reject', - ABSTAIN: 'abstain', -}; - -// ═══════════════════════════════════════════════════════════════════════════════ -// HELPERS -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Generate a unique identifier - * @returns {string} UUID-like identifier - */ -function generateId() { - return crypto.randomBytes(8).toString('hex'); -} - -/** - * Validate confidence value is between 0 and 1 - * @param {number} confidence - * @returns {boolean} - */ -function isValidConfidence(confidence) { - return typeof confidence === 'number' && confidence >= 0 && confidence <= 1; -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// SWARM INTELLIGENCE -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * SwarmIntelligence - Emergent intelligence from multi-agent collaboration - * - * Provides swarm creation, agent coordination, collective decision-making - * via voting strategies, shared knowledge management, and leader election. - * - * @extends EventEmitter - */ -class SwarmIntelligence extends EventEmitter { - /** - * Creates a new SwarmIntelligence instance - * @param {string} projectRoot - Project root directory for persistence - * @param {Object} [options] - Configuration options - * @param {boolean} [options.debug=false] - Enable debug logging - * @param {boolean} [options.persist=true] - Enable persistence to disk - */ - constructor(projectRoot, options = {}) { - super(); - - if (!projectRoot || typeof projectRoot !== 'string') { - throw new Error('projectRoot is required and must be a string'); - } - - this.projectRoot = projectRoot; - this.options = { - debug: options.debug ?? false, - persist: options.persist ?? true, - }; - - /** @type {Map} Active swarms indexed by ID */ - this.swarms = new Map(); - - /** @type {Object} Global statistics */ - this._stats = { - swarmsCreated: 0, - swarmsDissolved: 0, - proposalsCreated: 0, - proposalsResolved: 0, - knowledgeShared: 0, - leadersElected: 0, - totalVotes: 0, - }; - - /** @type {number} Round-robin index tracking per swarm */ - this._roundRobinIndex = new Map(); - - this._persistPath = path.join(projectRoot, PERSISTENCE_DIR, PERSISTENCE_FILE); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // SWARM MANAGEMENT - // ═══════════════════════════════════════════════════════════════════════════ - - /** - * Create a named swarm with configuration - * @param {string} name - Swarm name - * @param {Object} [config] - Swarm configuration - * @param {number} [config.minAgents=2] - Minimum agents required - * @param {number} [config.maxAgents=50] - Maximum agents allowed - * @param {number} [config.consensusThreshold=0.6] - Consensus threshold (0-1) - * @param {string} [config.votingStrategy='majority'] - Voting strategy - * @returns {Object} Created swarm - */ - createSwarm(name, config = {}) { - if (!name || typeof name !== 'string') { - throw new Error('Swarm name is required and must be a string'); - } - - const votingStrategy = config.votingStrategy ?? VOTING_STRATEGIES.MAJORITY; - if (!Object.values(VOTING_STRATEGIES).includes(votingStrategy)) { - throw new Error(`Invalid voting strategy: ${votingStrategy}. Must be one of: ${Object.values(VOTING_STRATEGIES).join(', ')}`); - } - - const consensusThreshold = config.consensusThreshold ?? 0.6; - if (typeof consensusThreshold !== 'number' || consensusThreshold < 0 || consensusThreshold > 1) { - throw new Error('consensusThreshold must be a number between 0 and 1'); - } - - const minAgents = config.minAgents ?? 2; - const maxAgents = config.maxAgents ?? 50; - - if (minAgents < 1) { - throw new Error('minAgents must be at least 1'); - } - if (maxAgents < minAgents) { - throw new Error('maxAgents must be >= minAgents'); - } - - const id = generateId(); - const swarm = { - id, - name, - agents: new Map(), - proposals: [], - knowledgeBase: [], - leader: null, - config: { - minAgents, - maxAgents, - consensusThreshold, - votingStrategy, - }, - createdAt: new Date().toISOString(), - status: SWARM_STATUS.ACTIVE, - }; - - this.swarms.set(id, swarm); - this._stats.swarmsCreated++; - this._roundRobinIndex.set(id, 0); - - this.emit('swarm:created', { swarmId: id, name, config: swarm.config }); - this._log(`Swarm created: ${name} (${id})`); - this._persistAsync(); - - return swarm; - } - - /** - * Agent joins a swarm with declared capabilities - * @param {string} swarmId - Swarm identifier - * @param {string} agentId - Agent identifier - * @param {string[]} [capabilities=[]] - Agent capabilities - * @returns {Object} Updated swarm - */ - joinSwarm(swarmId, agentId, capabilities = []) { - const swarm = this._getActiveSwarm(swarmId); - - if (!agentId || typeof agentId !== 'string') { - throw new Error('agentId is required and must be a string'); - } - - if (swarm.agents.has(agentId)) { - throw new Error(`Agent ${agentId} is already a member of swarm ${swarmId}`); - } - - if (swarm.agents.size >= swarm.config.maxAgents) { - throw new Error(`Swarm ${swarmId} has reached maximum capacity (${swarm.config.maxAgents})`); - } - - const agent = { - id: agentId, - capabilities: Array.isArray(capabilities) ? [...capabilities] : [], - joinedAt: new Date().toISOString(), - reputation: 1.0, - votesCount: 0, - }; - - swarm.agents.set(agentId, agent); - - this.emit('swarm:joined', { swarmId, agentId, capabilities: agent.capabilities }); - this._log(`Agent ${agentId} joined swarm ${swarmId}`); - this._persistAsync(); - - return swarm; - } - - /** - * Agent leaves a swarm - * @param {string} swarmId - Swarm identifier - * @param {string} agentId - Agent identifier - * @returns {Object} Updated swarm - */ - leaveSwarm(swarmId, agentId) { - const swarm = this._getActiveSwarm(swarmId); - - if (!swarm.agents.has(agentId)) { - throw new Error(`Agent ${agentId} is not a member of swarm ${swarmId}`); - } - - swarm.agents.delete(agentId); - - // If the leader left, clear leadership - if (swarm.leader === agentId) { - swarm.leader = null; - } - - this.emit('swarm:left', { swarmId, agentId }); - this._log(`Agent ${agentId} left swarm ${swarmId}`); - this._persistAsync(); - - return swarm; - } - - /** - * Dissolve a swarm - * @param {string} swarmId - Swarm identifier - * @returns {Object} Dissolved swarm summary - */ - dissolveSwarm(swarmId) { - const swarm = this._getActiveSwarm(swarmId); - - swarm.status = SWARM_STATUS.DISSOLVED; - swarm.dissolvedAt = new Date().toISOString(); - this._stats.swarmsDissolved++; - - const summary = { - id: swarm.id, - name: swarm.name, - agentCount: swarm.agents.size, - proposalCount: swarm.proposals.length, - knowledgeCount: swarm.knowledgeBase.length, - dissolvedAt: swarm.dissolvedAt, - }; - - this.emit('swarm:dissolved', { swarmId, summary }); - this._log(`Swarm dissolved: ${swarm.name} (${swarmId})`); - this._persistAsync(); - - return summary; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // DECISION MAKING - // ═══════════════════════════════════════════════════════════════════════════ - - /** - * Submit a proposal for collective voting - * @param {string} swarmId - Swarm identifier - * @param {Object} proposal - Proposal details - * @param {string} proposal.description - Proposal description - * @param {string} proposal.proposedBy - Agent ID proposing - * @param {string} [proposal.type='general'] - Proposal type - * @param {number} [proposal.deadlineMs=300000] - Deadline in milliseconds (default 5 min) - * @returns {Object} Created proposal - */ - proposeDecision(swarmId, proposal) { - const swarm = this._getActiveSwarm(swarmId); - - if (!proposal || typeof proposal !== 'object') { - throw new Error('proposal is required and must be an object'); - } - if (!proposal.description || typeof proposal.description !== 'string') { - throw new Error('proposal.description is required'); - } - if (!proposal.proposedBy || typeof proposal.proposedBy !== 'string') { - throw new Error('proposal.proposedBy is required'); - } - if (!swarm.agents.has(proposal.proposedBy)) { - throw new Error(`Agent ${proposal.proposedBy} is not a member of swarm ${swarmId}`); - } - - const id = generateId(); - const deadlineMs = proposal.deadlineMs ?? 300000; - const created = { - id, - swarmId, - proposedBy: proposal.proposedBy, - description: proposal.description, - type: proposal.type ?? 'general', - votes: new Map(), - status: PROPOSAL_STATUS.PENDING, - createdAt: new Date().toISOString(), - deadline: new Date(Date.now() + deadlineMs).toISOString(), - }; - - swarm.proposals.push(created); - this._stats.proposalsCreated++; - - this.emit('proposal:created', { swarmId, proposalId: id, proposedBy: proposal.proposedBy }); - this._log(`Proposal created in swarm ${swarmId}: ${proposal.description}`); - this._persistAsync(); - - return created; - } - - /** - * Cast a vote on a proposal - * @param {string} swarmId - Swarm identifier - * @param {string} proposalId - Proposal identifier - * @param {string} agentId - Voting agent ID - * @param {string} voteValue - Vote: 'approve', 'reject', or 'abstain' - * @param {number} [confidence=1.0] - Confidence level (0-1) - * @returns {Object} Updated proposal - */ - vote(swarmId, proposalId, agentId, voteValue, confidence = 1.0) { - const swarm = this._getActiveSwarm(swarmId); - const proposal = this._getPendingProposal(swarm, proposalId); - - if (!swarm.agents.has(agentId)) { - throw new Error(`Agent ${agentId} is not a member of swarm ${swarmId}`); - } - - if (!Object.values(VOTE_OPTIONS).includes(voteValue)) { - throw new Error(`Invalid vote: ${voteValue}. Must be one of: ${Object.values(VOTE_OPTIONS).join(', ')}`); - } - - if (!isValidConfidence(confidence)) { - throw new Error('confidence must be a number between 0 and 1'); - } - - if (proposal.votes.has(agentId)) { - throw new Error(`Agent ${agentId} has already voted on proposal ${proposalId}`); - } - - // Check deadline - if (new Date(proposal.deadline) < new Date()) { - proposal.status = PROPOSAL_STATUS.EXPIRED; - throw new Error(`Proposal ${proposalId} has expired`); - } - - proposal.votes.set(agentId, { - agentId, - vote: voteValue, - confidence, - timestamp: new Date().toISOString(), - }); - - // Update agent stats - const agent = swarm.agents.get(agentId); - agent.votesCount++; - this._stats.totalVotes++; - - this.emit('proposal:voted', { swarmId, proposalId, agentId, vote: voteValue, confidence }); - this._log(`Agent ${agentId} voted '${voteValue}' on proposal ${proposalId} (confidence: ${confidence})`); - this._persistAsync(); - - return proposal; - } - - /** - * Resolve a proposal based on votes and configured strategy - * @param {string} swarmId - Swarm identifier - * @param {string} proposalId - Proposal identifier - * @returns {Object} Resolution result - */ - resolveProposal(swarmId, proposalId) { - const swarm = this._getActiveSwarm(swarmId); - const proposal = this._getProposal(swarm, proposalId); - - if (proposal.status !== PROPOSAL_STATUS.PENDING) { - throw new Error(`Proposal ${proposalId} is already resolved (status: ${proposal.status})`); - } - - const result = this._applyVotingStrategy(swarm, proposal); - - proposal.status = result.approved ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.REJECTED; - proposal.resolvedAt = new Date().toISOString(); - proposal.resolution = result; - this._stats.proposalsResolved++; - - // Update reputation based on alignment with result - this._updateReputations(swarm, proposal, result.approved); - - this.emit('proposal:resolved', { - swarmId, - proposalId, - status: proposal.status, - result, - }); - this._log(`Proposal ${proposalId} resolved: ${proposal.status}`); - this._persistAsync(); - - return { - proposalId, - status: proposal.status, - ...result, - }; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // VOTING STRATEGIES - // ═══════════════════════════════════════════════════════════════════════════ - - /** - * Apply the configured voting strategy to determine outcome - * @param {Object} swarm - Swarm object - * @param {Object} proposal - Proposal object - * @returns {Object} Strategy result { approved, approveCount, rejectCount, abstainCount, details } - * @private - */ - _applyVotingStrategy(swarm, proposal) { - const votes = Array.from(proposal.votes.values()); - const approveVotes = votes.filter((v) => v.vote === VOTE_OPTIONS.APPROVE); - const rejectVotes = votes.filter((v) => v.vote === VOTE_OPTIONS.REJECT); - const abstainVotes = votes.filter((v) => v.vote === VOTE_OPTIONS.ABSTAIN); - - const base = { - approveCount: approveVotes.length, - rejectCount: rejectVotes.length, - abstainCount: abstainVotes.length, - totalVotes: votes.length, - totalAgents: swarm.agents.size, - }; - - switch (swarm.config.votingStrategy) { - case VOTING_STRATEGIES.MAJORITY: - return this._majorityStrategy(base); - - case VOTING_STRATEGIES.WEIGHTED: - return this._weightedStrategy(swarm, approveVotes, rejectVotes, base); - - case VOTING_STRATEGIES.UNANIMOUS: - return this._unanimousStrategy(base); - - case VOTING_STRATEGIES.QUORUM: - return this._quorumStrategy(swarm, base); - - default: - return this._majorityStrategy(base); - } - } - - /** - * Majority: simple majority of non-abstain votes - * @private - */ - _majorityStrategy(base) { - const nonAbstain = base.approveCount + base.rejectCount; - const approved = nonAbstain > 0 && base.approveCount > nonAbstain / 2; - return { ...base, approved, strategy: VOTING_STRATEGIES.MAJORITY }; - } - - /** - * Weighted: votes weighted by agent reputation and confidence - * @private - */ - _weightedStrategy(swarm, approveVotes, rejectVotes, base) { - let approveWeight = 0; - let rejectWeight = 0; - - for (const v of approveVotes) { - const agent = swarm.agents.get(v.agentId); - const reputation = agent ? agent.reputation : 1.0; - approveWeight += v.confidence * reputation; - } - - for (const v of rejectVotes) { - const agent = swarm.agents.get(v.agentId); - const reputation = agent ? agent.reputation : 1.0; - rejectWeight += v.confidence * reputation; - } - - const totalWeight = approveWeight + rejectWeight; - const approved = totalWeight > 0 && approveWeight > totalWeight / 2; - - return { - ...base, - approved, - approveWeight: Math.round(approveWeight * 100) / 100, - rejectWeight: Math.round(rejectWeight * 100) / 100, - strategy: VOTING_STRATEGIES.WEIGHTED, - }; - } - - /** - * Unanimous: all non-abstain votes must approve - * @private - */ - _unanimousStrategy(base) { - const nonAbstain = base.approveCount + base.rejectCount; - const approved = nonAbstain > 0 && base.rejectCount === 0; - return { ...base, approved, strategy: VOTING_STRATEGIES.UNANIMOUS }; - } - - /** - * Quorum: requires consensusThreshold proportion of total agents to approve - * @private - */ - _quorumStrategy(swarm, base) { - const quorumRequired = Math.ceil(swarm.agents.size * swarm.config.consensusThreshold); - const hasQuorum = base.totalVotes >= quorumRequired; - const approved = hasQuorum && base.approveCount > base.rejectCount; - - return { - ...base, - approved, - quorumRequired, - hasQuorum, - strategy: VOTING_STRATEGIES.QUORUM, - }; - } - - /** - * Update agent reputations based on vote alignment with the final result - * Agents who voted with the majority gain reputation, others lose slightly - * @param {Object} swarm - Swarm object - * @param {Object} proposal - Resolved proposal - * @param {boolean} approved - Whether the proposal was approved - * @private - */ - _updateReputations(swarm, proposal, approved) { - for (const [agentId, voteData] of proposal.votes) { - const agent = swarm.agents.get(agentId); - if (!agent) continue; - - const alignedWithResult = - (approved && voteData.vote === VOTE_OPTIONS.APPROVE) || - (!approved && voteData.vote === VOTE_OPTIONS.REJECT); - - if (voteData.vote === VOTE_OPTIONS.ABSTAIN) { - // Abstaining has no reputation effect - continue; - } - - if (alignedWithResult) { - agent.reputation = Math.min(2.0, agent.reputation + 0.05); - } else { - agent.reputation = Math.max(0.1, agent.reputation - 0.03); - } - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // KNOWLEDGE MANAGEMENT - // ═══════════════════════════════════════════════════════════════════════════ - - /** - * Share a knowledge artifact with the swarm - * @param {string} swarmId - Swarm identifier - * @param {string} agentId - Sharing agent ID - * @param {Object} knowledge - Knowledge artifact - * @param {string} knowledge.topic - Knowledge topic - * @param {*} knowledge.content - Knowledge content - * @param {string[]} [knowledge.tags=[]] - Searchable tags - * @returns {Object} Created knowledge entry - */ - shareKnowledge(swarmId, agentId, knowledge) { - const swarm = this._getActiveSwarm(swarmId); - - if (!swarm.agents.has(agentId)) { - throw new Error(`Agent ${agentId} is not a member of swarm ${swarmId}`); - } - - if (!knowledge || typeof knowledge !== 'object') { - throw new Error('knowledge is required and must be an object'); - } - if (!knowledge.topic || typeof knowledge.topic !== 'string') { - throw new Error('knowledge.topic is required'); - } - if (knowledge.content === undefined || knowledge.content === null) { - throw new Error('knowledge.content is required'); - } - - const id = generateId(); - const entry = { - id, - sharedBy: agentId, - topic: knowledge.topic, - content: knowledge.content, - tags: Array.isArray(knowledge.tags) ? [...knowledge.tags] : [], - timestamp: new Date().toISOString(), - citations: 0, - }; - - swarm.knowledgeBase.push(entry); - this._stats.knowledgeShared++; - - this.emit('knowledge:shared', { swarmId, agentId, knowledgeId: id, topic: knowledge.topic }); - this._log(`Knowledge shared in swarm ${swarmId}: ${knowledge.topic}`); - this._persistAsync(); - - return entry; - } - - /** - * Query the collective knowledge base of a swarm - * @param {string} swarmId - Swarm identifier - * @param {Object} query - Query parameters - * @param {string} [query.topic] - Filter by topic (substring match) - * @param {string[]} [query.tags] - Filter by tags (any match) - * @param {string} [query.sharedBy] - Filter by agent - * @param {number} [query.limit=10] - Max results - * @returns {Object[]} Matching knowledge entries - */ - queryKnowledge(swarmId, query = {}) { - const swarm = this._getActiveSwarm(swarmId); - let results = [...swarm.knowledgeBase]; - - if (query.topic) { - const topicLower = query.topic.toLowerCase(); - results = results.filter((k) => k.topic.toLowerCase().includes(topicLower)); - } - - if (query.tags && Array.isArray(query.tags) && query.tags.length > 0) { - const queryTags = query.tags.map((t) => t.toLowerCase()); - results = results.filter((k) => - k.tags.some((tag) => queryTags.includes(tag.toLowerCase())) - ); - } - - if (query.sharedBy) { - results = results.filter((k) => k.sharedBy === query.sharedBy); - } - - const limit = query.limit ?? 10; - - // Increment citation count for returned results - const limited = results.slice(0, limit); - for (const entry of limited) { - const original = swarm.knowledgeBase.find((k) => k.id === entry.id); - if (original) { - original.citations++; - } - } - - return limited; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // LEADER ELECTION - // ═══════════════════════════════════════════════════════════════════════════ - - /** - * Elect a leader for the swarm based on criterion - * @param {string} swarmId - Swarm identifier - * @param {string} [criterion='most-capable'] - Election criterion - * @returns {Object} Election result { leaderId, criterion, agentDetails } - */ - electLeader(swarmId, criterion = LEADER_CRITERIA.MOST_CAPABLE) { - const swarm = this._getActiveSwarm(swarmId); - - if (swarm.agents.size === 0) { - throw new Error(`Swarm ${swarmId} has no agents to elect a leader from`); - } - - const validCriteria = Object.values(LEADER_CRITERIA); - if (!validCriteria.includes(criterion)) { - throw new Error(`Invalid criterion: ${criterion}. Must be one of: ${validCriteria.join(', ')}`); - } - - let leaderId; - const agents = Array.from(swarm.agents.entries()); - - switch (criterion) { - case LEADER_CRITERIA.MOST_CAPABLE: { - // Agent with most capabilities wins - let maxCapabilities = -1; - for (const [id, agent] of agents) { - if (agent.capabilities.length > maxCapabilities) { - maxCapabilities = agent.capabilities.length; - leaderId = id; - } - } - break; - } - - case LEADER_CRITERIA.HIGHEST_REPUTATION: { - // Agent with highest reputation wins - let maxReputation = -1; - for (const [id, agent] of agents) { - if (agent.reputation > maxReputation) { - maxReputation = agent.reputation; - leaderId = id; - } - } - break; - } - - case LEADER_CRITERIA.ROUND_ROBIN: { - // Rotate through agents in insertion order - const agentIds = Array.from(swarm.agents.keys()); - const currentIndex = this._roundRobinIndex.get(swarmId) ?? 0; - leaderId = agentIds[currentIndex % agentIds.length]; - this._roundRobinIndex.set(swarmId, (currentIndex + 1) % agentIds.length); - break; - } - } - - swarm.leader = leaderId; - this._stats.leadersElected++; - - const leaderAgent = swarm.agents.get(leaderId); - const result = { - leaderId, - criterion, - agentDetails: { ...leaderAgent }, - }; - - this.emit('leader:elected', { swarmId, leaderId, criterion }); - this._log(`Leader elected in swarm ${swarmId}: ${leaderId} (criterion: ${criterion})`); - this._persistAsync(); - - return result; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // HEALTH & STATS - // ═══════════════════════════════════════════════════════════════════════════ - - /** - * Get health metrics for a swarm - * @param {string} swarmId - Swarm identifier - * @returns {Object} Health metrics - */ - getSwarmHealth(swarmId) { - const swarm = this._getSwarm(swarmId); - - const agentCount = swarm.agents.size; - const pendingProposals = swarm.proposals.filter((p) => p.status === PROPOSAL_STATUS.PENDING).length; - const resolvedProposals = swarm.proposals.filter( - (p) => p.status === PROPOSAL_STATUS.APPROVED || p.status === PROPOSAL_STATUS.REJECTED - ).length; - - const agents = Array.from(swarm.agents.values()); - const avgReputation = agents.length > 0 - ? Math.round((agents.reduce((sum, a) => sum + a.reputation, 0) / agents.length) * 100) / 100 - : 0; - - const hasLeader = swarm.leader !== null; - const meetsMinAgents = agentCount >= swarm.config.minAgents; - - // Health score: 0-100 - let healthScore = 0; - if (swarm.status === SWARM_STATUS.ACTIVE) { - healthScore += 30; // Base for being active - if (meetsMinAgents) healthScore += 25; - if (hasLeader) healthScore += 15; - if (avgReputation >= 1.0) healthScore += 15; - if (resolvedProposals > 0) healthScore += 15; - } - - return { - swarmId: swarm.id, - name: swarm.name, - status: swarm.status, - agentCount, - minAgents: swarm.config.minAgents, - maxAgents: swarm.config.maxAgents, - meetsMinAgents, - hasLeader, - leader: swarm.leader, - pendingProposals, - resolvedProposals, - knowledgeEntries: swarm.knowledgeBase.length, - avgReputation, - healthScore, - createdAt: swarm.createdAt, - }; - } - - /** - * Get global statistics across all swarms - * @returns {Object} Global stats - */ - getStats() { - const activeSwarms = Array.from(this.swarms.values()).filter( - (s) => s.status === SWARM_STATUS.ACTIVE - ).length; - - const totalAgents = Array.from(this.swarms.values()).reduce( - (sum, s) => sum + s.agents.size, 0 - ); - - return { - ...this._stats, - activeSwarms, - totalSwarms: this.swarms.size, - totalAgents, - }; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // PERSISTENCE - // ═══════════════════════════════════════════════════════════════════════════ - - /** - * Persist state to disk asynchronously (fire-and-forget) - * @private - */ - _persistAsync() { - if (!this.options.persist) return; - - this._saveToDisk().catch((err) => { - this._log(`Persistence error: ${err.message}`); - }); - } - - /** - * Save current state to disk - * @returns {Promise} - * @private - */ - async _saveToDisk() { - const dir = path.dirname(this._persistPath); - await fs.mkdir(dir, { recursive: true }); - - const data = { - version: '1.0.0', - savedAt: new Date().toISOString(), - stats: this._stats, - swarms: this._serializeSwarms(), - }; - - await fs.writeFile(this._persistPath, JSON.stringify(data, null, 2), 'utf8'); - } - - /** - * Load state from disk - * @returns {Promise} Whether state was loaded - */ - async loadFromDisk() { - try { - const raw = await fs.readFile(this._persistPath, 'utf8'); - const data = JSON.parse(raw); - - if (data.stats) { - this._stats = { ...this._stats, ...data.stats }; - } - - if (data.swarms && Array.isArray(data.swarms)) { - for (const s of data.swarms) { - const swarm = { - ...s, - agents: new Map(Object.entries(s.agents ?? {})), - proposals: (s.proposals ?? []).map((p) => ({ - ...p, - votes: new Map(Object.entries(p.votes ?? {})), - })), - knowledgeBase: s.knowledgeBase ?? [], - }; - this.swarms.set(swarm.id, swarm); - } - } - - this._log('State loaded from disk'); - return true; - } catch { - // File doesn't exist or is corrupted — start fresh - return false; - } - } - - /** - * Serialize swarms for JSON persistence (Maps to plain objects) - * @returns {Object[]} - * @private - */ - _serializeSwarms() { - const result = []; - for (const [, swarm] of this.swarms) { - result.push({ - ...swarm, - agents: Object.fromEntries(swarm.agents), - proposals: swarm.proposals.map((p) => ({ - ...p, - votes: Object.fromEntries(p.votes), - })), - }); - } - return result; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // INTERNAL HELPERS - // ═══════════════════════════════════════════════════════════════════════════ - - /** - * Get a swarm by ID (any status) - * @param {string} swarmId - * @returns {Object} - * @private - */ - _getSwarm(swarmId) { - const swarm = this.swarms.get(swarmId); - if (!swarm) { - throw new Error(`Swarm not found: ${swarmId}`); - } - return swarm; - } - - /** - * Get an active swarm by ID - * @param {string} swarmId - * @returns {Object} - * @private - */ - _getActiveSwarm(swarmId) { - const swarm = this._getSwarm(swarmId); - if (swarm.status !== SWARM_STATUS.ACTIVE) { - throw new Error(`Swarm ${swarmId} is not active (status: ${swarm.status})`); - } - return swarm; - } - - /** - * Get a proposal from a swarm - * @param {Object} swarm - * @param {string} proposalId - * @returns {Object} - * @private - */ - _getProposal(swarm, proposalId) { - const proposal = swarm.proposals.find((p) => p.id === proposalId); - if (!proposal) { - throw new Error(`Proposal not found: ${proposalId}`); - } - return proposal; - } - - /** - * Get a pending proposal from a swarm - * @param {Object} swarm - * @param {string} proposalId - * @returns {Object} - * @private - */ - _getPendingProposal(swarm, proposalId) { - const proposal = this._getProposal(swarm, proposalId); - if (proposal.status !== PROPOSAL_STATUS.PENDING) { - throw new Error(`Proposal ${proposalId} is not pending (status: ${proposal.status})`); - } - return proposal; - } - - /** - * Debug logging - * @param {string} message - * @private - */ - _log(message) { - if (this.options.debug) { - console.log(`[SwarmIntelligence] ${message}`); - } - } -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// EXPORTS -// ═══════════════════════════════════════════════════════════════════════════════ - -module.exports = SwarmIntelligence; -module.exports.SwarmIntelligence = SwarmIntelligence; -module.exports.VOTING_STRATEGIES = VOTING_STRATEGIES; -module.exports.PROPOSAL_STATUS = PROPOSAL_STATUS; -module.exports.SWARM_STATUS = SWARM_STATUS; -module.exports.LEADER_CRITERIA = LEADER_CRITERIA; -module.exports.VOTE_OPTIONS = VOTE_OPTIONS; +// Backward compatibility wrapper — canonical implementation lives in .aiox-core +module.exports = require('../../../.aiox-core/core/orchestration/swarm-intelligence'); diff --git a/.aiox-core/core/orchestration/swarm-intelligence.js b/.aiox-core/core/orchestration/swarm-intelligence.js index 171e710dc..e4f18013e 100644 --- a/.aiox-core/core/orchestration/swarm-intelligence.js +++ b/.aiox-core/core/orchestration/swarm-intelligence.js @@ -408,6 +408,29 @@ class SwarmIntelligence extends EventEmitter { throw new Error(`Proposal ${proposalId} is already resolved (status: ${proposal.status})`); } + // Reject expired proposals before applying voting strategy + if (new Date(proposal.deadline) < new Date()) { + proposal.status = PROPOSAL_STATUS.REJECTED; + proposal.resolvedAt = new Date().toISOString(); + proposal.resolution = { approved: false, reason: 'deadline_expired' }; + this._stats.proposalsResolved++; + + this.emit('proposal:resolved', { + swarmId, + proposalId, + status: proposal.status, + result: proposal.resolution, + }); + this._log(`Proposal ${proposalId} rejected: deadline expired`); + this._persistAsync(); + + return { + proposalId, + status: proposal.status, + ...proposal.resolution, + }; + } + const result = this._applyVotingStrategy(swarm, proposal); proposal.status = result.approved ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.REJECTED; @@ -535,7 +558,8 @@ class SwarmIntelligence extends EventEmitter { */ _quorumStrategy(swarm, base) { const quorumRequired = Math.ceil(swarm.agents.size * swarm.config.consensusThreshold); - const hasQuorum = base.totalVotes >= quorumRequired; + // Abstains and rejects don't count toward quorum + const hasQuorum = base.approveCount >= quorumRequired; const approved = hasQuorum && base.approveCount > base.rejectCount; return { @@ -670,6 +694,9 @@ class SwarmIntelligence extends EventEmitter { } } + // Persist citation bumps + this._persistAsync(); + return limited; } @@ -837,9 +864,12 @@ class SwarmIntelligence extends EventEmitter { _persistAsync() { if (!this.options.persist) return; - this._saveToDisk().catch((err) => { - this._log(`Persistence error: ${err.message}`); - }); + // Serialize writes to prevent concurrent fs operations + this._pendingSave = (this._pendingSave || Promise.resolve()) + .then(() => this._saveToDisk()) + .catch((err) => { + this._log(`Persistence error: ${err.message}`); + }); } /** @@ -891,9 +921,13 @@ class SwarmIntelligence extends EventEmitter { this._log('State loaded from disk'); return true; - } catch { - // File doesn't exist or is corrupted — start fresh - return false; + } catch (err) { + // Only treat ENOENT (file not found) as fresh start + if (err.code === 'ENOENT') { + return false; + } + this._log(`Failed to load state: ${err.message}`); + throw err; } } diff --git a/.aiox-core/data/entity-registry.yaml b/.aiox-core/data/entity-registry.yaml index d76133691..2390c82a7 100644 --- a/.aiox-core/data/entity-registry.yaml +++ b/.aiox-core/data/entity-registry.yaml @@ -1,7 +1,7 @@ metadata: version: 1.0.0 - lastUpdated: '2026-03-06T11:53:37.887Z' - entityCount: 745 + lastUpdated: '2026-03-08T05:58:36.216Z' + entityCount: 746 checksumAlgorithm: sha256 resolutionRate: 100 entities: @@ -12778,6 +12778,22 @@ entities: extensionPoints: [] checksum: sha256:dd025894f8f0d3bd22a147dbc0debef8b83e96f3c59483653404b3cd5a01d5aa lastVerified: '2026-03-06T11:53:37.512Z' + swarm-intelligence: + path: .aiox-core/core/orchestration/swarm-intelligence.js + layer: L1 + type: module + purpose: proposal.description, + keywords: + - swarm + - intelligence + usedBy: [] + dependencies: [] + adaptability: + score: 0.4 + constraints: [] + extensionPoints: [] + checksum: sha256:b9c0e2050639c3dbf9cf404e781799dd02fbb8d8157fd60a91ba47f17112bf53 + lastVerified: '2026-03-08T05:58:36.213Z' agents: aiox-master: path: .aiox-core/development/agents/aiox-master.md diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index f95778f30..9bba0dfbb 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-11T02:22:49.781Z" +generated_at: "2026-03-11T02:23:14.237Z" generator: scripts/generate-install-manifest.js -file_count: 1090 +file_count: 1091 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -936,6 +936,10 @@ files: hash: sha256:92e9d5bea78c3db4940c39f79e537821b36451cd524d69e6b738272aa63c08b6 type: core size: 12849 + - path: core/orchestration/swarm-intelligence.js + hash: sha256:960b90fbcafb68d5728a645e892891d9bdec053b4e9f36086f8e6d35259a08c1 + type: core + size: 35774 - path: core/orchestration/task-complexity-classifier.js hash: sha256:33b3b7c349352d607c156e0018c508f0869a1c7d233d107bed194a51bc608c93 type: core @@ -1225,9 +1229,9 @@ files: type: data size: 9575 - path: data/entity-registry.yaml - hash: sha256:151aca46769c614f0238e7a8e2968884c3888b438ff1c634f0eeb2505e325b83 + hash: sha256:85c566837111c7cdf46f672a41959ee38accde60c8820cbc5bbb04b8f98203f0 type: data - size: 521804 + size: 522285 - path: data/learned-patterns.yaml hash: sha256:24ac0b160615583a0ff783d3da8af80b7f94191575d6db2054ec8e10a3f945dc type: data diff --git a/tests/core/orchestration/swarm-intelligence.test.js b/tests/core/orchestration/swarm-intelligence.test.js index 909852eeb..6cf00792b 100644 --- a/tests/core/orchestration/swarm-intelligence.test.js +++ b/tests/core/orchestration/swarm-intelligence.test.js @@ -567,6 +567,56 @@ describe('SwarmIntelligence', () => { }); }); + describe('resolveProposal - expired deadline', () => { + it('should reject expired proposals with deadline_expired reason', () => { + const swarm = si.createSwarm('expired-swarm'); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + + const p = si.proposeDecision(swarm.id, { + description: 'should expire', + proposedBy: 'a1', + deadlineMs: 1, // 1ms — will expire immediately + }); + + si.vote(swarm.id, p.id, 'a1', 'approve'); + + // Force deadline to the past + p.deadline = new Date(Date.now() - 10000).toISOString(); + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.status).toBe(PROPOSAL_STATUS.REJECTED); + expect(result.approved).toBe(false); + expect(result.reason).toBe('deadline_expired'); + expect(p.resolvedAt).toBeDefined(); + }); + }); + + describe('resolveProposal - quorum counts only approves', () => { + it('should not count abstains or rejects toward quorum', () => { + const swarm = si.createSwarm('quorum-approve-only', { + votingStrategy: 'quorum', + consensusThreshold: 0.6, + }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + si.joinSwarm(swarm.id, 'a4'); + si.joinSwarm(swarm.id, 'a5'); + + // quorumRequired = ceil(5 * 0.6) = 3 approves needed + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'reject'); + si.vote(swarm.id, p.id, 'a3', 'abstain'); + // totalVotes = 3, but only 1 approve — quorum NOT met (needs 3) + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.hasQuorum).toBe(false); + expect(result.approved).toBe(false); + }); + }); + describe('resolveProposal - common', () => { it('should emit proposal:resolved event', () => { const handler = jest.fn(); @@ -923,6 +973,38 @@ describe('SwarmIntelligence', () => { const result = await fresh.loadFromDisk(); expect(result).toBe(false); }); + + it('should rethrow non-ENOENT errors in loadFromDisk', async () => { + const instance = new SwarmIntelligence(TEST_PROJECT_ROOT, { persist: true, debug: false }); + + // Write invalid JSON to the persistence file + const persistDir = path.join(TEST_PROJECT_ROOT, '.aiox'); + await fs.mkdir(persistDir, { recursive: true }); + await fs.writeFile(path.join(persistDir, 'swarms.json'), 'NOT_VALID_JSON', 'utf8'); + + // Should throw SyntaxError (JSON parse), NOT silently return false + await expect(instance.loadFromDisk()).rejects.toThrow(SyntaxError); + }); + + it('should serialize persistence writes (promise chain)', async () => { + const instance = new SwarmIntelligence(TEST_PROJECT_ROOT, { persist: true, debug: false }); + + // Create swarm to trigger multiple _persistAsync calls + instance.createSwarm('chain-test-1'); + instance.createSwarm('chain-test-2'); + instance.createSwarm('chain-test-3'); + + // _pendingSave should be a promise (chain established) + expect(instance._pendingSave).toBeInstanceOf(Promise); + + // Wait for all writes to complete + await instance._pendingSave; + + // Verify file was written (last write wins) + const raw = await fs.readFile(path.join(TEST_PROJECT_ROOT, '.aiox', 'swarms.json'), 'utf8'); + const data = JSON.parse(raw); + expect(data.swarms.length).toBe(3); + }); }); // ═══════════════════════════════════════════════════════════════════════════ From c6a80fe63198111a841cb37685d64491c8a16df1 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Sun, 8 Mar 2026 02:58:23 -0300 Subject: [PATCH 4/5] =?UTF-8?q?fix(orchestration):=20corrigir=20coment?= =?UTF-8?q?=C3=A1rios=20do=20code=20review=20no=20Cognitive=20Load=20Balan?= =?UTF-8?q?cer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validação de ID duplicado ao submeter tasks - Guards de transição de estado (COMPLETED/FAILED são terminais) - Validação do parâmetro profile em registerAgent - Processar fila ao registrar novo agente - Persistência async de métricas com tratamento de erro - Emissão de eventos apenas em transições reais de status --- .../orchestration/cognitive-load-balancer.js | 83 +- .../orchestration/cognitive-load-balancer.js | 83 +- .aiox-core/data/entity-registry.yaml | 6 +- .aiox-core/install-manifest.yaml | 8 +- .../cognitive-load-balancer.test.js | 865 ++++-------------- 5 files changed, 306 insertions(+), 739 deletions(-) diff --git a/.aios-core/core/orchestration/cognitive-load-balancer.js b/.aios-core/core/orchestration/cognitive-load-balancer.js index 3c2c102c8..c8021088c 100644 --- a/.aios-core/core/orchestration/cognitive-load-balancer.js +++ b/.aios-core/core/orchestration/cognitive-load-balancer.js @@ -201,16 +201,26 @@ class CognitiveLoadBalancer extends EventEmitter { * @param {number} [profile.processingSpeed=1.0] - Processing speed multiplier * @returns {Object} Registered agent profile * @throws {Error} If agentId is not a non-empty string + * @throws {Error} If profile is provided but is not an object */ registerAgent(agentId, profile = {}) { if (!agentId || typeof agentId !== 'string') { throw new Error('agentId must be a non-empty string'); } - const agentProfile = createAgentProfile(agentId, profile); + // Validate profile overrides must be object or undefined + if (profile !== undefined && profile !== null && typeof profile !== 'object') { + throw new Error('profile must be an object or undefined'); + } + + const agentProfile = createAgentProfile(agentId, profile || {}); this.agents.set(agentId, agentProfile); this.emit('agent:registered', { agentId, profile: agentProfile }); + + // Process queue when new agent registers so queued tasks get assigned + this._processQueue(); + return agentProfile; } @@ -261,6 +271,8 @@ class CognitiveLoadBalancer extends EventEmitter { * @param {number} [taskInput.complexity=5] - Complexity 1-10 * @param {string[]} [taskInput.requiredSpecialties=[]] - Required specialties * @returns {Object} Submission result with taskId and assignedTo + * @throws {Error} If task input is not a non-null object + * @throws {Error} If task with same ID already exists */ submitTask(taskInput) { if (!taskInput || typeof taskInput !== 'object') { @@ -268,6 +280,12 @@ class CognitiveLoadBalancer extends EventEmitter { } const task = createTask(taskInput); + + // Reject duplicate task IDs + if (this.tasks.has(task.id)) { + throw new Error(`Task '${task.id}' already exists`); + } + this.tasks.set(task.id, task); this.metrics.totalSubmitted++; @@ -308,6 +326,7 @@ class CognitiveLoadBalancer extends EventEmitter { * @param {string} agentId - Target agent * @returns {Object} Assignment result * @throws {Error} If task or agent not found + * @throws {Error} If task is already completed or failed */ assignTask(taskId, agentId) { const task = this.tasks.get(taskId); @@ -315,6 +334,14 @@ class CognitiveLoadBalancer extends EventEmitter { throw new Error(`Task '${taskId}' not found`); } + // Enforce task state transitions - cannot assign completed/failed tasks + if (task.status === TaskStatus.COMPLETED) { + throw new Error(`Task '${taskId}' is already completed`); + } + if (task.status === TaskStatus.FAILED) { + throw new Error(`Task '${taskId}' is already failed`); + } + const agent = this.agents.get(agentId); if (!agent) { throw new Error(`Agent '${agentId}' not found`); @@ -348,13 +375,22 @@ class CognitiveLoadBalancer extends EventEmitter { * @param {*} [result=null] - Task result * @returns {Object} Completion info * @throws {Error} If task not found + * @throws {Error} If task is already completed or failed */ - completeTask(taskId, result = null) { + async completeTask(taskId, result = null) { const task = this.tasks.get(taskId); if (!task) { throw new Error(`Task '${taskId}' not found`); } + // Enforce task state transitions + if (task.status === TaskStatus.COMPLETED) { + throw new Error(`Task '${taskId}' is already completed`); + } + if (task.status === TaskStatus.FAILED) { + throw new Error(`Task '${taskId}' is already failed`); + } + task.status = TaskStatus.COMPLETED; task.completedAt = Date.now(); task.result = result; @@ -375,8 +411,8 @@ class CognitiveLoadBalancer extends EventEmitter { // Try to process queue after freeing capacity this._processQueue(); - // Persist metrics - this._persistMetrics(); + // Await metrics persistence instead of fire-and-forget + await this._persistMetrics(); return { taskId, @@ -391,13 +427,22 @@ class CognitiveLoadBalancer extends EventEmitter { * @param {string|Error} [error='Unknown error'] - Error description * @returns {Object} Failure info * @throws {Error} If task not found + * @throws {Error} If task is already completed or failed */ - failTask(taskId, error = 'Unknown error') { + async failTask(taskId, error = 'Unknown error') { const task = this.tasks.get(taskId); if (!task) { throw new Error(`Task '${taskId}' not found`); } + // Enforce task state transitions + if (task.status === TaskStatus.COMPLETED) { + throw new Error(`Task '${taskId}' is already completed`); + } + if (task.status === TaskStatus.FAILED) { + throw new Error(`Task '${taskId}' is already failed`); + } + const errorMessage = error instanceof Error ? error.message : String(error); task.status = TaskStatus.FAILED; task.completedAt = Date.now(); @@ -416,8 +461,8 @@ class CognitiveLoadBalancer extends EventEmitter { // Try to process queue after freeing capacity this._processQueue(); - // Persist metrics - this._persistMetrics(); + // Await metrics persistence instead of fire-and-forget + await this._persistMetrics(); return { taskId, @@ -727,6 +772,9 @@ class CognitiveLoadBalancer extends EventEmitter { * @private */ _assignTaskToAgent(task, agent) { + // Save previous status for transition-only events + const previousStatus = agent.status; + task.assignedTo = agent.id; task.status = TaskStatus.ASSIGNED; task.startedAt = Date.now(); @@ -737,9 +785,9 @@ class CognitiveLoadBalancer extends EventEmitter { this._updateAgentStatus(agent); this.emit('task:assigned', { taskId: task.id, agentId: agent.id }); - // Check if agent became overloaded - const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; - if (loadPct >= OVERLOAD_THRESHOLD) { + // Only emit agent:overloaded on actual status transition + if (agent.status === AgentStatus.OVERLOADED && previousStatus !== AgentStatus.OVERLOADED) { + const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; this.emit('agent:overloaded', { agentId: agent.id, load: loadPct }); } } @@ -751,6 +799,9 @@ class CognitiveLoadBalancer extends EventEmitter { * @private */ _removeTaskFromAgent(agent, taskId) { + // Save previous status for transition-only events + const previousStatus = agent.status; + const idx = agent.activeTasks.indexOf(taskId); if (idx !== -1) { agent.activeTasks.splice(idx, 1); @@ -763,9 +814,10 @@ class CognitiveLoadBalancer extends EventEmitter { this._updateAgentStatus(agent); - // Check if agent became available again - const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; - if (loadPct < OVERLOAD_THRESHOLD && agent.status !== AgentStatus.OFFLINE) { + // Only emit agent:available on transition FROM overloaded + if (agent.status !== AgentStatus.OVERLOADED && agent.status !== AgentStatus.OFFLINE + && previousStatus === AgentStatus.OVERLOADED) { + const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; this.emit('agent:available', { agentId: agent.id, load: loadPct }); } } @@ -914,8 +966,9 @@ class CognitiveLoadBalancer extends EventEmitter { await fs.mkdir(metricsDir, { recursive: true }); await fs.writeFile(metricsPath, JSON.stringify(this.getMetrics(), null, 2), 'utf8'); - } catch { - // Silently ignore persistence errors in production + } catch (err) { + // Log persistence errors with context instead of silently ignoring + console.error(`Failed to persist load balancer metrics: ${err.message}`); } } } diff --git a/.aiox-core/core/orchestration/cognitive-load-balancer.js b/.aiox-core/core/orchestration/cognitive-load-balancer.js index 3c2c102c8..c8021088c 100644 --- a/.aiox-core/core/orchestration/cognitive-load-balancer.js +++ b/.aiox-core/core/orchestration/cognitive-load-balancer.js @@ -201,16 +201,26 @@ class CognitiveLoadBalancer extends EventEmitter { * @param {number} [profile.processingSpeed=1.0] - Processing speed multiplier * @returns {Object} Registered agent profile * @throws {Error} If agentId is not a non-empty string + * @throws {Error} If profile is provided but is not an object */ registerAgent(agentId, profile = {}) { if (!agentId || typeof agentId !== 'string') { throw new Error('agentId must be a non-empty string'); } - const agentProfile = createAgentProfile(agentId, profile); + // Validate profile overrides must be object or undefined + if (profile !== undefined && profile !== null && typeof profile !== 'object') { + throw new Error('profile must be an object or undefined'); + } + + const agentProfile = createAgentProfile(agentId, profile || {}); this.agents.set(agentId, agentProfile); this.emit('agent:registered', { agentId, profile: agentProfile }); + + // Process queue when new agent registers so queued tasks get assigned + this._processQueue(); + return agentProfile; } @@ -261,6 +271,8 @@ class CognitiveLoadBalancer extends EventEmitter { * @param {number} [taskInput.complexity=5] - Complexity 1-10 * @param {string[]} [taskInput.requiredSpecialties=[]] - Required specialties * @returns {Object} Submission result with taskId and assignedTo + * @throws {Error} If task input is not a non-null object + * @throws {Error} If task with same ID already exists */ submitTask(taskInput) { if (!taskInput || typeof taskInput !== 'object') { @@ -268,6 +280,12 @@ class CognitiveLoadBalancer extends EventEmitter { } const task = createTask(taskInput); + + // Reject duplicate task IDs + if (this.tasks.has(task.id)) { + throw new Error(`Task '${task.id}' already exists`); + } + this.tasks.set(task.id, task); this.metrics.totalSubmitted++; @@ -308,6 +326,7 @@ class CognitiveLoadBalancer extends EventEmitter { * @param {string} agentId - Target agent * @returns {Object} Assignment result * @throws {Error} If task or agent not found + * @throws {Error} If task is already completed or failed */ assignTask(taskId, agentId) { const task = this.tasks.get(taskId); @@ -315,6 +334,14 @@ class CognitiveLoadBalancer extends EventEmitter { throw new Error(`Task '${taskId}' not found`); } + // Enforce task state transitions - cannot assign completed/failed tasks + if (task.status === TaskStatus.COMPLETED) { + throw new Error(`Task '${taskId}' is already completed`); + } + if (task.status === TaskStatus.FAILED) { + throw new Error(`Task '${taskId}' is already failed`); + } + const agent = this.agents.get(agentId); if (!agent) { throw new Error(`Agent '${agentId}' not found`); @@ -348,13 +375,22 @@ class CognitiveLoadBalancer extends EventEmitter { * @param {*} [result=null] - Task result * @returns {Object} Completion info * @throws {Error} If task not found + * @throws {Error} If task is already completed or failed */ - completeTask(taskId, result = null) { + async completeTask(taskId, result = null) { const task = this.tasks.get(taskId); if (!task) { throw new Error(`Task '${taskId}' not found`); } + // Enforce task state transitions + if (task.status === TaskStatus.COMPLETED) { + throw new Error(`Task '${taskId}' is already completed`); + } + if (task.status === TaskStatus.FAILED) { + throw new Error(`Task '${taskId}' is already failed`); + } + task.status = TaskStatus.COMPLETED; task.completedAt = Date.now(); task.result = result; @@ -375,8 +411,8 @@ class CognitiveLoadBalancer extends EventEmitter { // Try to process queue after freeing capacity this._processQueue(); - // Persist metrics - this._persistMetrics(); + // Await metrics persistence instead of fire-and-forget + await this._persistMetrics(); return { taskId, @@ -391,13 +427,22 @@ class CognitiveLoadBalancer extends EventEmitter { * @param {string|Error} [error='Unknown error'] - Error description * @returns {Object} Failure info * @throws {Error} If task not found + * @throws {Error} If task is already completed or failed */ - failTask(taskId, error = 'Unknown error') { + async failTask(taskId, error = 'Unknown error') { const task = this.tasks.get(taskId); if (!task) { throw new Error(`Task '${taskId}' not found`); } + // Enforce task state transitions + if (task.status === TaskStatus.COMPLETED) { + throw new Error(`Task '${taskId}' is already completed`); + } + if (task.status === TaskStatus.FAILED) { + throw new Error(`Task '${taskId}' is already failed`); + } + const errorMessage = error instanceof Error ? error.message : String(error); task.status = TaskStatus.FAILED; task.completedAt = Date.now(); @@ -416,8 +461,8 @@ class CognitiveLoadBalancer extends EventEmitter { // Try to process queue after freeing capacity this._processQueue(); - // Persist metrics - this._persistMetrics(); + // Await metrics persistence instead of fire-and-forget + await this._persistMetrics(); return { taskId, @@ -727,6 +772,9 @@ class CognitiveLoadBalancer extends EventEmitter { * @private */ _assignTaskToAgent(task, agent) { + // Save previous status for transition-only events + const previousStatus = agent.status; + task.assignedTo = agent.id; task.status = TaskStatus.ASSIGNED; task.startedAt = Date.now(); @@ -737,9 +785,9 @@ class CognitiveLoadBalancer extends EventEmitter { this._updateAgentStatus(agent); this.emit('task:assigned', { taskId: task.id, agentId: agent.id }); - // Check if agent became overloaded - const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; - if (loadPct >= OVERLOAD_THRESHOLD) { + // Only emit agent:overloaded on actual status transition + if (agent.status === AgentStatus.OVERLOADED && previousStatus !== AgentStatus.OVERLOADED) { + const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; this.emit('agent:overloaded', { agentId: agent.id, load: loadPct }); } } @@ -751,6 +799,9 @@ class CognitiveLoadBalancer extends EventEmitter { * @private */ _removeTaskFromAgent(agent, taskId) { + // Save previous status for transition-only events + const previousStatus = agent.status; + const idx = agent.activeTasks.indexOf(taskId); if (idx !== -1) { agent.activeTasks.splice(idx, 1); @@ -763,9 +814,10 @@ class CognitiveLoadBalancer extends EventEmitter { this._updateAgentStatus(agent); - // Check if agent became available again - const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; - if (loadPct < OVERLOAD_THRESHOLD && agent.status !== AgentStatus.OFFLINE) { + // Only emit agent:available on transition FROM overloaded + if (agent.status !== AgentStatus.OVERLOADED && agent.status !== AgentStatus.OFFLINE + && previousStatus === AgentStatus.OVERLOADED) { + const loadPct = agent.maxLoad > 0 ? (agent.currentLoad / agent.maxLoad) * 100 : 100; this.emit('agent:available', { agentId: agent.id, load: loadPct }); } } @@ -914,8 +966,9 @@ class CognitiveLoadBalancer extends EventEmitter { await fs.mkdir(metricsDir, { recursive: true }); await fs.writeFile(metricsPath, JSON.stringify(this.getMetrics(), null, 2), 'utf8'); - } catch { - // Silently ignore persistence errors in production + } catch (err) { + // Log persistence errors with context instead of silently ignoring + console.error(`Failed to persist load balancer metrics: ${err.message}`); } } } diff --git a/.aiox-core/data/entity-registry.yaml b/.aiox-core/data/entity-registry.yaml index 2390c82a7..8f4764332 100644 --- a/.aiox-core/data/entity-registry.yaml +++ b/.aiox-core/data/entity-registry.yaml @@ -1,6 +1,6 @@ metadata: version: 1.0.0 - lastUpdated: '2026-03-08T05:58:36.216Z' + lastUpdated: '2026-03-08T05:58:54.622Z' entityCount: 746 checksumAlgorithm: sha256 resolutionRate: 100 @@ -12792,8 +12792,8 @@ entities: score: 0.4 constraints: [] extensionPoints: [] - checksum: sha256:b9c0e2050639c3dbf9cf404e781799dd02fbb8d8157fd60a91ba47f17112bf53 - lastVerified: '2026-03-08T05:58:36.213Z' + checksum: sha256:960b90fbcafb68d5728a645e892891d9bdec053b4e9f36086f8e6d35259a08c1 + lastVerified: '2026-03-08T05:58:54.620Z' agents: aiox-master: path: .aiox-core/development/agents/aiox-master.md diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 9bba0dfbb..f2380d23d 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-11T02:23:14.237Z" +generated_at: "2026-03-11T02:23:25.890Z" generator: scripts/generate-install-manifest.js file_count: 1091 files: @@ -829,9 +829,9 @@ files: type: core size: 19293 - path: core/orchestration/cognitive-load-balancer.js - hash: sha256:1b1903400ef45fe55b1cd02a0a824fd4a17fdfe368bf319ce2951ca59fee734b + hash: sha256:b3c3c865f2751cb3b39807c782cad21268900c558b7c9dc8a3607108699ff2bc type: core - size: 32804 + size: 35031 - path: core/orchestration/condition-evaluator.js hash: sha256:8bf565cf56194340ff4e1d642647150775277bce649411d0338faa2c96106745 type: core @@ -1229,7 +1229,7 @@ files: type: data size: 9575 - path: data/entity-registry.yaml - hash: sha256:85c566837111c7cdf46f672a41959ee38accde60c8820cbc5bbb04b8f98203f0 + hash: sha256:eceaba9d1de06b854c8dd7b0229c3b2b99c6dac0938d9c841a042e45e214b2f2 type: data size: 522285 - path: data/learned-patterns.yaml diff --git a/tests/core/orchestration/cognitive-load-balancer.test.js b/tests/core/orchestration/cognitive-load-balancer.test.js index 72068c03f..7caacf67d 100644 --- a/tests/core/orchestration/cognitive-load-balancer.test.js +++ b/tests/core/orchestration/cognitive-load-balancer.test.js @@ -1,17 +1,7 @@ /** * Cognitive Load Balancer Tests - * * Story ORCH-6 - Intelligent task distribution based on agent cognitive capacity - * - * Tests the core functionality of the CognitiveLoadBalancer class including: - * - Agent registration and unregistration - * - Task submission and routing - * - Affinity scoring algorithm - * - Throttle policies - * - Rebalancing - * - Metrics and queue management - * - * @version 1.0.0 + * @version 1.1.0 */ const path = require('path'); @@ -35,26 +25,14 @@ describe('CognitiveLoadBalancer', () => { beforeEach(() => { tempDir = path.join(os.tmpdir(), `clb-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); fs.mkdirSync(tempDir, { recursive: true }); - - balancer = new CognitiveLoadBalancer({ - projectRoot: tempDir, - persistMetrics: false, - }); + balancer = new CognitiveLoadBalancer({ projectRoot: tempDir, persistMetrics: false }); }); afterEach(() => { balancer.removeAllListeners(); - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } + try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* ignore */ } }); - // ═══════════════════════════════════════════════════════════════════════════════ - // CONSTRUCTOR - // ═══════════════════════════════════════════════════════════════════════════════ - describe('Constructor', () => { it('should create instance with default options', () => { const b = new CognitiveLoadBalancer(); @@ -65,7 +43,7 @@ describe('CognitiveLoadBalancer', () => { expect(b.queue).toEqual([]); }); - it('should accept custom options using nullish coalescing', () => { + it('should accept custom options', () => { const b = new CognitiveLoadBalancer({ projectRoot: '/custom/path', throttlePolicy: ThrottlePolicy.REJECT_WHEN_FULL, @@ -85,49 +63,28 @@ describe('CognitiveLoadBalancer', () => { it('should initialize metrics with startTime', () => { expect(balancer.metrics.startTime).toBeLessThanOrEqual(Date.now()); expect(balancer.metrics.totalSubmitted).toBe(0); - expect(balancer.metrics.totalCompleted).toBe(0); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // AGENT REGISTRATION - // ═══════════════════════════════════════════════════════════════════════════════ - describe('registerAgent', () => { it('should register agent with default profile', () => { const profile = balancer.registerAgent('agent-1'); expect(profile.id).toBe('agent-1'); expect(profile.maxLoad).toBe(100); - expect(profile.currentLoad).toBe(0); - expect(profile.specialties).toEqual([]); - expect(profile.processingSpeed).toBe(1.0); expect(profile.status).toBe(AgentStatus.AVAILABLE); - expect(profile.activeTasks).toEqual([]); - expect(profile.completedCount).toBe(0); - expect(profile.failedCount).toBe(0); }); it('should register agent with custom profile', () => { - const profile = balancer.registerAgent('agent-2', { - maxLoad: 50, - specialties: ['frontend', 'testing'], - processingSpeed: 1.5, - }); + const profile = balancer.registerAgent('agent-2', { maxLoad: 50, specialties: ['frontend'], processingSpeed: 1.5 }); expect(profile.maxLoad).toBe(50); - expect(profile.specialties).toEqual(['frontend', 'testing']); - expect(profile.processingSpeed).toBe(1.5); + expect(profile.specialties).toEqual(['frontend']); }); it('should emit agent:registered event', () => { const handler = jest.fn(); balancer.on('agent:registered', handler); - balancer.registerAgent('agent-3'); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ agentId: 'agent-3' }) - ); }); it('should throw on empty agentId', () => { @@ -139,18 +96,34 @@ describe('CognitiveLoadBalancer', () => { expect(() => balancer.registerAgent(123)).toThrow('agentId must be a non-empty string'); }); + it('should throw on non-object profile', () => { + expect(() => balancer.registerAgent('agent-1', 'invalid')).toThrow('profile must be an object or undefined'); + expect(() => balancer.registerAgent('agent-1', 42)).toThrow('profile must be an object or undefined'); + }); + it('should overwrite existing agent on re-registration', () => { balancer.registerAgent('agent-1', { maxLoad: 50 }); balancer.registerAgent('agent-1', { maxLoad: 200 }); expect(balancer.agents.get('agent-1').maxLoad).toBe(200); }); + + it('should process queue when new agent registers', () => { + balancer.submitTask({ id: 'wait-1', complexity: 5 }); + balancer.submitTask({ id: 'wait-2', complexity: 3 }); + expect(balancer.queue).toHaveLength(2); + + balancer.registerAgent('new-agent', { maxLoad: 100 }); + + expect(balancer.queue).toHaveLength(0); + expect(balancer.tasks.get('wait-1').assignedTo).toBe('new-agent'); + expect(balancer.tasks.get('wait-2').assignedTo).toBe('new-agent'); + }); }); describe('unregisterAgent', () => { it('should unregister agent and return orphaned task IDs', () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); balancer.submitTask({ id: 'task-1', complexity: 5 }); - const orphaned = balancer.unregisterAgent('agent-1'); expect(orphaned).toContain('task-1'); expect(balancer.agents.has('agent-1')).toBe(false); @@ -159,13 +132,10 @@ describe('CognitiveLoadBalancer', () => { it('should re-queue orphaned tasks', () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); balancer.submitTask({ id: 'task-1', complexity: 5 }); - balancer.unregisterAgent('agent-1'); - const task = balancer.tasks.get('task-1'); expect(task.status).toBe(TaskStatus.QUEUED); expect(task.assignedTo).toBeNull(); - expect(balancer.queue).toContain('task-1'); }); it('should throw on unknown agent', () => { @@ -175,26 +145,15 @@ describe('CognitiveLoadBalancer', () => { it('should reassign orphaned tasks to remaining agents', () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); balancer.registerAgent('agent-2', { maxLoad: 100 }); - balancer.submitTask({ id: 'task-1', complexity: 5 }); - const task = balancer.tasks.get('task-1'); - // Force assign to agent-1 - if (task.assignedTo !== 'agent-1') { + if (balancer.tasks.get('task-1').assignedTo !== 'agent-1') { balancer.assignTask('task-1', 'agent-1'); } - balancer.unregisterAgent('agent-1'); - - // Task should be reassigned to agent-2 via queue processing - const updatedTask = balancer.tasks.get('task-1'); - expect(updatedTask.assignedTo).toBe('agent-2'); + expect(balancer.tasks.get('task-1').assignedTo).toBe('agent-2'); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // TASK SUBMISSION - // ═══════════════════════════════════════════════════════════════════════════════ - describe('submitTask', () => { beforeEach(() => { balancer.registerAgent('agent-1', { maxLoad: 100, specialties: ['backend'] }); @@ -204,11 +163,10 @@ describe('CognitiveLoadBalancer', () => { const result = balancer.submitTask({ type: 'coding', complexity: 5 }); expect(result.assignedTo).toBe('agent-1'); expect(result.status).toBe(TaskStatus.ASSIGNED); - expect(result.taskId).toBeDefined(); }); it('should auto-generate task ID when omitted', () => { - const result = balancer.submitTask({ type: 'coding', complexity: 3 }); + const result = balancer.submitTask({ complexity: 3 }); expect(result.taskId).toMatch(/^task-/); }); @@ -217,28 +175,16 @@ describe('CognitiveLoadBalancer', () => { expect(result.taskId).toBe('my-task-1'); }); + it('should reject duplicate task IDs', () => { + balancer.submitTask({ id: 'dup-task', complexity: 3 }); + expect(() => balancer.submitTask({ id: 'dup-task', complexity: 5 })).toThrow("Task 'dup-task' already exists"); + }); + it('should emit task:submitted event', () => { const handler = jest.fn(); balancer.on('task:submitted', handler); - balancer.submitTask({ id: 'evt-task', complexity: 3 }); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ taskId: 'evt-task' }) - ); - }); - - it('should emit task:assigned event when assigned', () => { - const handler = jest.fn(); - balancer.on('task:assigned', handler); - - balancer.submitTask({ id: 'asgn-task', complexity: 3 }); - - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ taskId: 'asgn-task', agentId: 'agent-1' }) - ); }); it('should throw on non-object task input', () => { @@ -249,96 +195,24 @@ describe('CognitiveLoadBalancer', () => { it('should clamp complexity to range 1-10', () => { balancer.submitTask({ id: 'low', complexity: -5 }); balancer.submitTask({ id: 'high', complexity: 999 }); - expect(balancer.tasks.get('low').complexity).toBe(1); expect(balancer.tasks.get('high').complexity).toBe(10); }); - it('should default complexity to 5', () => { - balancer.submitTask({ id: 'default-cplx' }); - expect(balancer.tasks.get('default-cplx').complexity).toBe(5); - }); - - it('should queue task when all agents at capacity', () => { - balancer.registerAgent('small-agent', { maxLoad: 5 }); - // Fill agent-1 - for (let i = 0; i < 20; i++) { - balancer.submitTask({ id: `fill-${i}`, complexity: 5 }); - } - - const result = balancer.submitTask({ id: 'overflow-task', complexity: 10 }); - // Should be queued (or assigned to small-agent depending on capacity) - expect([TaskStatus.QUEUED, TaskStatus.ASSIGNED]).toContain(result.status); - }); - it('should increment totalSubmitted metric', () => { balancer.submitTask({ complexity: 3 }); balancer.submitTask({ complexity: 3 }); - balancer.submitTask({ complexity: 3 }); - - expect(balancer.metrics.totalSubmitted).toBe(3); + expect(balancer.metrics.totalSubmitted).toBe(2); }); }); - describe('submitTask - Priority handling', () => { - it('should handle critical priority tasks by bypassing queue', () => { - balancer.registerAgent('fast-agent', { maxLoad: 100 }); - - // Fill agent partially - balancer.submitTask({ id: 'normal-1', complexity: 5, priority: TaskPriority.NORMAL }); - - const result = balancer.submitTask({ - id: 'critical-1', - complexity: 3, - priority: TaskPriority.CRITICAL, - }); - - expect(result.status).toBe(TaskStatus.ASSIGNED); - expect(result.assignedTo).toBe('fast-agent'); - }); - - it('should reject critical tasks under reject-when-full policy when no agent available', () => { - balancer.setThrottlePolicy(ThrottlePolicy.REJECT_WHEN_FULL); - // No agents registered - - const result = balancer.submitTask({ - id: 'critical-no-agent', - complexity: 3, - priority: TaskPriority.CRITICAL, - }); - - expect(result.status).toBe(TaskStatus.FAILED); - }); - }); - - // ═══════════════════════════════════════════════════════════════════════════════ - // MANUAL ASSIGNMENT - // ═══════════════════════════════════════════════════════════════════════════════ - describe('assignTask', () => { it('should manually assign task to specific agent', () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); balancer.registerAgent('agent-2', { maxLoad: 100 }); - balancer.submitTask({ id: 'task-1', complexity: 5 }); - const result = balancer.assignTask('task-1', 'agent-2'); expect(result.assignedTo).toBe('agent-2'); - - const task = balancer.tasks.get('task-1'); - expect(task.assignedTo).toBe('agent-2'); - }); - - it('should remove task from queue on manual assignment', () => { - // Create a task that goes to queue (no agents) - balancer.submitTask({ id: 'queued-task', complexity: 5 }); - expect(balancer.queue).toContain('queued-task'); - - // Now register an agent and manually assign - balancer.registerAgent('agent-1', { maxLoad: 100 }); - balancer.assignTask('queued-task', 'agent-1'); - - expect(balancer.queue).not.toContain('queued-task'); }); it('should throw on unknown task', () => { @@ -352,185 +226,129 @@ describe('CognitiveLoadBalancer', () => { expect(() => balancer.assignTask('task-1', 'unknown')).toThrow("Agent 'unknown' not found"); }); - it('should move task from one agent to another', () => { + it('should throw when assigning a completed task', async () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); balancer.registerAgent('agent-2', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 5 }); + await balancer.completeTask('task-1'); + expect(() => balancer.assignTask('task-1', 'agent-2')).toThrow("Task 'task-1' is already completed"); + }); + it('should throw when assigning a failed task', async () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.registerAgent('agent-2', { maxLoad: 100 }); balancer.submitTask({ id: 'task-1', complexity: 5 }); + await balancer.failTask('task-1', 'error'); + expect(() => balancer.assignTask('task-1', 'agent-2')).toThrow("Task 'task-1' is already failed"); + }); - // Ensure it's on agent-1 + it('should move task from one agent to another', () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.registerAgent('agent-2', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 5 }); balancer.assignTask('task-1', 'agent-1'); - expect(balancer.agents.get('agent-1').activeTasks).toContain('task-1'); - - // Move to agent-2 balancer.assignTask('task-1', 'agent-2'); expect(balancer.agents.get('agent-1').activeTasks).not.toContain('task-1'); expect(balancer.agents.get('agent-2').activeTasks).toContain('task-1'); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // TASK COMPLETION - // ═══════════════════════════════════════════════════════════════════════════════ - describe('completeTask', () => { - beforeEach(() => { - balancer.registerAgent('agent-1', { maxLoad: 100 }); - }); + beforeEach(() => { balancer.registerAgent('agent-1', { maxLoad: 100 }); }); - it('should mark task as completed', () => { + it('should mark task as completed', async () => { balancer.submitTask({ id: 'task-1', complexity: 5 }); - balancer.completeTask('task-1', { output: 'done' }); - + await balancer.completeTask('task-1', { output: 'done' }); const task = balancer.tasks.get('task-1'); expect(task.status).toBe(TaskStatus.COMPLETED); expect(task.result).toEqual({ output: 'done' }); - expect(task.completedAt).toBeDefined(); }); - it('should free agent capacity', () => { + it('should free agent capacity', async () => { balancer.submitTask({ id: 'task-1', complexity: 8 }); expect(balancer.agents.get('agent-1').currentLoad).toBe(8); - - balancer.completeTask('task-1'); + await balancer.completeTask('task-1'); expect(balancer.agents.get('agent-1').currentLoad).toBe(0); }); - it('should update agent completion stats', () => { - balancer.submitTask({ id: 'task-1', complexity: 5 }); - balancer.completeTask('task-1'); - - const agent = balancer.agents.get('agent-1'); - expect(agent.completedCount).toBe(1); - expect(agent.avgCompletionTime).toBeGreaterThanOrEqual(0); - }); - - it('should emit task:completed event', () => { + it('should emit task:completed event', async () => { const handler = jest.fn(); balancer.on('task:completed', handler); - balancer.submitTask({ id: 'task-1', complexity: 5 }); - balancer.completeTask('task-1', 'result-data'); - - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ - taskId: 'task-1', - result: 'result-data', - agentId: 'agent-1', - }) - ); + await balancer.completeTask('task-1', 'result-data'); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'task-1', result: 'result-data' })); }); - it('should increment totalCompleted metric', () => { - balancer.submitTask({ id: 'task-1', complexity: 5 }); - balancer.completeTask('task-1'); - - expect(balancer.metrics.totalCompleted).toBe(1); + it('should throw on unknown task', async () => { + await expect(balancer.completeTask('unknown')).rejects.toThrow("Task 'unknown' not found"); }); - it('should throw on unknown task', () => { - expect(() => balancer.completeTask('unknown')).toThrow("Task 'unknown' not found"); + it('should throw when completing an already completed task', async () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + await balancer.completeTask('task-1'); + await expect(balancer.completeTask('task-1')).rejects.toThrow("Task 'task-1' is already completed"); }); - it('should process queue after completing task', () => { - // Fill agent to capacity - balancer.registerAgent('agent-full', { maxLoad: 10 }); - balancer.submitTask({ id: 'fill-1', complexity: 5 }); - balancer.submitTask({ id: 'fill-2', complexity: 5 }); - - // This should be queued (agent-1 may have space but agent-full is full) - balancer.submitTask({ id: 'waiting', complexity: 8 }); - - // Complete a task to free capacity - balancer.completeTask('fill-1'); - - // Check that queue processing attempted - expect(balancer.metrics.totalCompleted).toBe(1); + it('should throw when completing an already failed task', async () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + await balancer.failTask('task-1', 'error'); + await expect(balancer.completeTask('task-1')).rejects.toThrow("Task 'task-1' is already failed"); }); - it('should return completion time info', () => { + it('should return completion time info', async () => { balancer.submitTask({ id: 'task-1', complexity: 5 }); - const result = balancer.completeTask('task-1'); - + const result = await balancer.completeTask('task-1'); expect(result.taskId).toBe('task-1'); - expect(result.agentId).toBe('agent-1'); expect(result.completionTime).toBeGreaterThanOrEqual(0); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // TASK FAILURE - // ═══════════════════════════════════════════════════════════════════════════════ - describe('failTask', () => { - beforeEach(() => { - balancer.registerAgent('agent-1', { maxLoad: 100 }); - }); + beforeEach(() => { balancer.registerAgent('agent-1', { maxLoad: 100 }); }); - it('should mark task as failed', () => { + it('should mark task as failed', async () => { balancer.submitTask({ id: 'task-1', complexity: 5 }); - balancer.failTask('task-1', 'Something went wrong'); - + await balancer.failTask('task-1', 'Something went wrong'); const task = balancer.tasks.get('task-1'); expect(task.status).toBe(TaskStatus.FAILED); expect(task.error).toBe('Something went wrong'); }); - it('should accept Error objects', () => { + it('should accept Error objects', async () => { balancer.submitTask({ id: 'task-1', complexity: 5 }); - balancer.failTask('task-1', new Error('Detailed error')); - - const task = balancer.tasks.get('task-1'); - expect(task.error).toBe('Detailed error'); + await balancer.failTask('task-1', new Error('Detailed error')); + expect(balancer.tasks.get('task-1').error).toBe('Detailed error'); }); - it('should free agent capacity', () => { + it('should free agent capacity', async () => { balancer.submitTask({ id: 'task-1', complexity: 7 }); - expect(balancer.agents.get('agent-1').currentLoad).toBe(7); - - balancer.failTask('task-1', 'fail'); + await balancer.failTask('task-1', 'fail'); expect(balancer.agents.get('agent-1').currentLoad).toBe(0); }); - it('should update agent failure stats', () => { - balancer.submitTask({ id: 'task-1', complexity: 5 }); - balancer.failTask('task-1', 'error'); - - expect(balancer.agents.get('agent-1').failedCount).toBe(1); + it('should throw on unknown task', async () => { + await expect(balancer.failTask('unknown')).rejects.toThrow("Task 'unknown' not found"); }); - it('should emit task:failed event', () => { - const handler = jest.fn(); - balancer.on('task:failed', handler); - + it('should throw when failing an already failed task', async () => { balancer.submitTask({ id: 'task-1', complexity: 5 }); - balancer.failTask('task-1', 'test error'); - - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ - taskId: 'task-1', - error: 'test error', - agentId: 'agent-1', - }) - ); + await balancer.failTask('task-1', 'first'); + await expect(balancer.failTask('task-1', 'second')).rejects.toThrow("Task 'task-1' is already failed"); }); - it('should throw on unknown task', () => { - expect(() => balancer.failTask('unknown')).toThrow("Task 'unknown' not found"); + it('should throw when failing an already completed task', async () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + await balancer.completeTask('task-1'); + await expect(balancer.failTask('task-1', 'error')).rejects.toThrow("Task 'task-1' is already completed"); }); - it('should use default error message when none provided', () => { + it('should use default error message when none provided', async () => { balancer.submitTask({ id: 'task-1', complexity: 5 }); - balancer.failTask('task-1'); - + await balancer.failTask('task-1'); expect(balancer.tasks.get('task-1').error).toBe('Unknown error'); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // AGENT LOAD QUERIES - // ═══════════════════════════════════════════════════════════════════════════════ - describe('getAgentLoad', () => { it('should return 0% for empty agent', () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); @@ -540,7 +358,6 @@ describe('CognitiveLoadBalancer', () => { it('should return correct load percentage', () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); balancer.submitTask({ id: 'task-1', complexity: 10 }); - // complexity 10 on maxLoad 100 = 10% expect(balancer.getAgentLoad('agent-1')).toBe(10); }); @@ -549,380 +366,118 @@ describe('CognitiveLoadBalancer', () => { balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); balancer.submitTask({ id: 'task-1', complexity: 10 }); balancer.submitTask({ id: 'task-2', complexity: 10 }); - expect(balancer.getAgentLoad('agent-1')).toBe(100); }); - it('should return 100% for agent with maxLoad 0', () => { - balancer.registerAgent('agent-zero', { maxLoad: 0 }); - expect(balancer.getAgentLoad('agent-zero')).toBe(100); - }); - it('should throw on unknown agent', () => { expect(() => balancer.getAgentLoad('unknown')).toThrow("Agent 'unknown' not found"); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // OPTIMAL AGENT - // ═══════════════════════════════════════════════════════════════════════════════ - describe('getOptimalAgent', () => { it('should return null when no agents registered', () => { - const result = balancer.getOptimalAgent({ type: 'coding', complexity: 5 }); - expect(result).toBeNull(); - }); - - it('should return agent info without assigning', () => { - balancer.registerAgent('agent-1', { maxLoad: 100 }); - - const result = balancer.getOptimalAgent({ type: 'coding', complexity: 5 }); - expect(result.agentId).toBe('agent-1'); - expect(result.currentLoad).toBe(0); - expect(result.affinityScore).toBeGreaterThan(0); - expect(result.specialties).toEqual([]); - - // Verify no task was created or assigned - expect(balancer.agents.get('agent-1').activeTasks.length).toBe(0); + expect(balancer.getOptimalAgent({ complexity: 5 })).toBeNull(); }); it('should prefer agent with matching specialties', () => { - balancer.registerAgent('generalist', { maxLoad: 100, specialties: [] }); + balancer.registerAgent('generalist', { maxLoad: 100 }); balancer.registerAgent('specialist', { maxLoad: 100, specialties: ['testing'] }); - - const result = balancer.getOptimalAgent({ - type: 'test', - complexity: 5, - requiredSpecialties: ['testing'], - }); - + const result = balancer.getOptimalAgent({ complexity: 5, requiredSpecialties: ['testing'] }); expect(result.agentId).toBe('specialist'); }); - - it('should factor in processing speed', () => { - balancer.registerAgent('slow', { maxLoad: 100, processingSpeed: 0.5 }); - balancer.registerAgent('fast', { maxLoad: 100, processingSpeed: 2.0 }); - - const result = balancer.getOptimalAgent({ type: 'coding', complexity: 5 }); - expect(result.agentId).toBe('fast'); - }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // AFFINITY SCORING - // ═══════════════════════════════════════════════════════════════════════════════ - - describe('Affinity scoring algorithm', () => { - it('should weight specialty match at 40%', () => { - expect(AFFINITY_WEIGHTS.SPECIALTY).toBe(0.4); - }); - - it('should weight load inverse at 30%', () => { - expect(AFFINITY_WEIGHTS.LOAD_INVERSE).toBe(0.3); - }); - - it('should weight speed at 20%', () => { - expect(AFFINITY_WEIGHTS.SPEED).toBe(0.2); - }); - - it('should weight success rate at 10%', () => { - expect(AFFINITY_WEIGHTS.SUCCESS_RATE).toBe(0.1); - }); - - it('should prefer less loaded agents for equal specialty match', () => { - balancer.registerAgent('loaded', { maxLoad: 100, specialties: ['backend'] }); - balancer.registerAgent('free', { maxLoad: 100, specialties: ['backend'] }); - - // Load up the first agent - balancer.submitTask({ id: 'load-1', complexity: 8 }); - balancer.assignTask('load-1', 'loaded'); - balancer.submitTask({ id: 'load-2', complexity: 8 }); - balancer.assignTask('load-2', 'loaded'); - - const result = balancer.getOptimalAgent({ - type: 'backend', - complexity: 5, - requiredSpecialties: ['backend'], - }); - - expect(result.agentId).toBe('free'); - }); - - it('should give new agents benefit of the doubt for success rate', () => { - balancer.registerAgent('new-agent', { maxLoad: 100 }); - const agent = balancer.agents.get('new-agent'); + describe('Affinity scoring', () => { + it('should weight specialty match at 40%', () => { expect(AFFINITY_WEIGHTS.SPECIALTY).toBe(0.4); }); + it('should weight load inverse at 30%', () => { expect(AFFINITY_WEIGHTS.LOAD_INVERSE).toBe(0.3); }); + it('should weight speed at 20%', () => { expect(AFFINITY_WEIGHTS.SPEED).toBe(0.2); }); + it('should weight success rate at 10%', () => { expect(AFFINITY_WEIGHTS.SUCCESS_RATE).toBe(0.1); }); - // Internal method access for testing - const successRate = balancer._getSuccessRate(agent); - expect(successRate).toBe(1); // Perfect score for untested agents - }); - - it('should calculate correct success rate with history', () => { + it('should calculate correct success rate with history', async () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); - - // 3 completed, 1 failed = 75% success - balancer.submitTask({ id: 't1', complexity: 2 }); - balancer.completeTask('t1'); - balancer.submitTask({ id: 't2', complexity: 2 }); - balancer.completeTask('t2'); - balancer.submitTask({ id: 't3', complexity: 2 }); - balancer.completeTask('t3'); - balancer.submitTask({ id: 't4', complexity: 2 }); - balancer.failTask('t4', 'error'); - - const agent = balancer.agents.get('agent-1'); - expect(balancer._getSuccessRate(agent)).toBe(0.75); + balancer.submitTask({ id: 't1', complexity: 2 }); await balancer.completeTask('t1'); + balancer.submitTask({ id: 't2', complexity: 2 }); await balancer.completeTask('t2'); + balancer.submitTask({ id: 't3', complexity: 2 }); await balancer.completeTask('t3'); + balancer.submitTask({ id: 't4', complexity: 2 }); await balancer.failTask('t4', 'error'); + expect(balancer._getSuccessRate(balancer.agents.get('agent-1'))).toBe(0.75); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // THROTTLE POLICIES - // ═══════════════════════════════════════════════════════════════════════════════ - describe('Throttle policies', () => { - describe('setThrottlePolicy', () => { - it('should accept valid policies', () => { - balancer.setThrottlePolicy(ThrottlePolicy.QUEUE_WHEN_FULL); - expect(balancer.throttlePolicy).toBe('queue-when-full'); - - balancer.setThrottlePolicy(ThrottlePolicy.REJECT_WHEN_FULL); - expect(balancer.throttlePolicy).toBe('reject-when-full'); - - balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); - expect(balancer.throttlePolicy).toBe('spillover'); - }); - - it('should throw on invalid policy', () => { - expect(() => balancer.setThrottlePolicy('invalid')).toThrow("Invalid throttle policy 'invalid'"); - }); - }); - - describe('queue-when-full', () => { - it('should queue tasks when agents are full', () => { - balancer.setThrottlePolicy(ThrottlePolicy.QUEUE_WHEN_FULL); - balancer.registerAgent('small', { maxLoad: 5 }); - - balancer.submitTask({ id: 'fill', complexity: 5 }); - const result = balancer.submitTask({ id: 'overflow', complexity: 5 }); - - expect(result.status).toBe(TaskStatus.QUEUED); - expect(balancer.queue).toContain('overflow'); - }); - - it('should reject when queue is full', () => { - balancer = new CognitiveLoadBalancer({ - projectRoot: tempDir, - persistMetrics: false, - maxQueueSize: 1, - }); - balancer.setThrottlePolicy(ThrottlePolicy.QUEUE_WHEN_FULL); - - // No agents, first task goes to queue - balancer.submitTask({ id: 'queue-1', complexity: 5 }); - // Second task should fail because queue is full - const handler = jest.fn(); - balancer.on('queue:full', handler); - - const result = balancer.submitTask({ id: 'queue-2', complexity: 5 }); - expect(result.status).toBe(TaskStatus.FAILED); - expect(handler).toHaveBeenCalled(); - }); + it('should accept valid policies', () => { + balancer.setThrottlePolicy(ThrottlePolicy.QUEUE_WHEN_FULL); + expect(balancer.throttlePolicy).toBe('queue-when-full'); }); - describe('reject-when-full', () => { - it('should reject tasks when no agent can accept', () => { - balancer.setThrottlePolicy(ThrottlePolicy.REJECT_WHEN_FULL); - balancer.registerAgent('tiny', { maxLoad: 3 }); - - balancer.submitTask({ id: 'fill', complexity: 3 }); - const result = balancer.submitTask({ id: 'rejected', complexity: 3 }); - - expect(result.status).toBe(TaskStatus.FAILED); - expect(balancer.metrics.totalRejected).toBeGreaterThan(0); - }); + it('should throw on invalid policy', () => { + expect(() => balancer.setThrottlePolicy('invalid')).toThrow("Invalid throttle policy 'invalid'"); }); - describe('spillover', () => { - it('should assign to least loaded agent even when over capacity', () => { - balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); - balancer.registerAgent('agent-1', { maxLoad: 5 }); - - balancer.submitTask({ id: 'fill', complexity: 5 }); - const result = balancer.submitTask({ id: 'spill', complexity: 5 }); - - expect(result.status).toBe(TaskStatus.ASSIGNED); - expect(result.assignedTo).toBe('agent-1'); - }); + it('should queue tasks when agents are full', () => { + balancer.registerAgent('small', { maxLoad: 5 }); + balancer.submitTask({ id: 'fill', complexity: 5 }); + const result = balancer.submitTask({ id: 'overflow', complexity: 5 }); + expect(result.status).toBe(TaskStatus.QUEUED); }); - }); - // ═══════════════════════════════════════════════════════════════════════════════ - // REBALANCING - // ═══════════════════════════════════════════════════════════════════════════════ - - describe('rebalance', () => { - it('should return empty movements when no overloaded agents', () => { - balancer.registerAgent('agent-1', { maxLoad: 100 }); - balancer.submitTask({ id: 'task-1', complexity: 5 }); - - const result = balancer.rebalance(); - expect(result.movements).toEqual([]); + it('should reject tasks under reject-when-full', () => { + balancer.setThrottlePolicy(ThrottlePolicy.REJECT_WHEN_FULL); + balancer.registerAgent('tiny', { maxLoad: 3 }); + balancer.submitTask({ id: 'fill', complexity: 3 }); + const result = balancer.submitTask({ id: 'rejected', complexity: 3 }); + expect(result.status).toBe(TaskStatus.FAILED); }); - it('should return empty movements when no underloaded agents', () => { - balancer.registerAgent('agent-1', { maxLoad: 10 }); + it('should spillover to overloaded agent', () => { balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); - - // Overload the agent - for (let i = 0; i < 10; i++) { - balancer.submitTask({ id: `heavy-${i}`, complexity: 5 }); - } - - const result = balancer.rebalance(); - expect(result.movements).toEqual([]); + balancer.registerAgent('agent-1', { maxLoad: 5 }); + balancer.submitTask({ id: 'fill', complexity: 5 }); + const result = balancer.submitTask({ id: 'spill', complexity: 5 }); + expect(result.status).toBe(TaskStatus.ASSIGNED); }); + }); + describe('rebalance', () => { it('should move tasks from overloaded to underloaded agents', () => { balancer.registerAgent('overloaded', { maxLoad: 10 }); balancer.registerAgent('idle', { maxLoad: 100 }); balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); - - // Fill overloaded agent for (let i = 0; i < 5; i++) { balancer.submitTask({ id: `task-${i}`, complexity: 3 }); balancer.assignTask(`task-${i}`, 'overloaded'); } - const result = balancer.rebalance(); expect(result.movements.length).toBeGreaterThan(0); expect(result.movements[0].from).toBe('overloaded'); - expect(result.movements[0].to).toBe('idle'); - }); - - it('should emit task:rebalanced events', () => { - const handler = jest.fn(); - balancer.on('task:rebalanced', handler); - - balancer.registerAgent('overloaded', { maxLoad: 10 }); - balancer.registerAgent('idle', { maxLoad: 100 }); - balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); - - for (let i = 0; i < 5; i++) { - balancer.submitTask({ id: `rb-${i}`, complexity: 3 }); - balancer.assignTask(`rb-${i}`, 'overloaded'); - } - - balancer.rebalance(); - expect(handler).toHaveBeenCalled(); - }); - - it('should increment totalRebalanced metric', () => { - balancer.registerAgent('over', { maxLoad: 10 }); - balancer.registerAgent('under', { maxLoad: 100 }); - balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); - - for (let i = 0; i < 5; i++) { - balancer.submitTask({ id: `rebal-${i}`, complexity: 3 }); - balancer.assignTask(`rebal-${i}`, 'over'); - } - - balancer.rebalance(); - expect(balancer.metrics.totalRebalanced).toBeGreaterThan(0); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // QUEUE MANAGEMENT - // ═══════════════════════════════════════════════════════════════════════════════ - describe('getQueue', () => { it('should return empty array initially', () => { expect(balancer.getQueue()).toEqual([]); }); - it('should return queued tasks with details', () => { - // No agents - tasks go to queue - balancer.submitTask({ id: 'q1', type: 'code', complexity: 5 }); - balancer.submitTask({ id: 'q2', type: 'test', complexity: 3 }); - - const queue = balancer.getQueue(); - expect(queue).toHaveLength(2); - expect(queue[0].id).toBe('q1'); - expect(queue[1].id).toBe('q2'); - expect(queue[0].status).toBe(TaskStatus.QUEUED); - }); - - it('should drain queue when agent becomes available', () => { - // Tasks go to queue first + it('should drain queue when agent registers', () => { balancer.submitTask({ id: 'wait-1', complexity: 5 }); expect(balancer.queue).toHaveLength(1); - - // Register agent - queue should process balancer.registerAgent('new-agent', { maxLoad: 100 }); - - // Submit another task that triggers queue processing - balancer.submitTask({ id: 'trigger', complexity: 3 }); - - // wait-1 may still be in queue since registerAgent doesn't auto-process - // But the trigger task should be assigned - const task = balancer.tasks.get('trigger'); - expect(task.assignedTo).toBe('new-agent'); + expect(balancer.queue).toHaveLength(0); + expect(balancer.tasks.get('wait-1').assignedTo).toBe('new-agent'); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // METRICS - // ═══════════════════════════════════════════════════════════════════════════════ - describe('getMetrics', () => { - it('should return comprehensive metrics snapshot', () => { + it('should return comprehensive metrics snapshot', async () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); balancer.submitTask({ id: 'task-1', complexity: 5 }); - balancer.completeTask('task-1'); - + await balancer.completeTask('task-1'); const metrics = balancer.getMetrics(); - expect(metrics.totalSubmitted).toBe(1); expect(metrics.totalCompleted).toBe(1); - expect(metrics.totalFailed).toBe(0); - expect(metrics.totalRejected).toBe(0); - expect(metrics.queueLength).toBe(0); expect(metrics.activeAgents).toBe(1); - expect(metrics.throughputPerMinute).toBeGreaterThanOrEqual(0); - expect(metrics.avgWaitTime).toBeGreaterThanOrEqual(0); - expect(metrics.uptime).toBeGreaterThanOrEqual(0); - expect(metrics.agentUtilization).toBeDefined(); - expect(metrics.agentUtilization['agent-1']).toBeDefined(); - }); - - it('should include per-agent utilization', () => { - balancer.registerAgent('agent-1', { maxLoad: 100 }); - balancer.submitTask({ id: 'task-1', complexity: 10 }); - - const metrics = balancer.getMetrics(); - const agentMetrics = metrics.agentUtilization['agent-1']; - - expect(agentMetrics.load).toBe(10); - expect(agentMetrics.activeTasks).toBe(1); - expect(agentMetrics.status).toBeDefined(); - }); - - it('should track agent success rate in metrics', () => { - balancer.registerAgent('agent-1', { maxLoad: 100 }); - balancer.submitTask({ id: 't1', complexity: 2 }); - balancer.completeTask('t1'); - balancer.submitTask({ id: 't2', complexity: 2 }); - balancer.failTask('t2', 'error'); - - const metrics = balancer.getMetrics(); - expect(metrics.agentUtilization['agent-1'].successRate).toBe(0.5); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // AGENT STATUS - // ═══════════════════════════════════════════════════════════════════════════════ - describe('Agent status transitions', () => { it('should start as available', () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); @@ -932,199 +487,105 @@ describe('CognitiveLoadBalancer', () => { it('should transition to busy when tasks assigned', () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); balancer.submitTask({ id: 'task-1', complexity: 5 }); - expect(balancer.agents.get('agent-1').status).toBe(AgentStatus.BUSY); }); it('should transition to overloaded at threshold', () => { balancer.registerAgent('agent-1', { maxLoad: 10 }); - balancer.submitTask({ id: 'task-1', complexity: 9 }); // 90% load > 85% threshold - + balancer.submitTask({ id: 'task-1', complexity: 9 }); expect(balancer.agents.get('agent-1').status).toBe(AgentStatus.OVERLOADED); }); - it('should transition back to available when tasks complete', () => { + it('should transition back to available when tasks complete', async () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); balancer.submitTask({ id: 'task-1', complexity: 5 }); - - expect(balancer.agents.get('agent-1').status).toBe(AgentStatus.BUSY); - - balancer.completeTask('task-1'); + await balancer.completeTask('task-1'); expect(balancer.agents.get('agent-1').status).toBe(AgentStatus.AVAILABLE); }); - it('should emit agent:overloaded event', () => { + it('should emit agent:overloaded only on status transition', () => { const handler = jest.fn(); balancer.on('agent:overloaded', handler); - balancer.registerAgent('agent-1', { maxLoad: 10 }); + balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); balancer.submitTask({ id: 'task-1', complexity: 9 }); - - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ agentId: 'agent-1' }) - ); + expect(handler).toHaveBeenCalledTimes(1); + balancer.submitTask({ id: 'task-2', complexity: 5 }); + expect(handler).toHaveBeenCalledTimes(1); // no repeat emission }); - it('should emit agent:available event when load drops', () => { + it('should emit agent:available only on transition from overloaded', async () => { const handler = jest.fn(); balancer.on('agent:available', handler); - - balancer.registerAgent('agent-1', { maxLoad: 100 }); - balancer.submitTask({ id: 'task-1', complexity: 90 }); - balancer.completeTask('task-1'); - - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ agentId: 'agent-1' }) - ); + balancer.registerAgent('agent-1', { maxLoad: 10 }); + balancer.setThrottlePolicy(ThrottlePolicy.SPILLOVER); + balancer.submitTask({ id: 'task-1', complexity: 9 }); + balancer.submitTask({ id: 'task-2', complexity: 5 }); + await balancer.completeTask('task-1'); + const callCount = handler.mock.calls.length; + expect(callCount).toBeGreaterThanOrEqual(1); + await balancer.completeTask('task-2'); + // Should NOT emit again since agent was already non-overloaded + expect(handler.mock.calls.length).toBe(callCount); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // PERSISTENCE - // ═══════════════════════════════════════════════════════════════════════════════ - describe('Metrics persistence', () => { it('should persist metrics to disk when enabled', async () => { - const persistBalancer = new CognitiveLoadBalancer({ - projectRoot: tempDir, - persistMetrics: true, - }); - - persistBalancer.registerAgent('agent-1', { maxLoad: 100 }); - persistBalancer.submitTask({ id: 'task-1', complexity: 5 }); - await persistBalancer.completeTask('task-1'); - - // Allow async file write to complete - await new Promise((resolve) => setTimeout(resolve, 100)); - + const b = new CognitiveLoadBalancer({ projectRoot: tempDir, persistMetrics: true }); + b.registerAgent('agent-1', { maxLoad: 100 }); + b.submitTask({ id: 'task-1', complexity: 5 }); + await b.completeTask('task-1'); const metricsPath = path.join(tempDir, '.aiox', 'load-balancer-metrics.json'); - const exists = fs.existsSync(metricsPath); - expect(exists).toBe(true); - - if (exists) { - const content = JSON.parse(fs.readFileSync(metricsPath, 'utf8')); - expect(content.totalCompleted).toBe(1); - } - - persistBalancer.removeAllListeners(); + expect(fs.existsSync(metricsPath)).toBe(true); + b.removeAllListeners(); }); - it('should not persist when persistMetrics is false', async () => { - balancer.submitTask({ id: 'no-persist', complexity: 5 }); - // Register agent so the task gets assigned then completed + it('should not persist when disabled', async () => { balancer.registerAgent('agent-1', { maxLoad: 100 }); balancer.submitTask({ id: 'assigned', complexity: 5 }); await balancer.completeTask('assigned'); - - await new Promise((resolve) => setTimeout(resolve, 50)); - const metricsPath = path.join(tempDir, '.aiox', 'load-balancer-metrics.json'); expect(fs.existsSync(metricsPath)).toBe(false); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // EXPORTS - // ═══════════════════════════════════════════════════════════════════════════════ - describe('Module exports', () => { it('should export CognitiveLoadBalancer as default and named', () => { - expect(CognitiveLoadBalancer).toBeDefined(); expect(CognitiveLoadBalancer.CognitiveLoadBalancer).toBe(CognitiveLoadBalancer); }); - - it('should export AgentStatus enum', () => { + it('should export enums', () => { expect(AgentStatus.AVAILABLE).toBe('available'); - expect(AgentStatus.BUSY).toBe('busy'); - expect(AgentStatus.OVERLOADED).toBe('overloaded'); - expect(AgentStatus.OFFLINE).toBe('offline'); - }); - - it('should export TaskStatus enum', () => { - expect(TaskStatus.QUEUED).toBe('queued'); - expect(TaskStatus.ASSIGNED).toBe('assigned'); expect(TaskStatus.COMPLETED).toBe('completed'); - expect(TaskStatus.FAILED).toBe('failed'); - }); - - it('should export TaskPriority enum', () => { - expect(TaskPriority.LOW).toBe('low'); - expect(TaskPriority.NORMAL).toBe('normal'); - expect(TaskPriority.HIGH).toBe('high'); expect(TaskPriority.CRITICAL).toBe('critical'); - }); - - it('should export ThrottlePolicy enum', () => { - expect(ThrottlePolicy.QUEUE_WHEN_FULL).toBe('queue-when-full'); - expect(ThrottlePolicy.REJECT_WHEN_FULL).toBe('reject-when-full'); expect(ThrottlePolicy.SPILLOVER).toBe('spillover'); }); - - it('should export AFFINITY_WEIGHTS', () => { - const total = AFFINITY_WEIGHTS.SPECIALTY + AFFINITY_WEIGHTS.LOAD_INVERSE + - AFFINITY_WEIGHTS.SPEED + AFFINITY_WEIGHTS.SUCCESS_RATE; + it('should export AFFINITY_WEIGHTS summing to 1.0', () => { + const total = AFFINITY_WEIGHTS.SPECIALTY + AFFINITY_WEIGHTS.LOAD_INVERSE + AFFINITY_WEIGHTS.SPEED + AFFINITY_WEIGHTS.SUCCESS_RATE; expect(total).toBeCloseTo(1.0); }); - - it('should export OVERLOAD_THRESHOLD', () => { - expect(OVERLOAD_THRESHOLD).toBe(85); - }); + it('should export OVERLOAD_THRESHOLD', () => { expect(OVERLOAD_THRESHOLD).toBe(85); }); }); - // ═══════════════════════════════════════════════════════════════════════════════ - // EDGE CASES - // ═══════════════════════════════════════════════════════════════════════════════ - describe('Edge cases', () => { - it('should handle submitting tasks with no agents registered', () => { + it('should handle submitting tasks with no agents', () => { const result = balancer.submitTask({ id: 'orphan', complexity: 5 }); expect(result.status).toBe(TaskStatus.QUEUED); }); - it('should handle multiple agents with same specialty', () => { - balancer.registerAgent('a1', { maxLoad: 100, specialties: ['js'] }); - balancer.registerAgent('a2', { maxLoad: 100, specialties: ['js'] }); - - const result = balancer.submitTask({ - id: 'js-task', - complexity: 5, - requiredSpecialties: ['js'], - }); - - expect(result.status).toBe(TaskStatus.ASSIGNED); - expect(['a1', 'a2']).toContain(result.assignedTo); - }); - - it('should handle task with no required specialties', () => { - balancer.registerAgent('a1', { maxLoad: 100, specialties: ['niche'] }); - - const result = balancer.submitTask({ id: 'generic', complexity: 5 }); - expect(result.status).toBe(TaskStatus.ASSIGNED); - }); - - it('should handle completing task that has no agent', () => { - // Create task that goes to queue (no agents) + it('should handle completing task with no agent', async () => { balancer.submitTask({ id: 'no-agent-task', complexity: 5 }); - - // Manually change status to simulate edge case const task = balancer.tasks.get('no-agent-task'); task.status = TaskStatus.ASSIGNED; - - const result = balancer.completeTask('no-agent-task'); + const result = await balancer.completeTask('no-agent-task'); expect(result.taskId).toBe('no-agent-task'); }); it('should handle rapid fire task submission', () => { balancer.registerAgent('worker', { maxLoad: 1000 }); - const results = []; - for (let i = 0; i < 100; i++) { - results.push(balancer.submitTask({ complexity: 1 })); - } - - const assigned = results.filter((r) => r.status === TaskStatus.ASSIGNED).length; - expect(assigned).toBe(100); - expect(balancer.metrics.totalSubmitted).toBe(100); + for (let i = 0; i < 100; i++) { results.push(balancer.submitTask({ complexity: 1 })); } + expect(results.filter(r => r.status === TaskStatus.ASSIGNED).length).toBe(100); }); }); }); From 708b376d879c02ba53377f0055e1e1395a0ff2d5 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Sun, 8 Mar 2026 03:19:52 -0300 Subject: [PATCH 5/5] =?UTF-8?q?fix(test):=20corrigir=20race=20condition=20?= =?UTF-8?q?no=20teste=20de=20persist=C3=AAncia=20do=20swarm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teste 'save and load state from disk' agora aguarda _pendingSave antes de chamar _saveToDisk(), evitando escrita concorrente que corrompia JSON (SyntaxError: Unexpected end of JSON input no CI) --- tests/core/orchestration/swarm-intelligence.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/core/orchestration/swarm-intelligence.test.js b/tests/core/orchestration/swarm-intelligence.test.js index 6cf00792b..a171ae6e8 100644 --- a/tests/core/orchestration/swarm-intelligence.test.js +++ b/tests/core/orchestration/swarm-intelligence.test.js @@ -950,7 +950,8 @@ describe('SwarmIntelligence', () => { content: 'works!', }); - // Wait for async persistence + // Wait for all pending async writes, then do a final save + if (persisted._pendingSave) await persisted._pendingSave; await persisted._saveToDisk(); // Load into fresh instance