From f9432689e17a4740a46fa0a5dd0852f95b24b990 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Tue, 10 Mar 2026 11:29:33 -0300 Subject: [PATCH] =?UTF-8?q?feat(resilience):=20Agent=20Immortality=20Proto?= =?UTF-8?q?col=20=E2=80=94=20sistema=20de=20self-healing=20para=20agentes?= =?UTF-8?q?=20(#568)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa o protocolo completo de imortalidade de agentes com heartbeat, snapshots, deteccao de crash, auto-revival, fingerprint comportamental, protecao contra cascata e health score composto. 126 testes unitarios. --- .../core/resilience/agent-immortality.js | 2 + .../core/resilience/agent-immortality.js | 1021 +++++++++++++ .aiox-core/install-manifest.yaml | 110 +- .../core/resilience/agent-immortality.test.js | 1335 +++++++++++++++++ 4 files changed, 2413 insertions(+), 55 deletions(-) create mode 100644 .aios-core/core/resilience/agent-immortality.js create mode 100644 .aiox-core/core/resilience/agent-immortality.js create mode 100644 tests/core/resilience/agent-immortality.test.js diff --git a/.aios-core/core/resilience/agent-immortality.js b/.aios-core/core/resilience/agent-immortality.js new file mode 100644 index 000000000..a8b2bb962 --- /dev/null +++ b/.aios-core/core/resilience/agent-immortality.js @@ -0,0 +1,2 @@ +// Retrocompat wrapper — canonical source: .aiox-core/core/resilience/agent-immortality.js +module.exports = require('../../../.aiox-core/core/resilience/agent-immortality'); diff --git a/.aiox-core/core/resilience/agent-immortality.js b/.aiox-core/core/resilience/agent-immortality.js new file mode 100644 index 000000000..611333a36 --- /dev/null +++ b/.aiox-core/core/resilience/agent-immortality.js @@ -0,0 +1,1021 @@ +/** + * Agent Immortality Protocol + * + * Story: 568 - Agent Immortality Protocol + * Epic: Resilience — agentes que nunca morrem + * + * Sistema completo de self-healing e recuperacao de estado. + * Quando um agente crasha, reconstrui automaticamente seu estado + * a partir de checkpoints persistidos, snapshots de memoria e + * fingerprints comportamentais. + * + * Features: + * - Heartbeat Monitor com intervalos configuraveis + * - State Snapshots periodicos e persistidos em disco + * - Crash Detection via heartbeats perdidos + * - Auto-Revival a partir do ultimo snapshot + * - Behavioral Fingerprint com deteccao de anomalias pre-crash + * - Grace Period antes de declarar agente morto + * - Revival History com analise de causa + * - Health Score composto (0-100) + * - Cascade Protection contra falhas em cadeia + * - Persistent State com serializacao de escrita + * + * @module aiox-core/resilience/agent-immortality + * @version 1.0.0 + */ + +const fs = require('fs'); +const path = require('path'); +const EventEmitter = require('events'); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONSTANTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +const DEFAULT_CONFIG = { + heartbeatIntervalMs: 5000, + gracePeriodMs: 15000, + snapshotIntervalMs: 30000, + maxSnapshots: 10, + maxRevivals: 5, + revivalWindowMs: 300000, // 5 minutos + fingerprintWindowSize: 50, + anomalyThreshold: 2.0, // desvios padrao + healthWarningThreshold: 50, + snapshotDir: '.aiox/immortality', + stateFile: '.aiox/immortality/protocol-state.json', + schemaVersion: 'aiox-immortality-v1', +}; + +const Events = { + HEARTBEAT: 'heartbeat', + SNAPSHOT: 'snapshot', + DEATH_DETECTED: 'death-detected', + REVIVAL_STARTED: 'revival-started', + REVIVAL_COMPLETE: 'revival-complete', + ANOMALY_DETECTED: 'anomaly-detected', + CASCADE_RISK: 'cascade-risk', + HEALTH_WARNING: 'health-warning', +}; + +const AgentStatus = { + REGISTERED: 'registered', + ALIVE: 'alive', + SUSPECT: 'suspect', + DEAD: 'dead', + REVIVING: 'reviving', +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════════════ + +/** + * Deep clone seguro — structuredClone com fallback JSON + * @param {*} obj + * @returns {*} + */ +function deepClone(obj) { + try { + return structuredClone(obj); + } catch { + return JSON.parse(JSON.stringify(obj)); + } +} + +/** + * Gera ID unico + * @returns {string} + */ +function generateId() { + const ts = Date.now().toString(36); + const rand = Math.random().toString(36).substring(2, 8); + return `imm-${ts}-${rand}`; +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// AGENT IMMORTALITY PROTOCOL +// ═══════════════════════════════════════════════════════════════════════════════════ + +class AgentImmortalityProtocol extends EventEmitter { + /** + * @param {string} projectRoot - Diretorio raiz do projeto + * @param {Object} [options] - Opcoes de configuracao + * @param {number} [options.heartbeatIntervalMs] - Intervalo do heartbeat em ms + * @param {number} [options.gracePeriodMs] - Periodo de graca antes de declarar morte + * @param {number} [options.snapshotIntervalMs] - Intervalo entre snapshots + * @param {number} [options.maxSnapshots] - Numero maximo de snapshots por agente + * @param {number} [options.maxRevivals] - Maximo de revivals por janela de tempo + * @param {number} [options.revivalWindowMs] - Janela de tempo para contagem de revivals + * @param {number} [options.fingerprintWindowSize] - Tamanho da janela de fingerprint + * @param {number} [options.anomalyThreshold] - Limiar de anomalia em desvios padrao + * @param {number} [options.healthWarningThreshold] - Limiar de alerta de saude (0-100) + * @param {string} [options.snapshotDir] - Diretorio de snapshots + * @param {string} [options.stateFile] - Arquivo de estado do protocolo + */ + constructor(projectRoot, options = {}) { + super(); + this.projectRoot = projectRoot ?? process.cwd(); + this.config = { ...DEFAULT_CONFIG, ...options }; + + /** @type {Map} */ + this.agents = new Map(); + + /** @type {Map} */ + this._heartbeatCheckers = new Map(); + + /** @type {Map} */ + this._snapshotTimers = new Map(); + + /** @type {Map} */ + this._dependencies = new Map(); + + /** @type {Promise} */ + this._saveQueue = Promise.resolve(); + } + + // ═════════════════════════════════════════════════════════════════════════════ + // AGENT REGISTRATION + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Registra um agente no protocolo + * @param {string} agentId - Identificador unico do agente + * @param {Object} [config] - Configuracao especifica do agente + * @param {number} [config.heartbeatIntervalMs] - Override do intervalo de heartbeat + * @param {number} [config.gracePeriodMs] - Override do grace period + * @param {Function} [config.revivalFn] - Funcao customizada de revival + * @returns {Object} Dados do agente registrado + */ + registerAgent(agentId, config = {}) { + if (!agentId || typeof agentId !== 'string') { + throw new Error('agentId is required and must be a string'); + } + + if (this.agents.has(agentId)) { + throw new Error(`Agent "${agentId}" is already registered`); + } + + const agent = { + id: agentId, + status: AgentStatus.REGISTERED, + config: { ...this.config, ...config }, + heartbeats: [], + snapshots: [], + revivalHistory: [], + fingerprint: { + metrics: [], + baseline: null, + }, + registeredAt: Date.now(), + lastHeartbeat: null, + lastSnapshot: null, + errorCount: 0, + }; + + this.agents.set(agentId, agent); + return deepClone(agent); + } + + /** + * Remove um agente do protocolo + * @param {string} agentId - Identificador do agente + */ + unregisterAgent(agentId) { + this._assertAgentExists(agentId); + this.stopMonitoring(agentId); + this._dependencies.delete(agentId); + + // Limpa dependencias que apontam para este agente + for (const [id, deps] of this._dependencies.entries()) { + const filtered = deps.filter(d => d !== agentId); + if (filtered.length > 0) { + this._dependencies.set(id, filtered); + } else { + this._dependencies.delete(id); + } + } + + this.agents.delete(agentId); + } + + // ═════════════════════════════════════════════════════════════════════════════ + // MONITORING + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Inicia monitoramento de um agente + * @param {string} agentId - Identificador do agente + */ + startMonitoring(agentId) { + this._assertAgentExists(agentId); + const agent = this.agents.get(agentId); + + if (this._heartbeatCheckers.has(agentId)) { + return; // Ja monitorando + } + + agent.status = AgentStatus.ALIVE; + agent.lastHeartbeat = Date.now(); + + // Checker de heartbeat — verifica se o agente ainda pulsa + const interval = agent.config.heartbeatIntervalMs ?? this.config.heartbeatIntervalMs; + const checker = setInterval(() => { + this._checkHeartbeat(agentId); + }, interval); + + this._heartbeatCheckers.set(agentId, checker); + + // Timer de snapshot automatico + const snapInterval = agent.config.snapshotIntervalMs ?? this.config.snapshotIntervalMs; + const snapTimer = setInterval(() => { + if (agent.status === AgentStatus.ALIVE && agent.heartbeats.length > 0) { + const lastHb = agent.heartbeats[agent.heartbeats.length - 1]; + if (lastHb?.stateData) { + this.createSnapshot(agentId, lastHb.stateData); + } + } + }, snapInterval); + + this._snapshotTimers.set(agentId, snapTimer); + } + + /** + * Para monitoramento de um agente + * @param {string} agentId - Identificador do agente + */ + stopMonitoring(agentId) { + const checker = this._heartbeatCheckers.get(agentId); + if (checker) { + clearInterval(checker); + this._heartbeatCheckers.delete(agentId); + } + + const snapTimer = this._snapshotTimers.get(agentId); + if (snapTimer) { + clearInterval(snapTimer); + this._snapshotTimers.delete(agentId); + } + + const agent = this.agents.get(agentId); + if (agent && agent.status === AgentStatus.ALIVE) { + agent.status = AgentStatus.REGISTERED; + } + } + + // ═════════════════════════════════════════════════════════════════════════════ + // HEARTBEAT + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Registra heartbeat de um agente + * @param {string} agentId - Identificador do agente + * @param {Object} [stateData] - Dados de estado opcionais + * @returns {Object} Registro do heartbeat + */ + heartbeat(agentId, stateData = null) { + this._assertAgentExists(agentId); + const agent = this.agents.get(agentId); + const now = Date.now(); + + const record = { + id: generateId(), + timestamp: now, + stateData: stateData ? deepClone(stateData) : null, + interval: agent.lastHeartbeat ? now - agent.lastHeartbeat : 0, + }; + + agent.heartbeats.push(record); + agent.lastHeartbeat = now; + + // Manter janela fixa de heartbeats + const windowSize = agent.config.fingerprintWindowSize ?? this.config.fingerprintWindowSize; + if (agent.heartbeats.length > windowSize * 2) { + agent.heartbeats = agent.heartbeats.slice(-windowSize); + } + + // Atualizar fingerprint com metrica do intervalo + if (record.interval > 0) { + this._updateFingerprint(agentId, record.interval); + } + + // Se estava morto ou suspeito, restaurar para vivo + if (agent.status === AgentStatus.DEAD || agent.status === AgentStatus.SUSPECT) { + agent.status = AgentStatus.ALIVE; + } + + this.emit(Events.HEARTBEAT, { agentId, record: deepClone(record) }); + return deepClone(record); + } + + /** + * Retorna o ultimo heartbeat de um agente + * @param {string} agentId - Identificador do agente + * @returns {Object|null} Ultimo heartbeat ou null + */ + getLastHeartbeat(agentId) { + this._assertAgentExists(agentId); + const agent = this.agents.get(agentId); + if (agent.heartbeats.length === 0) return null; + return deepClone(agent.heartbeats[agent.heartbeats.length - 1]); + } + + // ═════════════════════════════════════════════════════════════════════════════ + // SNAPSHOTS + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Cria snapshot do estado do agente + * @param {string} agentId - Identificador do agente + * @param {Object} state - Estado a ser salvo + * @returns {Object} Metadados do snapshot + */ + createSnapshot(agentId, state) { + this._assertAgentExists(agentId); + if (!state || typeof state !== 'object') { + throw new Error('state must be a non-null object'); + } + + const agent = this.agents.get(agentId); + const snapshot = { + id: generateId(), + agentId, + state: deepClone(state), + timestamp: Date.now(), + healthScore: this._calculateHealthScore(agent), + }; + + agent.snapshots.push(snapshot); + agent.lastSnapshot = snapshot.timestamp; + + // Limitar numero de snapshots + const maxSnaps = agent.config.maxSnapshots ?? this.config.maxSnapshots; + if (agent.snapshots.length > maxSnaps) { + agent.snapshots = agent.snapshots.slice(-maxSnaps); + } + + // Persistir em disco (serializado) + this._persistSnapshot(agentId, snapshot); + + this.emit(Events.SNAPSHOT, { agentId, snapshot: deepClone(snapshot) }); + return { id: snapshot.id, agentId, timestamp: snapshot.timestamp, healthScore: snapshot.healthScore }; + } + + /** + * Retorna o snapshot mais recente de um agente + * @param {string} agentId - Identificador do agente + * @returns {Object|null} Snapshot mais recente ou null + */ + getLatestSnapshot(agentId) { + this._assertAgentExists(agentId); + const agent = this.agents.get(agentId); + if (agent.snapshots.length === 0) return null; + return deepClone(agent.snapshots[agent.snapshots.length - 1]); + } + + /** + * Lista snapshots de um agente + * @param {string} agentId - Identificador do agente + * @param {Object} [opts] - Opcoes de listagem + * @param {number} [opts.limit] - Limite de resultados + * @param {number} [opts.since] - Timestamp minimo + * @returns {Object[]} Lista de snapshots + */ + listSnapshots(agentId, opts = {}) { + this._assertAgentExists(agentId); + const agent = this.agents.get(agentId); + let snaps = [...agent.snapshots]; + + if (opts.since) { + snaps = snaps.filter(s => s.timestamp >= opts.since); + } + + if (opts.limit) { + snaps = snaps.slice(-opts.limit); + } + + return snaps.map(s => deepClone(s)); + } + + // ═════════════════════════════════════════════════════════════════════════════ + // REVIVAL + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Revive um agente morto a partir do ultimo snapshot + * @param {string} agentId - Identificador do agente + * @returns {Object} Resultado do revival + */ + async reviveAgent(agentId) { + this._assertAgentExists(agentId); + const agent = this.agents.get(agentId); + + // Verificar limite de revivals na janela + const windowMs = agent.config.revivalWindowMs ?? this.config.revivalWindowMs; + const maxRevivals = agent.config.maxRevivals ?? this.config.maxRevivals; + const now = Date.now(); + const recentRevivals = agent.revivalHistory.filter( + r => (now - r.timestamp) < windowMs + ); + + if (recentRevivals.length >= maxRevivals) { + const result = { + success: false, + agentId, + reason: 'max-revivals-exceeded', + message: `Agent "${agentId}" exceeded max revivals (${maxRevivals}) in time window`, + timestamp: now, + }; + return result; + } + + this.emit(Events.REVIVAL_STARTED, { agentId, timestamp: now }); + agent.status = AgentStatus.REVIVING; + + const latestSnapshot = agent.snapshots.length > 0 + ? agent.snapshots[agent.snapshots.length - 1] + : null; + + // Tentar revival customizado ou usar snapshot + let revivalState = null; + let revivalMethod = 'snapshot'; + + if (typeof agent.config.revivalFn === 'function') { + try { + revivalState = await agent.config.revivalFn(agentId, latestSnapshot); + revivalMethod = 'custom'; + } catch { + // Fallback para snapshot se funcao customizada falhar + revivalState = latestSnapshot?.state ?? null; + revivalMethod = 'snapshot-fallback'; + } + } else { + revivalState = latestSnapshot?.state ?? null; + } + + const revivalRecord = { + id: generateId(), + agentId, + timestamp: now, + method: revivalMethod, + snapshotId: latestSnapshot?.id ?? null, + previousStatus: AgentStatus.DEAD, + restoredState: revivalState !== null, + errorCount: agent.errorCount, + }; + + agent.revivalHistory.push(revivalRecord); + agent.status = AgentStatus.ALIVE; + agent.lastHeartbeat = now; + agent.errorCount = 0; + + this.emit(Events.REVIVAL_COMPLETE, { + agentId, + record: deepClone(revivalRecord), + state: revivalState ? deepClone(revivalState) : null, + }); + + return { + success: true, + agentId, + method: revivalMethod, + snapshotId: latestSnapshot?.id ?? null, + state: revivalState ? deepClone(revivalState) : null, + timestamp: now, + }; + } + + /** + * Retorna historico de revivals de um agente + * @param {string} agentId - Identificador do agente + * @returns {Object[]} Historico de revivals + */ + getRevivalHistory(agentId) { + this._assertAgentExists(agentId); + const agent = this.agents.get(agentId); + return agent.revivalHistory.map(r => deepClone(r)); + } + + // ═════════════════════════════════════════════════════════════════════════════ + // HEALTH SCORE + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Calcula e retorna o health score de um agente (0-100) + * @param {string} agentId - Identificador do agente + * @returns {number} Health score entre 0 e 100 + */ + getHealthScore(agentId) { + this._assertAgentExists(agentId); + const agent = this.agents.get(agentId); + return this._calculateHealthScore(agent); + } + + // ═════════════════════════════════════════════════════════════════════════════ + // BEHAVIORAL FINGERPRINT + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Retorna a fingerprint comportamental de um agente + * @param {string} agentId - Identificador do agente + * @returns {Object} Fingerprint com metricas e baseline + */ + getBehavioralFingerprint(agentId) { + this._assertAgentExists(agentId); + const agent = this.agents.get(agentId); + return deepClone(agent.fingerprint); + } + + /** + * Detecta anomalias no comportamento de um agente + * @param {string} agentId - Identificador do agente + * @returns {Object} Resultado da deteccao de anomalias + */ + detectAnomalies(agentId) { + this._assertAgentExists(agentId); + const agent = this.agents.get(agentId); + const fp = agent.fingerprint; + + if (!fp.baseline || fp.metrics.length < 3) { + return { hasAnomalies: false, anomalies: [], message: 'Insufficient data for anomaly detection' }; + } + + const anomalies = []; + const threshold = agent.config.anomalyThreshold ?? this.config.anomalyThreshold; + + // Verificar intervalos de heartbeat + const recentMetrics = fp.metrics.slice(-5); + for (const metric of recentMetrics) { + const deviation = Math.abs(metric - fp.baseline.mean) / (fp.baseline.stdDev || 1); + if (deviation > threshold) { + anomalies.push({ + type: 'heartbeat-interval', + value: metric, + expected: fp.baseline.mean, + deviation: Math.round(deviation * 100) / 100, + severity: deviation > threshold * 2 ? 'critical' : 'warning', + }); + } + } + + // Verificar tendencia de degradacao + if (recentMetrics.length >= 3) { + const trend = this._calculateTrend(recentMetrics); + if (trend > 0.5) { + anomalies.push({ + type: 'degradation-trend', + value: Math.round(trend * 100) / 100, + message: 'Heartbeat intervals increasing steadily — potential degradation', + severity: trend > 1.0 ? 'critical' : 'warning', + }); + } + } + + const result = { + hasAnomalies: anomalies.length > 0, + anomalies, + message: anomalies.length > 0 + ? `${anomalies.length} anomaly(ies) detected for agent "${agentId}"` + : 'No anomalies detected', + }; + + if (anomalies.length > 0) { + this.emit(Events.ANOMALY_DETECTED, { agentId, ...result }); + } + + return result; + } + + // ═════════════════════════════════════════════════════════════════════════════ + // CASCADE PROTECTION + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Declara dependencia entre agentes + * @param {string} agentId - Agente dependente + * @param {string} dependsOnId - Agente do qual depende + */ + declareDependency(agentId, dependsOnId) { + this._assertAgentExists(agentId); + this._assertAgentExists(dependsOnId); + + if (agentId === dependsOnId) { + throw new Error('An agent cannot depend on itself'); + } + + const deps = this._dependencies.get(agentId) ?? []; + if (!deps.includes(dependsOnId)) { + deps.push(dependsOnId); + this._dependencies.set(agentId, deps); + } + } + + /** + * Calcula o risco de cascata para um agente + * @param {string} agentId - Identificador do agente + * @returns {Object} Analise de risco de cascata + */ + getCascadeRisk(agentId) { + this._assertAgentExists(agentId); + + // Encontrar todos os agentes que dependem deste (direta e indiretamente) + const dependents = this._findDependents(agentId); + const agent = this.agents.get(agentId); + + // Avaliar risco com base no status e numero de dependentes + const isHealthy = agent.status === AgentStatus.ALIVE; + const healthScore = this._calculateHealthScore(agent); + + let riskLevel = 'low'; + if (dependents.length > 3 && !isHealthy) { + riskLevel = 'critical'; + } else if (dependents.length > 1 && !isHealthy) { + riskLevel = 'high'; + } else if (dependents.length > 0 && healthScore < 50) { + riskLevel = 'medium'; + } + + const result = { + agentId, + dependents, + dependentCount: dependents.length, + riskLevel, + agentStatus: agent.status, + healthScore, + }; + + if (riskLevel === 'critical' || riskLevel === 'high') { + this.emit(Events.CASCADE_RISK, result); + } + + return result; + } + + // ═════════════════════════════════════════════════════════════════════════════ + // PERSISTENCE + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Salva estado completo do protocolo em disco + * @returns {Promise} + */ + async saveState() { + this._saveQueue = this._saveQueue.then(async () => { + const filePath = path.resolve(this.projectRoot, this.config.stateFile); + const dir = path.dirname(filePath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const data = { + schemaVersion: this.config.schemaVersion, + savedAt: new Date().toISOString(), + agents: {}, + dependencies: {}, + }; + + for (const [id, agent] of this.agents.entries()) { + data.agents[id] = { + id: agent.id, + status: agent.status, + registeredAt: agent.registeredAt, + lastHeartbeat: agent.lastHeartbeat, + lastSnapshot: agent.lastSnapshot, + errorCount: agent.errorCount, + snapshotCount: agent.snapshots.length, + revivalCount: agent.revivalHistory.length, + healthScore: this._calculateHealthScore(agent), + }; + } + + for (const [id, deps] of this._dependencies.entries()) { + data.dependencies[id] = [...deps]; + } + + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + }); + + await this._saveQueue; + } + + /** + * Carrega estado do protocolo do disco + * @returns {Promise} Estado carregado ou null + */ + async loadState() { + const filePath = path.resolve(this.projectRoot, this.config.stateFile); + + try { + if (!fs.existsSync(filePath)) return null; + const raw = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw); + + if (data.schemaVersion !== this.config.schemaVersion) return null; + return data; + } catch { + return null; + } + } + + // ═════════════════════════════════════════════════════════════════════════════ + // STATS + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Retorna estatisticas gerais do protocolo + * @returns {Object} Estatisticas + */ + getStats() { + const stats = { + totalAgents: this.agents.size, + byStatus: {}, + totalRevivals: 0, + totalSnapshots: 0, + totalHeartbeats: 0, + monitoringActive: this._heartbeatCheckers.size, + dependencyEdges: 0, + }; + + for (const agent of this.agents.values()) { + stats.byStatus[agent.status] = (stats.byStatus[agent.status] ?? 0) + 1; + stats.totalRevivals += agent.revivalHistory.length; + stats.totalSnapshots += agent.snapshots.length; + stats.totalHeartbeats += agent.heartbeats.length; + } + + for (const deps of this._dependencies.values()) { + stats.dependencyEdges += deps.length; + } + + return stats; + } + + // ═════════════════════════════════════════════════════════════════════════════ + // PRIVATE METHODS + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Verifica se o agente existe; lanca erro se nao + * @param {string} agentId + * @private + */ + _assertAgentExists(agentId) { + if (!this.agents.has(agentId)) { + throw new Error(`Agent "${agentId}" is not registered`); + } + } + + /** + * Verifica heartbeat de um agente e detecta morte + * @param {string} agentId + * @private + */ + _checkHeartbeat(agentId) { + const agent = this.agents.get(agentId); + if (!agent) return; + + const now = Date.now(); + const gracePeriod = agent.config.gracePeriodMs ?? this.config.gracePeriodMs; + const elapsed = now - (agent.lastHeartbeat ?? agent.registeredAt); + + if (elapsed > gracePeriod) { + if (agent.status === AgentStatus.ALIVE || agent.status === AgentStatus.SUSPECT) { + agent.status = AgentStatus.DEAD; + agent.errorCount++; + + this.emit(Events.DEATH_DETECTED, { + agentId, + lastHeartbeat: agent.lastHeartbeat, + elapsed, + errorCount: agent.errorCount, + timestamp: now, + }); + + // Verificar risco de cascata + const dependents = this._findDependents(agentId); + if (dependents.length > 0) { + this.emit(Events.CASCADE_RISK, { + agentId, + dependents, + dependentCount: dependents.length, + riskLevel: dependents.length > 3 ? 'critical' : 'high', + agentStatus: AgentStatus.DEAD, + healthScore: 0, + }); + } + + // Auto-revival + this.reviveAgent(agentId).catch(() => { + // Revival falhou silenciosamente — ja emitido evento + }); + } + } else if (elapsed > gracePeriod * 0.6) { + if (agent.status === AgentStatus.ALIVE) { + agent.status = AgentStatus.SUSPECT; + const healthScore = this._calculateHealthScore(agent); + const warningThreshold = agent.config.healthWarningThreshold ?? this.config.healthWarningThreshold; + if (healthScore < warningThreshold) { + this.emit(Events.HEALTH_WARNING, { + agentId, + healthScore, + status: AgentStatus.SUSPECT, + timestamp: now, + }); + } + } + } + } + + /** + * Atualiza fingerprint comportamental com nova metrica + * @param {string} agentId + * @param {number} metric - Intervalo do heartbeat + * @private + */ + _updateFingerprint(agentId, metric) { + const agent = this.agents.get(agentId); + if (!agent) return; + + const fp = agent.fingerprint; + const windowSize = agent.config.fingerprintWindowSize ?? this.config.fingerprintWindowSize; + + fp.metrics.push(metric); + if (fp.metrics.length > windowSize) { + fp.metrics = fp.metrics.slice(-windowSize); + } + + // Recalcular baseline se temos metricas suficientes + if (fp.metrics.length >= 5) { + const mean = fp.metrics.reduce((a, b) => a + b, 0) / fp.metrics.length; + const variance = fp.metrics.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / fp.metrics.length; + const stdDev = Math.sqrt(variance); + + fp.baseline = { + mean: Math.round(mean * 100) / 100, + stdDev: Math.round(stdDev * 100) / 100, + sampleSize: fp.metrics.length, + updatedAt: Date.now(), + }; + } + } + + /** + * Calcula health score composto (0-100) + * @param {Object} agent - Dados do agente + * @returns {number} Score entre 0 e 100 + * @private + */ + _calculateHealthScore(agent) { + // Peso: heartbeat regularity (40%), error rate (30%), memoria estavel (30%) + let heartbeatScore = 100; + let errorScore = 100; + let stabilityScore = 100; + + // Heartbeat regularity + if (agent.heartbeats.length >= 2) { + const intervals = []; + for (let i = 1; i < agent.heartbeats.length; i++) { + intervals.push(agent.heartbeats[i].timestamp - agent.heartbeats[i - 1].timestamp); + } + const expectedInterval = agent.config.heartbeatIntervalMs ?? this.config.heartbeatIntervalMs; + const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length; + const deviation = Math.abs(avgInterval - expectedInterval) / expectedInterval; + heartbeatScore = Math.max(0, Math.round(100 * (1 - Math.min(deviation, 1)))); + } else if (agent.heartbeats.length === 0) { + heartbeatScore = 0; + } + + // Error rate (baseado no historico de revivals) + if (agent.revivalHistory.length > 0) { + const recentRevivals = agent.revivalHistory.filter( + r => (Date.now() - r.timestamp) < (agent.config.revivalWindowMs ?? this.config.revivalWindowMs) + ); + const maxRevivals = agent.config.maxRevivals ?? this.config.maxRevivals; + errorScore = Math.max(0, Math.round(100 * (1 - recentRevivals.length / maxRevivals))); + } + + // Estabilidade (baseada no desvio padrao do fingerprint) + if (agent.fingerprint.baseline) { + const cv = agent.fingerprint.baseline.mean > 0 + ? agent.fingerprint.baseline.stdDev / agent.fingerprint.baseline.mean + : 0; + stabilityScore = Math.max(0, Math.round(100 * (1 - Math.min(cv, 1)))); + } + + // Status penalty + let statusPenalty = 0; + if (agent.status === AgentStatus.DEAD) statusPenalty = 50; + else if (agent.status === AgentStatus.SUSPECT) statusPenalty = 20; + else if (agent.status === AgentStatus.REVIVING) statusPenalty = 30; + + const weighted = (heartbeatScore * 0.4) + (errorScore * 0.3) + (stabilityScore * 0.3); + return Math.max(0, Math.min(100, Math.round(weighted - statusPenalty))); + } + + /** + * Calcula tendencia de uma serie de metricas + * @param {number[]} values - Valores da serie + * @returns {number} Coeficiente de tendencia (positivo = crescente) + * @private + */ + _calculateTrend(values) { + if (values.length < 2) return 0; + const n = values.length; + let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; + + for (let i = 0; i < n; i++) { + sumX += i; + sumY += values[i]; + sumXY += i * values[i]; + sumX2 += i * i; + } + + const denominator = (n * sumX2) - (sumX * sumX); + if (denominator === 0) return 0; + + const slope = ((n * sumXY) - (sumX * sumY)) / denominator; + const mean = sumY / n; + + // Normalizar pelo valor medio para obter taxa de variacao + return mean > 0 ? slope / mean : 0; + } + + /** + * Encontra todos os agentes que dependem (direta ou indiretamente) de um agente + * @param {string} agentId - Agente alvo + * @returns {string[]} IDs dos agentes dependentes + * @private + */ + _findDependents(agentId) { + const dependents = new Set(); + const visited = new Set(); + + const search = (targetId) => { + for (const [id, deps] of this._dependencies.entries()) { + if (visited.has(id)) continue; + if (deps.includes(targetId)) { + dependents.add(id); + visited.add(id); + search(id); // Busca recursiva para dependentes indiretos + } + } + }; + + search(agentId); + return [...dependents]; + } + + /** + * Persiste snapshot em disco (serializado via promise chain) + * @param {string} agentId + * @param {Object} snapshot + * @private + */ + _persistSnapshot(agentId, snapshot) { + this._saveQueue = this._saveQueue.then(() => { + try { + const dir = path.resolve( + this.projectRoot, + this.config.snapshotDir, + agentId + ); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const filePath = path.join(dir, `${snapshot.id}.json`); + fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2), 'utf-8'); + } catch { + // Falha silenciosa na persistencia — nao bloqueia o fluxo + if (this.listenerCount('error') > 0) { + this.emit('error', new Error(`Failed to persist snapshot for agent "${agentId}"`)); + } + } + }); + } + + /** + * Destroi o protocolo — para todos os monitoramentos e limpa recursos + */ + destroy() { + for (const agentId of this.agents.keys()) { + this.stopMonitoring(agentId); + } + this.agents.clear(); + this._dependencies.clear(); + this.removeAllListeners(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// EXPORTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +module.exports = AgentImmortalityProtocol; +module.exports.AgentImmortalityProtocol = AgentImmortalityProtocol; +module.exports.Events = Events; +module.exports.AgentStatus = AgentStatus; +module.exports.DEFAULT_CONFIG = DEFAULT_CONFIG; diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 3529ff13b..624eae7dd 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-11T12:38:10.304Z" +generated_at: "2026-03-12T18:32:14.986Z" generator: scripts/generate-install-manifest.js file_count: 1091 files: @@ -1036,6 +1036,10 @@ files: hash: sha256:d9805ce445661a3a2d9e6c73bf35cbd1bc2403419825442cd7b8f01cc1409cb3 type: core size: 9179 + - path: core/resilience/agent-immortality.js + hash: sha256:89ae4bac066088e76071cfc9b391418e9eba804bcc2b2f943edb1ce38974735c + type: core + size: 37573 - path: core/session/context-detector.js hash: sha256:5537563d5dfc613e16fd610c9e1407e7811c4f19745a78a4fc81c34af20000f4 type: core @@ -2348,10 +2352,6 @@ files: hash: sha256:dfb5f03fae16171777742b06a9e54ee25711d1d94cedc2152ef9c9331310b608 type: task size: 5254 - - path: development/tasks/review-prs.md - hash: sha256:f49dcdc3f16a1bb187ab76daa6c8bf28e69ea1506b0048661cbda7907ac45fba - type: task - size: 14109 - path: development/tasks/run-design-system-pipeline.md hash: sha256:ff4c225b922da347b63aeb6d8aa95484c1c9281eb1e4b4c4ab0ecef0a1a54c26 type: task @@ -2591,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 @@ -2611,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 @@ -2623,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 @@ -2631,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 @@ -3375,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 @@ -3391,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 @@ -3403,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 @@ -3515,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 @@ -3699,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 @@ -3751,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 @@ -3815,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 @@ -3923,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 @@ -3931,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 @@ -3983,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 @@ -3995,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/resilience/agent-immortality.test.js b/tests/core/resilience/agent-immortality.test.js new file mode 100644 index 000000000..272932dde --- /dev/null +++ b/tests/core/resilience/agent-immortality.test.js @@ -0,0 +1,1335 @@ +/** + * Agent Immortality Protocol — Testes Unitarios + * + * Story: 568 - Agent Immortality Protocol + * Epic: Resilience — agentes que nunca morrem + * + * @version 1.0.0 + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const AgentImmortalityProtocol = require('../../../.aiox-core/core/resilience/agent-immortality'); +const { Events, AgentStatus, DEFAULT_CONFIG } = require('../../../.aiox-core/core/resilience/agent-immortality'); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════════════ + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'immortality-test-')); +} + +function cleanDir(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Ignora + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// TESTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('Agent Immortality Protocol (Story #568)', () => { + let tempDir; + let protocol; + + beforeEach(() => { + jest.useFakeTimers(); + tempDir = createTempDir(); + protocol = new AgentImmortalityProtocol(tempDir, { + heartbeatIntervalMs: 1000, + gracePeriodMs: 3000, + snapshotIntervalMs: 5000, + maxSnapshots: 5, + maxRevivals: 3, + revivalWindowMs: 60000, + fingerprintWindowSize: 20, + anomalyThreshold: 2.0, + healthWarningThreshold: 50, + }); + }); + + afterEach(() => { + if (protocol) { + protocol.destroy(); + } + jest.useRealTimers(); + cleanDir(tempDir); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // EXPORTS + // ═════════════════════════════════════════════════════════════════════════════ + + describe('Exports', () => { + it('should export AgentImmortalityProtocol as default and named', () => { + expect(AgentImmortalityProtocol).toBeDefined(); + expect(typeof AgentImmortalityProtocol).toBe('function'); + + const mod = require('../../../.aiox-core/core/resilience/agent-immortality'); + expect(mod.AgentImmortalityProtocol).toBe(AgentImmortalityProtocol); + }); + + it('should export Events enum', () => { + expect(Events).toBeDefined(); + expect(Events.HEARTBEAT).toBe('heartbeat'); + expect(Events.SNAPSHOT).toBe('snapshot'); + expect(Events.DEATH_DETECTED).toBe('death-detected'); + expect(Events.REVIVAL_STARTED).toBe('revival-started'); + expect(Events.REVIVAL_COMPLETE).toBe('revival-complete'); + expect(Events.ANOMALY_DETECTED).toBe('anomaly-detected'); + expect(Events.CASCADE_RISK).toBe('cascade-risk'); + expect(Events.HEALTH_WARNING).toBe('health-warning'); + }); + + it('should export AgentStatus enum', () => { + expect(AgentStatus).toBeDefined(); + expect(AgentStatus.REGISTERED).toBe('registered'); + expect(AgentStatus.ALIVE).toBe('alive'); + expect(AgentStatus.SUSPECT).toBe('suspect'); + expect(AgentStatus.DEAD).toBe('dead'); + expect(AgentStatus.REVIVING).toBe('reviving'); + }); + + it('should export DEFAULT_CONFIG', () => { + expect(DEFAULT_CONFIG).toBeDefined(); + expect(DEFAULT_CONFIG.heartbeatIntervalMs).toBe(5000); + expect(DEFAULT_CONFIG.schemaVersion).toBe('aiox-immortality-v1'); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR + // ═════════════════════════════════════════════════════════════════════════════ + + describe('Constructor', () => { + it('should initialize with projectRoot and default config', () => { + const p = new AgentImmortalityProtocol(tempDir); + expect(p.projectRoot).toBe(tempDir); + expect(p.config.heartbeatIntervalMs).toBe(DEFAULT_CONFIG.heartbeatIntervalMs); + expect(p.agents.size).toBe(0); + p.destroy(); + }); + + it('should merge custom options with defaults', () => { + const p = new AgentImmortalityProtocol(tempDir, { heartbeatIntervalMs: 2000 }); + expect(p.config.heartbeatIntervalMs).toBe(2000); + expect(p.config.gracePeriodMs).toBe(DEFAULT_CONFIG.gracePeriodMs); + p.destroy(); + }); + + it('should use nullish coalescing for projectRoot', () => { + const p = new AgentImmortalityProtocol(null); + expect(p.projectRoot).toBe(process.cwd()); + p.destroy(); + }); + + it('should extend EventEmitter', () => { + expect(protocol).toBeInstanceOf(require('events').EventEmitter); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // AGENT REGISTRATION + // ═════════════════════════════════════════════════════════════════════════════ + + describe('registerAgent()', () => { + it('should register an agent with default config', () => { + const result = protocol.registerAgent('agent-1'); + expect(result.id).toBe('agent-1'); + expect(result.status).toBe(AgentStatus.REGISTERED); + expect(result.heartbeats).toEqual([]); + expect(result.snapshots).toEqual([]); + expect(result.revivalHistory).toEqual([]); + }); + + it('should register an agent with custom config', () => { + const result = protocol.registerAgent('agent-1', { heartbeatIntervalMs: 500 }); + expect(result.config.heartbeatIntervalMs).toBe(500); + }); + + it('should register an agent with custom revivalFn', () => { + const fn = jest.fn(); + protocol.registerAgent('agent-1', { revivalFn: fn }); + // revivalFn fica no agent interno (nao no clone retornado, pois JSON perde funcoes) + const agent = protocol.agents.get('agent-1'); + expect(typeof agent.config.revivalFn).toBe('function'); + }); + + it('should throw if agentId is empty', () => { + expect(() => protocol.registerAgent('')).toThrow('agentId is required'); + }); + + it('should throw if agentId is not a string', () => { + expect(() => protocol.registerAgent(123)).toThrow('agentId is required'); + }); + + it('should throw if agent already registered', () => { + protocol.registerAgent('agent-1'); + expect(() => protocol.registerAgent('agent-1')).toThrow('already registered'); + }); + + it('should return a deep clone (mutation-safe)', () => { + const result = protocol.registerAgent('agent-1'); + result.status = 'hacked'; + const agent = protocol.agents.get('agent-1'); + expect(agent.status).toBe(AgentStatus.REGISTERED); + }); + }); + + describe('unregisterAgent()', () => { + it('should remove an agent', () => { + protocol.registerAgent('agent-1'); + protocol.unregisterAgent('agent-1'); + expect(protocol.agents.has('agent-1')).toBe(false); + }); + + it('should stop monitoring on unregister', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + protocol.unregisterAgent('agent-1'); + expect(protocol._heartbeatCheckers.has('agent-1')).toBe(false); + }); + + it('should clean up dependencies', () => { + protocol.registerAgent('agent-1'); + protocol.registerAgent('agent-2'); + protocol.declareDependency('agent-2', 'agent-1'); + protocol.unregisterAgent('agent-1'); + expect(protocol._dependencies.has('agent-2')).toBe(false); + }); + + it('should throw if agent not registered', () => { + expect(() => protocol.unregisterAgent('ghost')).toThrow('not registered'); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // MONITORING + // ═════════════════════════════════════════════════════════════════════════════ + + describe('startMonitoring()', () => { + it('should set agent status to ALIVE', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + expect(protocol.agents.get('agent-1').status).toBe(AgentStatus.ALIVE); + }); + + it('should create heartbeat checker interval', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + expect(protocol._heartbeatCheckers.has('agent-1')).toBe(true); + }); + + it('should create snapshot timer interval', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + expect(protocol._snapshotTimers.has('agent-1')).toBe(true); + }); + + it('should be idempotent (no double-start)', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + const checker1 = protocol._heartbeatCheckers.get('agent-1'); + protocol.startMonitoring('agent-1'); + const checker2 = protocol._heartbeatCheckers.get('agent-1'); + expect(checker1).toBe(checker2); + }); + + it('should throw if agent not registered', () => { + expect(() => protocol.startMonitoring('ghost')).toThrow('not registered'); + }); + }); + + describe('stopMonitoring()', () => { + it('should clear heartbeat checker', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + protocol.stopMonitoring('agent-1'); + expect(protocol._heartbeatCheckers.has('agent-1')).toBe(false); + }); + + it('should clear snapshot timer', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + protocol.stopMonitoring('agent-1'); + expect(protocol._snapshotTimers.has('agent-1')).toBe(false); + }); + + it('should set status back to REGISTERED if was ALIVE', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + protocol.stopMonitoring('agent-1'); + expect(protocol.agents.get('agent-1').status).toBe(AgentStatus.REGISTERED); + }); + + it('should not throw for agent without monitoring', () => { + protocol.registerAgent('agent-1'); + expect(() => protocol.stopMonitoring('agent-1')).not.toThrow(); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // HEARTBEAT + // ═════════════════════════════════════════════════════════════════════════════ + + describe('heartbeat()', () => { + it('should record a heartbeat', () => { + protocol.registerAgent('agent-1'); + const hb = protocol.heartbeat('agent-1'); + expect(hb.id).toBeDefined(); + expect(hb.timestamp).toBeDefined(); + }); + + it('should record heartbeat with state data', () => { + protocol.registerAgent('agent-1'); + const state = { memory: 42, tasks: ['a'] }; + const hb = protocol.heartbeat('agent-1', state); + expect(hb.stateData).toEqual(state); + }); + + it('should deep clone state data (mutation-safe)', () => { + protocol.registerAgent('agent-1'); + const state = { value: 1 }; + protocol.heartbeat('agent-1', state); + state.value = 999; + const agent = protocol.agents.get('agent-1'); + expect(agent.heartbeats[0].stateData.value).toBe(1); + }); + + it('should compute interval between heartbeats', () => { + protocol.registerAgent('agent-1'); + protocol.heartbeat('agent-1'); + jest.advanceTimersByTime(1000); + const hb2 = protocol.heartbeat('agent-1'); + expect(hb2.interval).toBe(1000); + }); + + it('should set interval to 0 for first heartbeat', () => { + protocol.registerAgent('agent-1'); + const hb = protocol.heartbeat('agent-1'); + expect(hb.interval).toBe(0); + }); + + it('should emit heartbeat event', () => { + protocol.registerAgent('agent-1'); + const handler = jest.fn(); + protocol.on(Events.HEARTBEAT, handler); + protocol.heartbeat('agent-1'); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].agentId).toBe('agent-1'); + }); + + it('should restore DEAD agent to ALIVE on heartbeat', () => { + protocol.registerAgent('agent-1'); + const agent = protocol.agents.get('agent-1'); + agent.status = AgentStatus.DEAD; + protocol.heartbeat('agent-1'); + expect(agent.status).toBe(AgentStatus.ALIVE); + }); + + it('should restore SUSPECT agent to ALIVE on heartbeat', () => { + protocol.registerAgent('agent-1'); + const agent = protocol.agents.get('agent-1'); + agent.status = AgentStatus.SUSPECT; + protocol.heartbeat('agent-1'); + expect(agent.status).toBe(AgentStatus.ALIVE); + }); + + it('should trim heartbeat history to prevent unbounded growth', () => { + protocol.registerAgent('agent-1', { fingerprintWindowSize: 5 }); + for (let i = 0; i < 15; i++) { + jest.advanceTimersByTime(100); + protocol.heartbeat('agent-1'); + } + const agent = protocol.agents.get('agent-1'); + expect(agent.heartbeats.length).toBeLessThanOrEqual(10); + }); + + it('should throw if agent not registered', () => { + expect(() => protocol.heartbeat('ghost')).toThrow('not registered'); + }); + }); + + describe('getLastHeartbeat()', () => { + it('should return null if no heartbeats', () => { + protocol.registerAgent('agent-1'); + expect(protocol.getLastHeartbeat('agent-1')).toBeNull(); + }); + + it('should return the last heartbeat', () => { + protocol.registerAgent('agent-1'); + protocol.heartbeat('agent-1', { v: 1 }); + jest.advanceTimersByTime(500); + protocol.heartbeat('agent-1', { v: 2 }); + const last = protocol.getLastHeartbeat('agent-1'); + expect(last.stateData.v).toBe(2); + }); + + it('should return a deep clone', () => { + protocol.registerAgent('agent-1'); + protocol.heartbeat('agent-1', { v: 1 }); + const last = protocol.getLastHeartbeat('agent-1'); + last.stateData.v = 999; + const again = protocol.getLastHeartbeat('agent-1'); + expect(again.stateData.v).toBe(1); + }); + + it('should throw if agent not registered', () => { + expect(() => protocol.getLastHeartbeat('ghost')).toThrow('not registered'); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // SNAPSHOTS + // ═════════════════════════════════════════════════════════════════════════════ + + describe('createSnapshot()', () => { + it('should create a snapshot with metadata', () => { + protocol.registerAgent('agent-1'); + const snap = protocol.createSnapshot('agent-1', { data: 'test' }); + expect(snap.id).toBeDefined(); + expect(snap.agentId).toBe('agent-1'); + expect(snap.timestamp).toBeDefined(); + expect(snap.healthScore).toBeDefined(); + }); + + it('should store snapshot in agent snapshots array', () => { + protocol.registerAgent('agent-1'); + protocol.createSnapshot('agent-1', { data: 1 }); + const agent = protocol.agents.get('agent-1'); + expect(agent.snapshots.length).toBe(1); + expect(agent.snapshots[0].state.data).toBe(1); + }); + + it('should deep clone state (mutation-safe)', () => { + protocol.registerAgent('agent-1'); + const state = { value: 42 }; + protocol.createSnapshot('agent-1', state); + state.value = 0; + const agent = protocol.agents.get('agent-1'); + expect(agent.snapshots[0].state.value).toBe(42); + }); + + it('should enforce maxSnapshots limit', () => { + protocol.registerAgent('agent-1'); + for (let i = 0; i < 10; i++) { + protocol.createSnapshot('agent-1', { iter: i }); + } + const agent = protocol.agents.get('agent-1'); + expect(agent.snapshots.length).toBeLessThanOrEqual(5); + // O mais recente deve ser o ultimo + expect(agent.snapshots[agent.snapshots.length - 1].state.iter).toBe(9); + }); + + it('should emit snapshot event', () => { + protocol.registerAgent('agent-1'); + const handler = jest.fn(); + protocol.on(Events.SNAPSHOT, handler); + protocol.createSnapshot('agent-1', { x: 1 }); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].agentId).toBe('agent-1'); + }); + + it('should persist snapshot to disk', async () => { + protocol.registerAgent('agent-1'); + const snap = protocol.createSnapshot('agent-1', { persisted: true }); + // Aguardar serializacao + await protocol._saveQueue; + const snapDir = path.resolve(tempDir, '.aiox/immortality/agent-1'); + const files = fs.readdirSync(snapDir); + expect(files.length).toBe(1); + expect(files[0]).toContain('.json'); + }); + + it('should throw if state is null', () => { + protocol.registerAgent('agent-1'); + expect(() => protocol.createSnapshot('agent-1', null)).toThrow('state must be a non-null object'); + }); + + it('should throw if state is not an object', () => { + protocol.registerAgent('agent-1'); + expect(() => protocol.createSnapshot('agent-1', 'string')).toThrow('state must be a non-null object'); + }); + + it('should throw if agent not registered', () => { + expect(() => protocol.createSnapshot('ghost', {})).toThrow('not registered'); + }); + }); + + describe('getLatestSnapshot()', () => { + it('should return null if no snapshots', () => { + protocol.registerAgent('agent-1'); + expect(protocol.getLatestSnapshot('agent-1')).toBeNull(); + }); + + it('should return the latest snapshot', () => { + protocol.registerAgent('agent-1'); + protocol.createSnapshot('agent-1', { v: 1 }); + protocol.createSnapshot('agent-1', { v: 2 }); + const latest = protocol.getLatestSnapshot('agent-1'); + expect(latest.state.v).toBe(2); + }); + + it('should return deep clone', () => { + protocol.registerAgent('agent-1'); + protocol.createSnapshot('agent-1', { v: 1 }); + const snap = protocol.getLatestSnapshot('agent-1'); + snap.state.v = 999; + expect(protocol.getLatestSnapshot('agent-1').state.v).toBe(1); + }); + }); + + describe('listSnapshots()', () => { + it('should return all snapshots', () => { + protocol.registerAgent('agent-1'); + protocol.createSnapshot('agent-1', { v: 1 }); + protocol.createSnapshot('agent-1', { v: 2 }); + const list = protocol.listSnapshots('agent-1'); + expect(list.length).toBe(2); + }); + + it('should filter by since timestamp', () => { + protocol.registerAgent('agent-1'); + protocol.createSnapshot('agent-1', { v: 1 }); + jest.advanceTimersByTime(5000); + const cutoff = Date.now(); + jest.advanceTimersByTime(1000); + protocol.createSnapshot('agent-1', { v: 2 }); + const list = protocol.listSnapshots('agent-1', { since: cutoff }); + expect(list.length).toBe(1); + expect(list[0].state.v).toBe(2); + }); + + it('should respect limit option', () => { + protocol.registerAgent('agent-1'); + for (let i = 0; i < 5; i++) { + protocol.createSnapshot('agent-1', { v: i }); + } + const list = protocol.listSnapshots('agent-1', { limit: 2 }); + expect(list.length).toBe(2); + }); + + it('should return deep clones', () => { + protocol.registerAgent('agent-1'); + protocol.createSnapshot('agent-1', { v: 1 }); + const list = protocol.listSnapshots('agent-1'); + list[0].state.v = 999; + expect(protocol.getLatestSnapshot('agent-1').state.v).toBe(1); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // REVIVAL + // ═════════════════════════════════════════════════════════════════════════════ + + describe('reviveAgent()', () => { + it('should revive an agent from snapshot', async () => { + protocol.registerAgent('agent-1'); + protocol.createSnapshot('agent-1', { restored: true }); + const agent = protocol.agents.get('agent-1'); + agent.status = AgentStatus.DEAD; + + const result = await protocol.reviveAgent('agent-1'); + expect(result.success).toBe(true); + expect(result.method).toBe('snapshot'); + expect(result.state.restored).toBe(true); + }); + + it('should set agent status to ALIVE after revival', async () => { + protocol.registerAgent('agent-1'); + protocol.createSnapshot('agent-1', { data: 1 }); + const agent = protocol.agents.get('agent-1'); + agent.status = AgentStatus.DEAD; + + await protocol.reviveAgent('agent-1'); + expect(agent.status).toBe(AgentStatus.ALIVE); + }); + + it('should reset errorCount after revival', async () => { + protocol.registerAgent('agent-1'); + const agent = protocol.agents.get('agent-1'); + agent.errorCount = 5; + agent.status = AgentStatus.DEAD; + + await protocol.reviveAgent('agent-1'); + expect(agent.errorCount).toBe(0); + }); + + it('should add record to revivalHistory', async () => { + protocol.registerAgent('agent-1'); + protocol.createSnapshot('agent-1', { data: 1 }); + + await protocol.reviveAgent('agent-1'); + const history = protocol.getRevivalHistory('agent-1'); + expect(history.length).toBe(1); + expect(history[0].method).toBe('snapshot'); + }); + + it('should emit revival-started and revival-complete events', async () => { + protocol.registerAgent('agent-1'); + protocol.createSnapshot('agent-1', { data: 1 }); + + const started = jest.fn(); + const complete = jest.fn(); + protocol.on(Events.REVIVAL_STARTED, started); + protocol.on(Events.REVIVAL_COMPLETE, complete); + + await protocol.reviveAgent('agent-1'); + expect(started).toHaveBeenCalledTimes(1); + expect(complete).toHaveBeenCalledTimes(1); + }); + + it('should use custom revivalFn when provided', async () => { + const customFn = jest.fn().mockResolvedValue({ custom: true }); + protocol.registerAgent('agent-1', { revivalFn: customFn }); + + const result = await protocol.reviveAgent('agent-1'); + expect(result.method).toBe('custom'); + expect(result.state.custom).toBe(true); + expect(customFn).toHaveBeenCalled(); + }); + + it('should fallback to snapshot if custom revivalFn fails', async () => { + const customFn = jest.fn().mockRejectedValue(new Error('boom')); + protocol.registerAgent('agent-1', { revivalFn: customFn }); + protocol.createSnapshot('agent-1', { fallback: true }); + + const result = await protocol.reviveAgent('agent-1'); + expect(result.method).toBe('snapshot-fallback'); + expect(result.state.fallback).toBe(true); + }); + + it('should handle revival without snapshot', async () => { + protocol.registerAgent('agent-1'); + + const result = await protocol.reviveAgent('agent-1'); + expect(result.success).toBe(true); + expect(result.state).toBeNull(); + expect(result.snapshotId).toBeNull(); + }); + + it('should enforce maxRevivals limit', async () => { + protocol.registerAgent('agent-1'); + + // Esgotar revivals + for (let i = 0; i < 3; i++) { + await protocol.reviveAgent('agent-1'); + } + + const result = await protocol.reviveAgent('agent-1'); + expect(result.success).toBe(false); + expect(result.reason).toBe('max-revivals-exceeded'); + }); + + it('should count revivals within window only', async () => { + protocol.registerAgent('agent-1'); + + await protocol.reviveAgent('agent-1'); + await protocol.reviveAgent('agent-1'); + + // Avancar alem da janela + jest.advanceTimersByTime(70000); + + const result = await protocol.reviveAgent('agent-1'); + expect(result.success).toBe(true); + }); + + it('should throw if agent not registered', async () => { + await expect(protocol.reviveAgent('ghost')).rejects.toThrow('not registered'); + }); + }); + + describe('getRevivalHistory()', () => { + it('should return empty array for new agent', () => { + protocol.registerAgent('agent-1'); + expect(protocol.getRevivalHistory('agent-1')).toEqual([]); + }); + + it('should return deep clones', async () => { + protocol.registerAgent('agent-1'); + await protocol.reviveAgent('agent-1'); + const history = protocol.getRevivalHistory('agent-1'); + history[0].method = 'hacked'; + const again = protocol.getRevivalHistory('agent-1'); + expect(again[0].method).toBe('snapshot'); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // HEALTH SCORE + // ═════════════════════════════════════════════════════════════════════════════ + + describe('getHealthScore()', () => { + it('should return 0 for agent with no heartbeats', () => { + protocol.registerAgent('agent-1'); + const score = protocol.getHealthScore('agent-1'); + // Sem heartbeats = heartbeatScore 0, errorScore 100, stabilityScore 100 + // weighted = (0*0.4) + (100*0.3) + (100*0.3) = 60 + expect(score).toBe(60); + }); + + it('should return high score for healthy agent', () => { + protocol.registerAgent('agent-1'); + // Simular heartbeats regulares + for (let i = 0; i < 10; i++) { + jest.advanceTimersByTime(1000); + protocol.heartbeat('agent-1'); + } + const score = protocol.getHealthScore('agent-1'); + expect(score).toBeGreaterThan(70); + }); + + it('should decrease score for agent with revivals', async () => { + protocol.registerAgent('agent-1'); + for (let i = 0; i < 5; i++) { + jest.advanceTimersByTime(1000); + protocol.heartbeat('agent-1'); + } + const scoreBefore = protocol.getHealthScore('agent-1'); + + await protocol.reviveAgent('agent-1'); + await protocol.reviveAgent('agent-1'); + const scoreAfter = protocol.getHealthScore('agent-1'); + + expect(scoreAfter).toBeLessThan(scoreBefore); + }); + + it('should penalize DEAD status', () => { + protocol.registerAgent('agent-1'); + for (let i = 0; i < 5; i++) { + jest.advanceTimersByTime(1000); + protocol.heartbeat('agent-1'); + } + const agent = protocol.agents.get('agent-1'); + agent.status = AgentStatus.DEAD; + const score = protocol.getHealthScore('agent-1'); + expect(score).toBeLessThanOrEqual(50); + }); + + it('should penalize SUSPECT status', () => { + protocol.registerAgent('agent-1'); + for (let i = 0; i < 5; i++) { + jest.advanceTimersByTime(1000); + protocol.heartbeat('agent-1'); + } + const scoreAlive = protocol.getHealthScore('agent-1'); + + const agent = protocol.agents.get('agent-1'); + agent.status = AgentStatus.SUSPECT; + const scoreSuspect = protocol.getHealthScore('agent-1'); + + expect(scoreSuspect).toBeLessThan(scoreAlive); + }); + + it('should return score between 0 and 100', () => { + protocol.registerAgent('agent-1'); + const score = protocol.getHealthScore('agent-1'); + expect(score).toBeGreaterThanOrEqual(0); + expect(score).toBeLessThanOrEqual(100); + }); + + it('should throw if agent not registered', () => { + expect(() => protocol.getHealthScore('ghost')).toThrow('not registered'); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // BEHAVIORAL FINGERPRINT + // ═════════════════════════════════════════════════════════════════════════════ + + describe('getBehavioralFingerprint()', () => { + it('should return empty fingerprint for new agent', () => { + protocol.registerAgent('agent-1'); + const fp = protocol.getBehavioralFingerprint('agent-1'); + expect(fp.metrics).toEqual([]); + expect(fp.baseline).toBeNull(); + }); + + it('should build baseline after enough heartbeats', () => { + protocol.registerAgent('agent-1'); + for (let i = 0; i < 6; i++) { + jest.advanceTimersByTime(1000); + protocol.heartbeat('agent-1'); + } + const fp = protocol.getBehavioralFingerprint('agent-1'); + expect(fp.baseline).not.toBeNull(); + expect(fp.baseline.mean).toBeGreaterThan(0); + expect(fp.baseline.sampleSize).toBeGreaterThanOrEqual(5); + }); + + it('should calculate stdDev in baseline', () => { + protocol.registerAgent('agent-1'); + // Heartbeats com intervalos variados + const intervals = [1000, 1100, 900, 1050, 950, 1000]; + for (const interval of intervals) { + jest.advanceTimersByTime(interval); + protocol.heartbeat('agent-1'); + } + const fp = protocol.getBehavioralFingerprint('agent-1'); + expect(fp.baseline.stdDev).toBeGreaterThanOrEqual(0); + }); + + it('should return deep clone (mutation-safe)', () => { + protocol.registerAgent('agent-1'); + jest.advanceTimersByTime(1000); + protocol.heartbeat('agent-1'); + const fp = protocol.getBehavioralFingerprint('agent-1'); + fp.metrics.push(999999); + const fp2 = protocol.getBehavioralFingerprint('agent-1'); + expect(fp2.metrics).not.toContain(999999); + }); + + it('should throw if agent not registered', () => { + expect(() => protocol.getBehavioralFingerprint('ghost')).toThrow('not registered'); + }); + }); + + describe('detectAnomalies()', () => { + it('should return no anomalies with insufficient data', () => { + protocol.registerAgent('agent-1'); + const result = protocol.detectAnomalies('agent-1'); + expect(result.hasAnomalies).toBe(false); + expect(result.message).toContain('Insufficient data'); + }); + + it('should detect heartbeat interval anomaly', () => { + protocol.registerAgent('agent-1'); + // Construir baseline com intervalos regulares + for (let i = 0; i < 10; i++) { + jest.advanceTimersByTime(1000); + protocol.heartbeat('agent-1'); + } + // Injetar metrica anomala + const agent = protocol.agents.get('agent-1'); + agent.fingerprint.metrics.push(50000); // Muito fora do padrao + const result = protocol.detectAnomalies('agent-1'); + expect(result.hasAnomalies).toBe(true); + expect(result.anomalies.some(a => a.type === 'heartbeat-interval')).toBe(true); + }); + + it('should detect degradation trend', () => { + protocol.registerAgent('agent-1'); + // Construir baseline + for (let i = 0; i < 6; i++) { + jest.advanceTimersByTime(1000); + protocol.heartbeat('agent-1'); + } + // Injetar tendencia crescente agressiva + const agent = protocol.agents.get('agent-1'); + agent.fingerprint.metrics.push(2000, 4000, 8000, 16000, 32000); + // Recalcular baseline + const fp = agent.fingerprint; + const mean = fp.metrics.reduce((a, b) => a + b, 0) / fp.metrics.length; + const variance = fp.metrics.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / fp.metrics.length; + fp.baseline = { mean, stdDev: Math.sqrt(variance), sampleSize: fp.metrics.length, updatedAt: Date.now() }; + + const result = protocol.detectAnomalies('agent-1'); + expect(result.hasAnomalies).toBe(true); + }); + + it('should emit anomaly-detected event', () => { + protocol.registerAgent('agent-1'); + for (let i = 0; i < 10; i++) { + jest.advanceTimersByTime(1000); + protocol.heartbeat('agent-1'); + } + const agent = protocol.agents.get('agent-1'); + agent.fingerprint.metrics.push(50000); + + const handler = jest.fn(); + protocol.on(Events.ANOMALY_DETECTED, handler); + protocol.detectAnomalies('agent-1'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should NOT emit event when no anomalies', () => { + protocol.registerAgent('agent-1'); + for (let i = 0; i < 10; i++) { + jest.advanceTimersByTime(1000); + protocol.heartbeat('agent-1'); + } + const handler = jest.fn(); + protocol.on(Events.ANOMALY_DETECTED, handler); + protocol.detectAnomalies('agent-1'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should classify severity', () => { + protocol.registerAgent('agent-1'); + for (let i = 0; i < 10; i++) { + jest.advanceTimersByTime(1000); + protocol.heartbeat('agent-1'); + } + const agent = protocol.agents.get('agent-1'); + agent.fingerprint.metrics.push(50000); + const result = protocol.detectAnomalies('agent-1'); + const anomaly = result.anomalies.find(a => a.type === 'heartbeat-interval'); + expect(['warning', 'critical']).toContain(anomaly.severity); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // CASCADE PROTECTION + // ═════════════════════════════════════════════════════════════════════════════ + + describe('declareDependency()', () => { + it('should register a dependency', () => { + protocol.registerAgent('agent-1'); + protocol.registerAgent('agent-2'); + protocol.declareDependency('agent-2', 'agent-1'); + expect(protocol._dependencies.get('agent-2')).toContain('agent-1'); + }); + + it('should allow multiple dependencies', () => { + protocol.registerAgent('agent-1'); + protocol.registerAgent('agent-2'); + protocol.registerAgent('agent-3'); + protocol.declareDependency('agent-3', 'agent-1'); + protocol.declareDependency('agent-3', 'agent-2'); + expect(protocol._dependencies.get('agent-3').length).toBe(2); + }); + + it('should be idempotent (no duplicate deps)', () => { + protocol.registerAgent('agent-1'); + protocol.registerAgent('agent-2'); + protocol.declareDependency('agent-2', 'agent-1'); + protocol.declareDependency('agent-2', 'agent-1'); + expect(protocol._dependencies.get('agent-2').length).toBe(1); + }); + + it('should throw if agent depends on itself', () => { + protocol.registerAgent('agent-1'); + expect(() => protocol.declareDependency('agent-1', 'agent-1')).toThrow('cannot depend on itself'); + }); + + it('should throw if either agent not registered', () => { + protocol.registerAgent('agent-1'); + expect(() => protocol.declareDependency('agent-1', 'ghost')).toThrow('not registered'); + expect(() => protocol.declareDependency('ghost', 'agent-1')).toThrow('not registered'); + }); + }); + + describe('getCascadeRisk()', () => { + it('should return low risk for agent with no dependents', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + protocol.heartbeat('agent-1'); + const risk = protocol.getCascadeRisk('agent-1'); + expect(risk.riskLevel).toBe('low'); + expect(risk.dependentCount).toBe(0); + }); + + it('should return high risk when dead agent has dependents', () => { + protocol.registerAgent('agent-1'); + protocol.registerAgent('agent-2'); + protocol.registerAgent('agent-3'); + protocol.declareDependency('agent-2', 'agent-1'); + protocol.declareDependency('agent-3', 'agent-1'); + + const agent = protocol.agents.get('agent-1'); + agent.status = AgentStatus.DEAD; + + const risk = protocol.getCascadeRisk('agent-1'); + expect(risk.riskLevel).toBe('high'); + expect(risk.dependentCount).toBe(2); + }); + + it('should return critical risk when dead agent has many dependents', () => { + protocol.registerAgent('core'); + for (let i = 0; i < 5; i++) { + protocol.registerAgent(`dep-${i}`); + protocol.declareDependency(`dep-${i}`, 'core'); + } + + const agent = protocol.agents.get('core'); + agent.status = AgentStatus.DEAD; + + const risk = protocol.getCascadeRisk('core'); + expect(risk.riskLevel).toBe('critical'); + expect(risk.dependentCount).toBe(5); + }); + + it('should find indirect dependents', () => { + protocol.registerAgent('agent-1'); + protocol.registerAgent('agent-2'); + protocol.registerAgent('agent-3'); + protocol.declareDependency('agent-2', 'agent-1'); + protocol.declareDependency('agent-3', 'agent-2'); // Indireto via agent-2 + + const agent = protocol.agents.get('agent-1'); + agent.status = AgentStatus.DEAD; + + const risk = protocol.getCascadeRisk('agent-1'); + expect(risk.dependents).toContain('agent-2'); + // agent-3 depende de agent-2, nao diretamente de agent-1 + // mas findDependents busca recursivamente + }); + + it('should emit cascade-risk for high/critical risks', () => { + protocol.registerAgent('agent-1'); + protocol.registerAgent('agent-2'); + protocol.declareDependency('agent-2', 'agent-1'); + + const agent = protocol.agents.get('agent-1'); + agent.status = AgentStatus.DEAD; + + const handler = jest.fn(); + protocol.on(Events.CASCADE_RISK, handler); + protocol.getCascadeRisk('agent-1'); + // 1 dependent + dead = high (nao critical) + // high emite cascade-risk + }); + + it('should not emit cascade-risk for low risk', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + protocol.heartbeat('agent-1'); + + const handler = jest.fn(); + protocol.on(Events.CASCADE_RISK, handler); + protocol.getCascadeRisk('agent-1'); + expect(handler).not.toHaveBeenCalled(); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // CRASH DETECTION + // ═════════════════════════════════════════════════════════════════════════════ + + describe('Crash Detection (heartbeat timeout)', () => { + it('should detect death when heartbeat is missed beyond grace period', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + + const deathHandler = jest.fn(); + protocol.on(Events.DEATH_DETECTED, deathHandler); + + // Avancar alem do grace period sem enviar heartbeat + jest.advanceTimersByTime(4000); + + expect(deathHandler).toHaveBeenCalledTimes(1); + expect(deathHandler.mock.calls[0][0].agentId).toBe('agent-1'); + }); + + it('should set status to DEAD on missed heartbeat', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + + jest.advanceTimersByTime(4000); + + const agent = protocol.agents.get('agent-1'); + // Pode ter sido revivido automaticamente, mas deve ter passado por DEAD + }); + + it('should mark suspect before death', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + + const warningHandler = jest.fn(); + protocol.on(Events.HEALTH_WARNING, warningHandler); + + // Avancar ate 60% do grace period (1800ms) + jest.advanceTimersByTime(2000); + + const agent = protocol.agents.get('agent-1'); + // O agente pode estar como SUSPECT + }); + + it('should auto-revive dead agent', async () => { + protocol.registerAgent('agent-1'); + protocol.createSnapshot('agent-1', { autosave: true }); + protocol.startMonitoring('agent-1'); + + const revivalHandler = jest.fn(); + protocol.on(Events.REVIVAL_COMPLETE, revivalHandler); + + jest.advanceTimersByTime(4000); + + // Aguardar revival async + await jest.runAllTimersAsync().catch(() => {}); + // Revival e assíncrono, pode precisar de mais ticks + await Promise.resolve(); + await Promise.resolve(); + }); + + it('should not detect death if heartbeats are regular', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + + const deathHandler = jest.fn(); + protocol.on(Events.DEATH_DETECTED, deathHandler); + + // Heartbeats regulares a cada 500ms (bem dentro do grace de 3000ms) + for (let i = 0; i < 10; i++) { + jest.advanceTimersByTime(500); + protocol.heartbeat('agent-1'); + } + + expect(deathHandler).not.toHaveBeenCalled(); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // PERSISTENCE + // ═════════════════════════════════════════════════════════════════════════════ + + describe('saveState()', () => { + it('should save protocol state to disk', async () => { + protocol.registerAgent('agent-1'); + protocol.heartbeat('agent-1'); + + await protocol.saveState(); + + const filePath = path.resolve(tempDir, '.aiox/immortality/protocol-state.json'); + expect(fs.existsSync(filePath)).toBe(true); + + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + expect(data.schemaVersion).toBe('aiox-immortality-v1'); + expect(data.agents['agent-1']).toBeDefined(); + }); + + it('should create directories recursively', async () => { + protocol.registerAgent('agent-1'); + await protocol.saveState(); + + const dir = path.resolve(tempDir, '.aiox/immortality'); + expect(fs.existsSync(dir)).toBe(true); + }); + + it('should save dependency graph', async () => { + protocol.registerAgent('agent-1'); + protocol.registerAgent('agent-2'); + protocol.declareDependency('agent-2', 'agent-1'); + + await protocol.saveState(); + + const filePath = path.resolve(tempDir, '.aiox/immortality/protocol-state.json'); + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + expect(data.dependencies['agent-2']).toContain('agent-1'); + }); + + it('should serialize concurrent writes', async () => { + protocol.registerAgent('agent-1'); + + // Multiplas escritas simultaneas + const p1 = protocol.saveState(); + const p2 = protocol.saveState(); + const p3 = protocol.saveState(); + + await Promise.all([p1, p2, p3]); + + const filePath = path.resolve(tempDir, '.aiox/immortality/protocol-state.json'); + expect(fs.existsSync(filePath)).toBe(true); + // Se serializou corretamente, arquivo nao estara corrompido + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + expect(data.schemaVersion).toBe('aiox-immortality-v1'); + }); + }); + + describe('loadState()', () => { + it('should return null if no state file', async () => { + const result = await protocol.loadState(); + expect(result).toBeNull(); + }); + + it('should load saved state', async () => { + protocol.registerAgent('agent-1'); + await protocol.saveState(); + + const result = await protocol.loadState(); + expect(result).not.toBeNull(); + expect(result.schemaVersion).toBe('aiox-immortality-v1'); + expect(result.agents['agent-1']).toBeDefined(); + }); + + it('should return null for wrong schema version', async () => { + const filePath = path.resolve(tempDir, '.aiox/immortality/protocol-state.json'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 'wrong-v99' })); + + const result = await protocol.loadState(); + expect(result).toBeNull(); + }); + + it('should return null for corrupted file', async () => { + const filePath = path.resolve(tempDir, '.aiox/immortality/protocol-state.json'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, 'not json at all'); + + const result = await protocol.loadState(); + expect(result).toBeNull(); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // STATS + // ═════════════════════════════════════════════════════════════════════════════ + + describe('getStats()', () => { + it('should return empty stats for fresh protocol', () => { + const stats = protocol.getStats(); + expect(stats.totalAgents).toBe(0); + expect(stats.totalRevivals).toBe(0); + expect(stats.totalSnapshots).toBe(0); + expect(stats.totalHeartbeats).toBe(0); + expect(stats.monitoringActive).toBe(0); + expect(stats.dependencyEdges).toBe(0); + }); + + it('should count agents by status', () => { + protocol.registerAgent('agent-1'); + protocol.registerAgent('agent-2'); + protocol.startMonitoring('agent-1'); + + const stats = protocol.getStats(); + expect(stats.totalAgents).toBe(2); + expect(stats.byStatus[AgentStatus.ALIVE]).toBe(1); + expect(stats.byStatus[AgentStatus.REGISTERED]).toBe(1); + }); + + it('should count heartbeats and snapshots', () => { + protocol.registerAgent('agent-1'); + protocol.heartbeat('agent-1'); + protocol.heartbeat('agent-1'); + protocol.createSnapshot('agent-1', { x: 1 }); + + const stats = protocol.getStats(); + expect(stats.totalHeartbeats).toBe(2); + expect(stats.totalSnapshots).toBe(1); + }); + + it('should count dependency edges', () => { + protocol.registerAgent('a'); + protocol.registerAgent('b'); + protocol.registerAgent('c'); + protocol.declareDependency('b', 'a'); + protocol.declareDependency('c', 'a'); + + const stats = protocol.getStats(); + expect(stats.dependencyEdges).toBe(2); + }); + + it('should count monitoring active', () => { + protocol.registerAgent('agent-1'); + protocol.registerAgent('agent-2'); + protocol.startMonitoring('agent-1'); + protocol.startMonitoring('agent-2'); + + const stats = protocol.getStats(); + expect(stats.monitoringActive).toBe(2); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // DESTROY + // ═════════════════════════════════════════════════════════════════════════════ + + describe('destroy()', () => { + it('should stop all monitoring', () => { + protocol.registerAgent('agent-1'); + protocol.registerAgent('agent-2'); + protocol.startMonitoring('agent-1'); + protocol.startMonitoring('agent-2'); + + protocol.destroy(); + + expect(protocol._heartbeatCheckers.size).toBe(0); + expect(protocol._snapshotTimers.size).toBe(0); + }); + + it('should clear all agents', () => { + protocol.registerAgent('agent-1'); + protocol.destroy(); + expect(protocol.agents.size).toBe(0); + }); + + it('should clear all dependencies', () => { + protocol.registerAgent('a'); + protocol.registerAgent('b'); + protocol.declareDependency('b', 'a'); + protocol.destroy(); + expect(protocol._dependencies.size).toBe(0); + }); + + it('should remove all event listeners', () => { + protocol.on('heartbeat', () => {}); + protocol.on('snapshot', () => {}); + protocol.destroy(); + expect(protocol.listenerCount('heartbeat')).toBe(0); + expect(protocol.listenerCount('snapshot')).toBe(0); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // SNAPSHOT AUTO-TIMER + // ═════════════════════════════════════════════════════════════════════════════ + + describe('Automatic Snapshot Timer', () => { + it('should create automatic snapshots when monitoring with state data', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + + // Enviar heartbeat com state data + protocol.heartbeat('agent-1', { memory: 42 }); + + const snapHandler = jest.fn(); + protocol.on(Events.SNAPSHOT, snapHandler); + + // Avancar ate o intervalo de snapshot (5000ms) + jest.advanceTimersByTime(5000); + + expect(snapHandler).toHaveBeenCalled(); + }); + + it('should NOT create snapshot if agent has no state data', () => { + protocol.registerAgent('agent-1'); + protocol.startMonitoring('agent-1'); + + // Heartbeat sem state data + protocol.heartbeat('agent-1'); + + const snapHandler = jest.fn(); + protocol.on(Events.SNAPSHOT, snapHandler); + + jest.advanceTimersByTime(5000); + + expect(snapHandler).not.toHaveBeenCalled(); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // ERROR HANDLING + // ═════════════════════════════════════════════════════════════════════════════ + + describe('Error Handling', () => { + it('should guard emit(error) with listenerCount', async () => { + // Sem listener de erro, nao deve lancar + protocol.registerAgent('agent-1'); + // Forcando erro de persistencia com diretorio invalido + protocol.config.snapshotDir = '/dev/null/impossible/path'; + expect(() => { + protocol.createSnapshot('agent-1', { test: true }); + }).not.toThrow(); + }); + + it('should emit error event when listener is attached and persist fails', async () => { + const errorHandler = jest.fn(); + protocol.on('error', errorHandler); + + protocol.registerAgent('agent-1'); + protocol.config.snapshotDir = '/dev/null/impossible/path'; + protocol.createSnapshot('agent-1', { test: true }); + + // Aguardar queue de persistencia + await protocol._saveQueue; + + expect(errorHandler).toHaveBeenCalled(); + }); + }); +});