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..c8021088c --- /dev/null +++ b/.aios-core/core/orchestration/cognitive-load-balancer.js @@ -0,0 +1,987 @@ +/** + * 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 + * @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'); + } + + // 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; + } + + /** + * 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 + * @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') { + throw new Error('Task must be a non-null object'); + } + + 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++; + + 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 + * @throws {Error} If task is already completed or failed + */ + assignTask(taskId, agentId) { + const task = this.tasks.get(taskId); + if (!task) { + 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`); + } + + // 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 + * @throws {Error} If task is already completed or failed + */ + 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; + + 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(); + + // Await metrics persistence instead of fire-and-forget + await 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 + * @throws {Error} If task is already completed or failed + */ + 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(); + 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(); + + // Await metrics persistence instead of fire-and-forget + await 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) { + // Save previous status for transition-only events + const previousStatus = agent.status; + + 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 }); + + // 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 }); + } + } + + /** + * Remove a task from an agent's active list + * @param {Object} agent - Agent profile + * @param {string} taskId - Task to remove + * @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); + } + + const task = this.tasks.get(taskId); + if (task) { + agent.currentLoad = Math.max(0, agent.currentLoad - task.complexity); + } + + this._updateAgentStatus(agent); + + // 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 }); + } + } + + /** + * 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 (err) { + // Log persistence errors with context instead of silently ignoring + console.error(`Failed to persist load balancer metrics: ${err.message}`); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// 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/.aios-core/core/orchestration/swarm-intelligence.js b/.aios-core/core/orchestration/swarm-intelligence.js new file mode 100644 index 000000000..45e0618c4 --- /dev/null +++ b/.aios-core/core/orchestration/swarm-intelligence.js @@ -0,0 +1,2 @@ +// 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/cognitive-load-balancer.js b/.aiox-core/core/orchestration/cognitive-load-balancer.js new file mode 100644 index 000000000..c8021088c --- /dev/null +++ b/.aiox-core/core/orchestration/cognitive-load-balancer.js @@ -0,0 +1,987 @@ +/** + * 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 + * @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'); + } + + // 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; + } + + /** + * 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 + * @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') { + throw new Error('Task must be a non-null object'); + } + + 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++; + + 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 + * @throws {Error} If task is already completed or failed + */ + assignTask(taskId, agentId) { + const task = this.tasks.get(taskId); + if (!task) { + 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`); + } + + // 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 + * @throws {Error} If task is already completed or failed + */ + 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; + + 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(); + + // Await metrics persistence instead of fire-and-forget + await 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 + * @throws {Error} If task is already completed or failed + */ + 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(); + 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(); + + // Await metrics persistence instead of fire-and-forget + await 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) { + // Save previous status for transition-only events + const previousStatus = agent.status; + + 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 }); + + // 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 }); + } + } + + /** + * Remove a task from an agent's active list + * @param {Object} agent - Agent profile + * @param {string} taskId - Task to remove + * @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); + } + + const task = this.tasks.get(taskId); + if (task) { + agent.currentLoad = Math.max(0, agent.currentLoad - task.complexity); + } + + this._updateAgentStatus(agent); + + // 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 }); + } + } + + /** + * 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 (err) { + // Log persistence errors with context instead of silently ignoring + console.error(`Failed to persist load balancer metrics: ${err.message}`); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// 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/swarm-intelligence.js b/.aiox-core/core/orchestration/swarm-intelligence.js new file mode 100644 index 000000000..e4f18013e --- /dev/null +++ b/.aiox-core/core/orchestration/swarm-intelligence.js @@ -0,0 +1,1038 @@ +/** + * 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})`); + } + + // 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; + 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); + // Abstains and rejects don't count toward quorum + const hasQuorum = base.approveCount >= 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++; + } + } + + // Persist citation bumps + this._persistAsync(); + + 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; + + // Serialize writes to prevent concurrent fs operations + this._pendingSave = (this._pendingSave || Promise.resolve()) + .then(() => 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 (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; + } + } + + /** + * 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/data/entity-registry.yaml b/.aiox-core/data/entity-registry.yaml index d76133691..8f4764332 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:54.622Z' + 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: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 2516e4c29..f2380d23d 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:23:25.890Z" generator: scripts/generate-install-manifest.js -file_count: 1089 +file_count: 1091 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:b3c3c865f2751cb3b39807c782cad21268900c558b7c9dc8a3607108699ff2bc + type: core + size: 35031 - path: core/orchestration/condition-evaluator.js hash: sha256:8bf565cf56194340ff4e1d642647150775277bce649411d0338faa2c96106745 type: core @@ -932,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 @@ -1221,9 +1229,9 @@ files: type: data size: 9575 - path: data/entity-registry.yaml - hash: sha256:151aca46769c614f0238e7a8e2968884c3888b438ff1c634f0eeb2505e325b83 + hash: sha256:eceaba9d1de06b854c8dd7b0229c3b2b99c6dac0938d9c841a042e45e214b2f2 type: data - size: 521804 + size: 522285 - path: data/learned-patterns.yaml hash: sha256:24ac0b160615583a0ff783d3da8af80b7f94191575d6db2054ec8e10a3f945dc type: data @@ -2583,19 +2591,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 +2611,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 +2623,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 +2631,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 +3375,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 +3391,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 +3403,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 +3515,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 +3699,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 +3751,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 +3815,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 +3923,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 +3931,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 +3983,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 +3995,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..7caacf67d --- /dev/null +++ b/tests/core/orchestration/cognitive-load-balancer.test.js @@ -0,0 +1,591 @@ +/** + * Cognitive Load Balancer Tests + * Story ORCH-6 - Intelligent task distribution based on agent cognitive capacity + * @version 1.1.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 */ } + }); + + 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', () => { + 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); + }); + }); + + 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.status).toBe(AgentStatus.AVAILABLE); + }); + + it('should register agent with custom profile', () => { + const profile = balancer.registerAgent('agent-2', { maxLoad: 50, specialties: ['frontend'], processingSpeed: 1.5 }); + expect(profile.maxLoad).toBe(50); + 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); + }); + + 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 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); + }); + + 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(); + }); + + 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 }); + if (balancer.tasks.get('task-1').assignedTo !== 'agent-1') { + balancer.assignTask('task-1', 'agent-1'); + } + balancer.unregisterAgent('agent-1'); + expect(balancer.tasks.get('task-1').assignedTo).toBe('agent-2'); + }); + }); + + 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); + }); + + it('should auto-generate task ID when omitted', () => { + const result = balancer.submitTask({ 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 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); + }); + + 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 increment totalSubmitted metric', () => { + balancer.submitTask({ complexity: 3 }); + balancer.submitTask({ complexity: 3 }); + expect(balancer.metrics.totalSubmitted).toBe(2); + }); + }); + + 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'); + }); + + 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 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"); + }); + + 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'); + 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'); + }); + }); + + describe('completeTask', () => { + beforeEach(() => { balancer.registerAgent('agent-1', { maxLoad: 100 }); }); + + it('should mark task as completed', async () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + 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' }); + }); + + it('should free agent capacity', async () => { + balancer.submitTask({ id: 'task-1', complexity: 8 }); + expect(balancer.agents.get('agent-1').currentLoad).toBe(8); + await balancer.completeTask('task-1'); + expect(balancer.agents.get('agent-1').currentLoad).toBe(0); + }); + + it('should emit task:completed event', async () => { + const handler = jest.fn(); + balancer.on('task:completed', handler); + balancer.submitTask({ id: 'task-1', complexity: 5 }); + await balancer.completeTask('task-1', 'result-data'); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'task-1', result: 'result-data' })); + }); + + it('should throw on unknown task', async () => { + await expect(balancer.completeTask('unknown')).rejects.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 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', async () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + const result = await balancer.completeTask('task-1'); + expect(result.taskId).toBe('task-1'); + expect(result.completionTime).toBeGreaterThanOrEqual(0); + }); + }); + + describe('failTask', () => { + beforeEach(() => { balancer.registerAgent('agent-1', { maxLoad: 100 }); }); + + it('should mark task as failed', async () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + 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', async () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + await balancer.failTask('task-1', new Error('Detailed error')); + expect(balancer.tasks.get('task-1').error).toBe('Detailed error'); + }); + + it('should free agent capacity', async () => { + balancer.submitTask({ id: 'task-1', complexity: 7 }); + await balancer.failTask('task-1', 'fail'); + expect(balancer.agents.get('agent-1').currentLoad).toBe(0); + }); + + it('should throw on unknown task', async () => { + await expect(balancer.failTask('unknown')).rejects.toThrow("Task 'unknown' not found"); + }); + + it('should throw when failing an already failed task', async () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + await balancer.failTask('task-1', 'first'); + await expect(balancer.failTask('task-1', 'second')).rejects.toThrow("Task 'task-1' is already failed"); + }); + + 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', async () => { + balancer.submitTask({ id: 'task-1', complexity: 5 }); + await balancer.failTask('task-1'); + expect(balancer.tasks.get('task-1').error).toBe('Unknown error'); + }); + }); + + 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 }); + 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 throw on unknown agent', () => { + expect(() => balancer.getAgentLoad('unknown')).toThrow("Agent 'unknown' not found"); + }); + }); + + describe('getOptimalAgent', () => { + it('should return null when no agents registered', () => { + expect(balancer.getOptimalAgent({ complexity: 5 })).toBeNull(); + }); + + it('should prefer agent with matching specialties', () => { + balancer.registerAgent('generalist', { maxLoad: 100 }); + balancer.registerAgent('specialist', { maxLoad: 100, specialties: ['testing'] }); + const result = balancer.getOptimalAgent({ complexity: 5, requiredSpecialties: ['testing'] }); + expect(result.agentId).toBe('specialist'); + }); + }); + + 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); }); + + it('should calculate correct success rate with history', async () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + 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); + }); + }); + + describe('Throttle policies', () => { + it('should accept valid policies', () => { + balancer.setThrottlePolicy(ThrottlePolicy.QUEUE_WHEN_FULL); + expect(balancer.throttlePolicy).toBe('queue-when-full'); + }); + + it('should throw on invalid policy', () => { + expect(() => balancer.setThrottlePolicy('invalid')).toThrow("Invalid throttle policy 'invalid'"); + }); + + 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); + }); + + 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 spillover to overloaded agent', () => { + 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); + }); + }); + + 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); + 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'); + }); + }); + + describe('getQueue', () => { + it('should return empty array initially', () => { + expect(balancer.getQueue()).toEqual([]); + }); + + it('should drain queue when agent registers', () => { + balancer.submitTask({ id: 'wait-1', complexity: 5 }); + expect(balancer.queue).toHaveLength(1); + balancer.registerAgent('new-agent', { maxLoad: 100 }); + expect(balancer.queue).toHaveLength(0); + expect(balancer.tasks.get('wait-1').assignedTo).toBe('new-agent'); + }); + }); + + describe('getMetrics', () => { + it('should return comprehensive metrics snapshot', async () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 5 }); + await balancer.completeTask('task-1'); + const metrics = balancer.getMetrics(); + expect(metrics.totalSubmitted).toBe(1); + expect(metrics.totalCompleted).toBe(1); + expect(metrics.activeAgents).toBe(1); + }); + }); + + 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 }); + expect(balancer.agents.get('agent-1').status).toBe(AgentStatus.OVERLOADED); + }); + + it('should transition back to available when tasks complete', async () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'task-1', complexity: 5 }); + await balancer.completeTask('task-1'); + expect(balancer.agents.get('agent-1').status).toBe(AgentStatus.AVAILABLE); + }); + + 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).toHaveBeenCalledTimes(1); + balancer.submitTask({ id: 'task-2', complexity: 5 }); + expect(handler).toHaveBeenCalledTimes(1); // no repeat emission + }); + + 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: 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); + }); + }); + + describe('Metrics persistence', () => { + it('should persist metrics to disk when enabled', async () => { + 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'); + expect(fs.existsSync(metricsPath)).toBe(true); + b.removeAllListeners(); + }); + + it('should not persist when disabled', async () => { + balancer.registerAgent('agent-1', { maxLoad: 100 }); + balancer.submitTask({ id: 'assigned', complexity: 5 }); + await balancer.completeTask('assigned'); + const metricsPath = path.join(tempDir, '.aiox', 'load-balancer-metrics.json'); + expect(fs.existsSync(metricsPath)).toBe(false); + }); + }); + + describe('Module exports', () => { + it('should export CognitiveLoadBalancer as default and named', () => { + expect(CognitiveLoadBalancer.CognitiveLoadBalancer).toBe(CognitiveLoadBalancer); + }); + it('should export enums', () => { + expect(AgentStatus.AVAILABLE).toBe('available'); + expect(TaskStatus.COMPLETED).toBe('completed'); + expect(TaskPriority.CRITICAL).toBe('critical'); + expect(ThrottlePolicy.SPILLOVER).toBe('spillover'); + }); + 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); }); + }); + + describe('Edge cases', () => { + 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 completing task with no agent', async () => { + balancer.submitTask({ id: 'no-agent-task', complexity: 5 }); + const task = balancer.tasks.get('no-agent-task'); + task.status = TaskStatus.ASSIGNED; + 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 })); } + expect(results.filter(r => r.status === TaskStatus.ASSIGNED).length).toBe(100); + }); + }); +}); diff --git a/tests/core/orchestration/swarm-intelligence.test.js b/tests/core/orchestration/swarm-intelligence.test.js new file mode 100644 index 000000000..a171ae6e8 --- /dev/null +++ b/tests/core/orchestration/swarm-intelligence.test.js @@ -0,0 +1,1030 @@ +/** + * 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 - 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(); + 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 all pending async writes, then do a final save + if (persisted._pendingSave) await persisted._pendingSave; + 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); + }); + + 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); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // 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(); + }); + }); +});