From 7c06ac42ae847136792a44f6d7f70bc3bc5d1f44 Mon Sep 17 00:00:00 2001 From: rafaelscosta Date: Sat, 9 May 2026 04:38:06 -0300 Subject: [PATCH] feat(resilience): add agent immortality foundation [Story 482.1] --- .aiox-core/core/index.js | 6 + .aiox-core/core/resilience/README.md | 38 + .../core/resilience/agent-immortality.js | 666 ++++++++++++++++++ .aiox-core/core/resilience/index.js | 7 + .aiox-core/data/entity-registry.yaml | 51 +- .aiox-core/install-manifest.yaml | 24 +- .../EPIC-482-AGENT-IMMORTALITY.md | 36 + ...-482.1-AUTOPSY-REINCARNATION-FOUNDATION.md | 190 +++++ package.json | 2 + .../core/resilience/agent-immortality.test.js | 242 +++++++ 10 files changed, 1252 insertions(+), 10 deletions(-) create mode 100644 .aiox-core/core/resilience/README.md create mode 100644 .aiox-core/core/resilience/agent-immortality.js create mode 100644 .aiox-core/core/resilience/index.js create mode 100644 docs/stories/epic-482-agent-immortality/EPIC-482-AGENT-IMMORTALITY.md create mode 100644 docs/stories/epic-482-agent-immortality/STORY-482.1-AUTOPSY-REINCARNATION-FOUNDATION.md create mode 100644 tests/core/resilience/agent-immortality.test.js diff --git a/.aiox-core/core/index.js b/.aiox-core/core/index.js index 9e02e59b35..736d8b2799 100644 --- a/.aiox-core/core/index.js +++ b/.aiox-core/core/index.js @@ -39,6 +39,9 @@ const healthCheck = require('./health-check'); // External executor delegation const externalExecutors = require('./external-executors'); +// Resilience primitives +const resilience = require('./resilience'); + /** * Core module exports */ @@ -88,6 +91,9 @@ module.exports = { // External executors externalExecutors, + // Resilience + resilience, + // Version info version: '2.0.0', moduleName: 'core', diff --git a/.aiox-core/core/resilience/README.md b/.aiox-core/core/resilience/README.md new file mode 100644 index 0000000000..5cc110f45a --- /dev/null +++ b/.aiox-core/core/resilience/README.md @@ -0,0 +1,38 @@ +# AIOX Resilience + +Core resilience modules that preserve agent execution continuity after fatal failures. + +## Agent Immortality Protocol + +```js +const { + AgentImmortalityProtocol, + CauseOfDeath, +} = require('@aiox-squads/core/resilience'); + +const protocol = new AgentImmortalityProtocol(process.cwd()); + +const result = protocol.captureFailure({ + agentState: { + id: 'dev-1', + lastGoal: 'Implement story 482.1', + lastSuccessfulStep: 'Created tests', + currentAction: 'retry failing tool call', + workingMemory: ['large history omitted', 'last useful step'], + criticalVariables: { storyId: '482.1' }, + }, + error: new Error('Tool execution failed: invalid schema'), +}); + +console.log(result.reincarnationContext); +console.log(result.report.cause === CauseOfDeath.TOOL_EXECUTION_FAILURE); +``` + +The protocol records: + +- a compact autopsy report, without rehydrating the full failed context; +- a reincarnation queue item with prevention directives; +- a delta state commit that can be replayed later; +- an evolution event for repeated failure-pattern analysis. + +Storage defaults to `.aiox/immortality/` inside the project root. diff --git a/.aiox-core/core/resilience/agent-immortality.js b/.aiox-core/core/resilience/agent-immortality.js new file mode 100644 index 0000000000..00e8a5e7bc --- /dev/null +++ b/.aiox-core/core/resilience/agent-immortality.js @@ -0,0 +1,666 @@ +'use strict'; + +/** + * Agent Immortality Protocol + * + * Deterministic resilience primitives for turning fatal agent failures into + * compact recovery context for the next execution attempt. + * + * @module core/resilience/agent-immortality + * @created Story 482.1 - Agent Immortality Phase 1 + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const EventEmitter = require('events'); + +const SCHEMA_VERSION = 'aiox-agent-immortality-v1'; + +const CauseOfDeath = Object.freeze({ + CONTEXT_OVERFLOW: 'CONTEXT_OVERFLOW', + TOOL_EXECUTION_FAILURE: 'TOOL_EXECUTION_FAILURE', + RECURSIVE_LOOP: 'RECURSIVE_LOOP', + EXTERNAL_API_ERROR: 'EXTERNAL_API_ERROR', + UNKNOWN: 'UNKNOWN', +}); + +const QueueStatus = Object.freeze({ + QUEUED: 'queued', + CLAIMED: 'claimed', + COMPLETED: 'completed', +}); + +const Events = Object.freeze({ + AUTOPSY_RECORDED: 'autopsy-recorded', + REINCARNATION_ENQUEUED: 'reincarnation-enqueued', + REINCARNATION_CLAIMED: 'reincarnation-claimed', + STATE_COMMITTED: 'state-committed', + EVOLUTION_RECORDED: 'evolution-recorded', +}); + +const DEFAULT_CONFIG = Object.freeze({ + dataDir: '.aiox/immortality', + autopsyFile: 'autopsies.json', + queueFile: 'reincarnation-queue.json', + stateCommitFile: 'state-commits.json', + evolutionFile: 'evolution-log.json', + memoryTailSize: 2, + maxTraceChars: 2000, + maxLegacyChars: 2400, + lockRetryAttempts: 80, + lockRetryMs: 10, + lockStaleMs: 30000, +}); + +function nextId(prefix) { + const randomId = typeof crypto.randomUUID === 'function' + ? crypto.randomUUID().slice(0, 8) + : crypto.randomBytes(4).toString('hex'); + return `${prefix}-${Date.now().toString(36)}-${process.pid.toString(36)}-${randomId}`; +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function readJsonArray(filePath) { + try { + if (!fs.existsSync(filePath)) return []; + const raw = fs.readFileSync(filePath, 'utf8').trim(); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + quarantineJsonFile(filePath); + return []; + } +} + +function writeJsonAtomic(filePath, value) { + ensureDir(path.dirname(filePath)); + const tmpPath = `${filePath}.${process.pid}.tmp`; + fs.writeFileSync(tmpPath, `${JSON.stringify(value, null, 2)}\n`); + fs.renameSync(tmpPath, filePath); +} + +function quarantineJsonFile(filePath) { + try { + if (!fs.existsSync(filePath)) return; + fs.renameSync(filePath, `${filePath}.corrupt-${Date.now()}-${process.pid}`); + } catch { + // Best-effort: resilience capture should continue even if quarantine fails. + } +} + +function sleepSync(ms) { + if (ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function removeStaleLock(lockPath, staleMs) { + try { + const stat = fs.statSync(lockPath); + if (Date.now() - stat.mtimeMs > staleMs) { + fs.unlinkSync(lockPath); + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } +} + +function acquireFileLock(filePath, options = {}) { + ensureDir(path.dirname(filePath)); + const lockPath = `${filePath}.lock`; + const attempts = options.lockRetryAttempts ?? DEFAULT_CONFIG.lockRetryAttempts; + const retryMs = options.lockRetryMs ?? DEFAULT_CONFIG.lockRetryMs; + const staleMs = options.lockStaleMs ?? DEFAULT_CONFIG.lockStaleMs; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const fd = fs.openSync(lockPath, 'wx'); + fs.writeFileSync(fd, `${process.pid}\n${new Date().toISOString()}\n`); + return { fd, lockPath }; + } catch (error) { + if (error.code !== 'EEXIST') { + throw error; + } + removeStaleLock(lockPath, staleMs); + sleepSync(retryMs); + } + } + + throw new Error(`Failed to acquire lock for ${filePath}`); +} + +function releaseFileLock(lock) { + try { + fs.closeSync(lock.fd); + } catch { + // Ignore close errors; unlink is the important cleanup. + } + try { + fs.unlinkSync(lock.lockPath); + } catch { + // Another recovery path may have removed a stale lock first. + } +} + +function withFileLock(filePath, options, operation) { + const lock = acquireFileLock(filePath, options); + try { + return operation(); + } finally { + releaseFileLock(lock); + } +} + +function cloneValue(value) { + if (value === undefined) return undefined; + if (value === null) return null; + + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return String(value); + } +} + +function truncate(value, maxChars) { + const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2); + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}\n[truncated:${text.length - maxChars}]`; +} + +function normalizeError(error) { + if (error instanceof Error) { + return { + name: error.name || 'Error', + message: error.message || 'Unknown error', + stack: error.stack || error.message || '', + }; + } + + if (error && typeof error === 'object') { + return { + name: error.name || 'Error', + message: error.message || JSON.stringify(error), + stack: error.stack || error.trace || JSON.stringify(error, null, 2), + }; + } + + return { + name: 'Error', + message: String(error || 'Unknown error'), + stack: String(error || 'Unknown error'), + }; +} + +// Ordering is intentional: text aggregates name, message and stack, so keyword +// overlap is common. Keep overflow and recursion checks before tool/API checks; +// the text is lowercased, so errno fragments such as enoent/econn stay lowercase. +function diagnoseCause(error) { + const normalized = normalizeError(error); + const text = `${normalized.name} ${normalized.message} ${normalized.stack}`.toLowerCase(); + + if (/\b(context|token|window|length|too large|max tokens|overflow)\b/.test(text)) { + return CauseOfDeath.CONTEXT_OVERFLOW; + } + if (/\b(recursive|recursion|loop|same action|repeated action|maximum call stack)\b/.test(text)) { + return CauseOfDeath.RECURSIVE_LOOP; + } + if (/\b(tool|function call|schema|invalid arguments|command failed|spawn|enoent)\b/.test(text)) { + return CauseOfDeath.TOOL_EXECUTION_FAILURE; + } + if (/\b(api|network|auth|unauthorized|forbidden|timeout|econn|http|rate limit)\b/.test(text)) { + return CauseOfDeath.EXTERNAL_API_ERROR; + } + + return CauseOfDeath.UNKNOWN; +} + +function normalizeState(state = {}) { + if (!state || typeof state !== 'object') { + return { + id: 'unknown-agent', + name: 'unknown', + workingMemory: [], + criticalVariables: {}, + lastGoal: '', + }; + } + + return { + id: state.id || state.agentId || 'unknown-agent', + name: state.name || state.agentName || state.id || 'unknown', + workingMemory: Array.isArray(state.workingMemory) ? state.workingMemory : [], + criticalVariables: state.criticalVariables && typeof state.criticalVariables === 'object' + ? state.criticalVariables + : {}, + lastGoal: state.lastGoal || state.goal || '', + lastSuccessfulStep: state.lastSuccessfulStep || state.lastCheckpoint || '', + currentAction: state.currentAction || state.current_action || state.action || '', + suggestedPivot: state.suggestedPivot || state.suggested_pivot || '', + diff: state.diff || state.delta || state.lastDiff || null, + }; +} + +function buildLegacy(state, cause, options = {}) { + const normalized = normalizeState(state); + const tailSize = options.memoryTailSize ?? DEFAULT_CONFIG.memoryTailSize; + const memoryTail = normalized.workingMemory.slice(Math.max(0, normalized.workingMemory.length - tailSize)); + const omittedMemoryItems = Math.max(0, normalized.workingMemory.length - memoryTail.length); + + const legacy = { + goal: normalized.lastGoal, + lastSuccessfulStep: normalized.lastSuccessfulStep, + currentAction: normalized.currentAction, + criticalVariables: cloneValue(normalized.criticalVariables), + memoryTail: cloneValue(memoryTail), + omittedMemoryItems, + diff: cloneValue(normalized.diff), + cause, + }; + + const summary = [ + `Last goal: ${legacy.goal || 'unknown'}`, + `Last successful step: ${legacy.lastSuccessfulStep || 'unknown'}`, + `Current action: ${legacy.currentAction || 'unknown'}`, + `Critical variables: ${Object.keys(legacy.criticalVariables || {}).join(', ') || 'none'}`, + `Omitted memory items: ${legacy.omittedMemoryItems}`, + `Memory tail: ${truncate(legacy.memoryTail, options.maxLegacyChars ?? DEFAULT_CONFIG.maxLegacyChars)}`, + legacy.diff ? `Delta: ${truncate(legacy.diff, options.maxLegacyChars ?? DEFAULT_CONFIG.maxLegacyChars)}` : '', + ].filter(Boolean).join('\n'); + + return { + legacy, + summary: truncate(summary, options.maxLegacyChars ?? DEFAULT_CONFIG.maxLegacyChars), + }; +} + +function suggestPivot(cause, state) { + const normalized = normalizeState(state); + if (normalized.suggestedPivot) return normalized.suggestedPivot; + + switch (cause) { + case CauseOfDeath.CONTEXT_OVERFLOW: + return 'Resume from a compact checkpoint and keep only the last successful step in active context.'; + case CauseOfDeath.TOOL_EXECUTION_FAILURE: + return 'Validate the tool schema and try an alternate tool or a dry-run before retrying.'; + case CauseOfDeath.RECURSIVE_LOOP: + return 'Change strategy before retrying the same action.'; + case CauseOfDeath.EXTERNAL_API_ERROR: + return 'Verify credentials/network state and use an offline fallback when possible.'; + default: + return 'Inspect the autopsy report before choosing the next action.'; + } +} + +function generateDirectives(cause, error, state = {}) { + const normalizedError = normalizeError(error); + const normalizedState = normalizeState(state); + const directives = []; + + if (normalizedState.currentAction) { + directives.push(`Do not immediately repeat action: ${normalizedState.currentAction}`); + } + + switch (cause) { + case CauseOfDeath.CONTEXT_OVERFLOW: + directives.push('Use the compressed legacy summary; do not rehydrate the full previous context.'); + directives.push('Summarize any large memory block before adding it to active context.'); + break; + case CauseOfDeath.TOOL_EXECUTION_FAILURE: + directives.push('Validate tool parameters against the schema before retrying.'); + directives.push(`Treat the previous tool failure as toxic until parameters change: ${truncate(normalizedError.message, 160)}`); + break; + case CauseOfDeath.RECURSIVE_LOOP: + directives.push('Check execution history before repeating an action.'); + directives.push('After two similar failures, pivot strategy or escalate.'); + break; + case CauseOfDeath.EXTERNAL_API_ERROR: + directives.push('Check credentials, rate limits and network availability before retrying.'); + directives.push('Prefer a cached/offline fallback if the external dependency is unstable.'); + break; + default: + directives.push('Read the autopsy report and proceed with an explicit recovery plan.'); + } + + return [...new Set(directives)]; +} + +function buildReincarnationContext(report) { + return [ + '[AIOX REINCARNATION CONTEXT]', + `Previous agent: ${report.deceasedAgentId}`, + `Cause of death: ${report.cause}`, + `Report id: ${report.id}`, + '', + 'Prevention directives:', + ...report.preventionDirectives.map((directive, index) => `${index + 1}. ${directive}`), + '', + 'Immunity token:', + `- forbidden_action: ${report.immunityToken.forbiddenAction || 'none'}`, + `- suggested_pivot: ${report.immunityToken.suggestedPivot}`, + '', + 'Legacy summary:', + report.legacySummary, + ].join('\n'); +} + +class StateCommitLog { + constructor(projectRoot = process.cwd(), options = {}) { + this.projectRoot = projectRoot; + this.config = { ...DEFAULT_CONFIG, ...options }; + this.filePath = path.join(this.projectRoot, this.config.dataDir, this.config.stateCommitFile); + } + + commitDelta(agentId, delta = {}, metadata = {}) { + return withFileLock(this.filePath, this.config, () => { + const commits = this.list(); + const previous = commits.findLast(commit => commit.agentId === agentId); + const commit = { + id: nextId('state'), + schemaVersion: SCHEMA_VERSION, + agentId, + previousId: previous?.id || null, + timestamp: new Date().toISOString(), + delta: cloneValue(delta || {}), + metadata: cloneValue(metadata || {}), + }; + + commits.push(commit); + writeJsonAtomic(this.filePath, commits); + return cloneValue(commit); + }); + } + + list(agentId = null) { + const commits = readJsonArray(this.filePath); + return agentId ? commits.filter(commit => commit.agentId === agentId) : commits; + } + + latest(agentId) { + return this.list(agentId).at(-1) || null; + } +} + +class EvolutionLog { + constructor(projectRoot = process.cwd(), options = {}) { + this.projectRoot = projectRoot; + this.config = { ...DEFAULT_CONFIG, ...options }; + this.filePath = path.join(this.projectRoot, this.config.dataDir, this.config.evolutionFile); + } + + record(report) { + return withFileLock(this.filePath, this.config, () => { + const events = this.list(); + const event = { + id: nextId('evolution'), + schemaVersion: SCHEMA_VERSION, + reportId: report.id, + agentId: report.deceasedAgentId, + timestamp: new Date().toISOString(), + cause: report.cause, + forbiddenAction: report.immunityToken?.forbiddenAction || '', + suggestedPivot: report.immunityToken?.suggestedPivot || '', + directives: cloneValue(report.preventionDirectives || []), + }; + + events.push(event); + writeJsonAtomic(this.filePath, events); + return { + event: cloneValue(event), + patterns: this.getPatterns(events), + toxicActions: this.getToxicActions(events), + }; + }); + } + + list() { + return readJsonArray(this.filePath); + } + + getPatterns(events = this.list()) { + return events.reduce((patterns, event) => { + const key = `${event.cause}:${event.forbiddenAction || 'unknown-action'}`; + patterns[key] = (patterns[key] || 0) + 1; + return patterns; + }, {}); + } + + getToxicActions(events = this.list()) { + return events.reduce((actions, event) => { + if (!event.forbiddenAction) return actions; + actions[event.forbiddenAction] = (actions[event.forbiddenAction] || 0) + 1; + return actions; + }, {}); + } +} + +class ReincarnationQueue { + constructor(projectRoot = process.cwd(), options = {}) { + this.projectRoot = projectRoot; + this.config = { ...DEFAULT_CONFIG, ...options }; + this.filePath = path.join(this.projectRoot, this.config.dataDir, this.config.queueFile); + } + + enqueue(report) { + return withFileLock(this.filePath, this.config, () => { + const queue = this.list(); + const item = { + id: nextId('reincarnation'), + schemaVersion: SCHEMA_VERSION, + status: QueueStatus.QUEUED, + createdAt: new Date().toISOString(), + claimedAt: null, + reportId: report.id, + deceasedAgentId: report.deceasedAgentId, + reincarnationContext: report.reincarnationContext, + preventionDirectives: cloneValue(report.preventionDirectives || []), + immunityToken: cloneValue(report.immunityToken || {}), + }; + + queue.push(item); + writeJsonAtomic(this.filePath, queue); + return cloneValue(item); + }); + } + + claimNext() { + return withFileLock(this.filePath, this.config, () => { + const queue = this.list(); + const index = queue.findIndex(item => item.status === QueueStatus.QUEUED); + if (index === -1) return null; + + queue[index] = { + ...queue[index], + status: QueueStatus.CLAIMED, + claimedAt: new Date().toISOString(), + }; + writeJsonAtomic(this.filePath, queue); + return cloneValue(queue[index]); + }); + } + + list(status = null) { + const queue = readJsonArray(this.filePath); + return status ? queue.filter(item => item.status === status) : queue; + } +} + +class AutopsyEngine extends EventEmitter { + constructor(projectRoot = process.cwd(), options = {}) { + super(); + this.projectRoot = projectRoot; + this.config = { ...DEFAULT_CONFIG, ...options }; + this.filePath = path.join(this.projectRoot, this.config.dataDir, this.config.autopsyFile); + this.stateCommitLog = options.stateCommitLog || new StateCommitLog(projectRoot, this.config); + } + + recordDeath(state, error, metadata = {}) { + const normalizedState = normalizeState(state); + const normalizedError = normalizeError(error); + const cause = metadata.cause || diagnoseCause(error); + const stateCommit = this.stateCommitLog.commitDelta( + normalizedState.id, + normalizedState.diff || { + currentAction: normalizedState.currentAction, + lastSuccessfulStep: normalizedState.lastSuccessfulStep, + criticalVariables: normalizedState.criticalVariables, + }, + { cause, source: 'autopsy' }, + ); + const { legacy, summary } = buildLegacy(normalizedState, cause, this.config); + const preventionDirectives = generateDirectives(cause, error, normalizedState); + + const report = { + id: nextId('autopsy'), + schemaVersion: SCHEMA_VERSION, + deceasedAgentId: normalizedState.id, + deceasedAgentName: normalizedState.name, + timestamp: new Date().toISOString(), + cause, + errorName: normalizedError.name, + errorMessage: normalizedError.message, + errorTrace: truncate(normalizedError.stack, this.config.maxTraceChars), + legacy, + legacySummary: summary, + preventionDirectives, + immunityToken: { + forbiddenAction: normalizedState.currentAction || '', + suggestedPivot: suggestPivot(cause, normalizedState), + reason: `Previous instance failed with ${cause}: ${truncate(normalizedError.message, 220)}`, + }, + stateCommitId: stateCommit.id, + metadata: cloneValue(metadata || {}), + }; + report.reincarnationContext = buildReincarnationContext(report); + + withFileLock(this.filePath, this.config, () => { + const reports = this.listReports(); + reports.push(report); + writeJsonAtomic(this.filePath, reports); + }); + this.emit(Events.AUTOPSY_RECORDED, report); + + return cloneValue(report); + } + + getReincarnationContext() { + const last = this.getLastReport(); + return last?.reincarnationContext || ''; + } + + getLastReport() { + return this.listReports().at(-1) || null; + } + + listReports() { + return readJsonArray(this.filePath); + } +} + +class AgentImmortalityProtocol extends EventEmitter { + constructor(projectRoot = process.cwd(), options = {}) { + super(); + this.projectRoot = projectRoot; + this.config = { ...DEFAULT_CONFIG, ...options }; + this.stateCommitLog = options.stateCommitLog || new StateCommitLog(projectRoot, this.config); + this.autopsyEngine = options.autopsyEngine || new AutopsyEngine(projectRoot, { + ...this.config, + stateCommitLog: this.stateCommitLog, + }); + this.reincarnationQueue = options.reincarnationQueue || new ReincarnationQueue(projectRoot, this.config); + this.evolutionLog = options.evolutionLog || new EvolutionLog(projectRoot, this.config); + this.gotchasMemory = options.gotchasMemory || null; + } + + captureFailure({ agentState, error, metadata = {} } = {}) { + if (!agentState) { + throw new Error('agentState is required'); + } + if (!error) { + throw new Error('error is required'); + } + + const report = this.autopsyEngine.recordDeath(agentState, error, metadata); + const queueItem = this.reincarnationQueue.enqueue(report); + const evolution = this.evolutionLog.record(report); + + this._trackGotcha(report); + this.emit(Events.AUTOPSY_RECORDED, report); + this.emit(Events.REINCARNATION_ENQUEUED, queueItem); + this.emit(Events.EVOLUTION_RECORDED, evolution.event); + + return { + report, + queueItem, + evolution, + reincarnationContext: report.reincarnationContext, + }; + } + + claimReincarnation() { + const item = this.reincarnationQueue.claimNext(); + if (item) { + this.emit(Events.REINCARNATION_CLAIMED, item); + } + return item; + } + + commitState(agentId, delta = {}, metadata = {}) { + const commit = this.stateCommitLog.commitDelta(agentId, delta, metadata); + this.emit(Events.STATE_COMMITTED, commit); + return commit; + } + + getEvolutionPatterns() { + return this.evolutionLog.getPatterns(); + } + + _trackGotcha(report) { + if (!this.gotchasMemory || typeof this.gotchasMemory.trackError !== 'function') { + return; + } + + try { + this.gotchasMemory.trackError({ + message: report.errorMessage, + stack: report.errorTrace, + category: 'runtime', + context: { + source: 'agent-immortality', + cause: report.cause, + reportId: report.id, + directives: report.preventionDirectives, + }, + }); + } catch (error) { + this.emit('gotcha-track-failed', error); + } + } +} + +module.exports = AgentImmortalityProtocol; +module.exports.AgentImmortalityProtocol = AgentImmortalityProtocol; +module.exports.AutopsyEngine = AutopsyEngine; +module.exports.ReincarnationQueue = ReincarnationQueue; +module.exports.StateCommitLog = StateCommitLog; +module.exports.EvolutionLog = EvolutionLog; +module.exports.CauseOfDeath = CauseOfDeath; +module.exports.QueueStatus = QueueStatus; +module.exports.Events = Events; +module.exports.DEFAULT_CONFIG = DEFAULT_CONFIG; +module.exports.SCHEMA_VERSION = SCHEMA_VERSION; +module.exports._internal = { + buildLegacy, + buildReincarnationContext, + diagnoseCause, + generateDirectives, + normalizeError, + normalizeState, + readJsonArray, +}; diff --git a/.aiox-core/core/resilience/index.js b/.aiox-core/core/resilience/index.js new file mode 100644 index 0000000000..5458d92e7b --- /dev/null +++ b/.aiox-core/core/resilience/index.js @@ -0,0 +1,7 @@ +'use strict'; + +const agentImmortality = require('./agent-immortality'); + +module.exports = { + ...agentImmortality, +}; diff --git a/.aiox-core/data/entity-registry.yaml b/.aiox-core/data/entity-registry.yaml index 380d535862..1cdcfd85c6 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-05-08T21:44:50.560Z' - entityCount: 755 + lastUpdated: '2026-05-09T07:56:57.030Z' + entityCount: 757 checksumAlgorithm: sha256 resolutionRate: 100 entities: @@ -8233,16 +8233,19 @@ entities: - output-formatter - yaml-validator - registry-loader + - resilience-index externalDeps: [] plannedDeps: + - health-check-index + - external-executors-index - health-check lifecycle: experimental adaptability: score: 0.4 constraints: [] extensionPoints: [] - checksum: sha256:970b617b0e723e8248065f698eca2298a5a0b72e3e43ee820ea85294555736e2 - lastVerified: '2026-03-11T00:48:55.872Z' + checksum: sha256:b09d5546bdb1507c60ddc5dc9b48760b55e4e6a4ccc8fdcb63e168e8ea334b13 + lastVerified: '2026-05-09T07:56:57.018Z' code-intel-client: path: .aiox-core/core/code-intel/code-intel-client.js layer: L1 @@ -12991,6 +12994,46 @@ entities: extensionPoints: [] checksum: sha256:a67e843cf61e823e0c1b73efdb2d67d268a798fc0c25505a199c1da130d4fe36 lastVerified: '2026-05-08T19:10:54.503Z' + agent-immortality: + path: .aiox-core/core/resilience/agent-immortality.js + layer: L1 + type: module + purpose: truncate(summary, options.maxLegacyChars ?? DEFAULT_CONFIG.maxLegacyChars), + keywords: + - agent + - immortality + usedBy: + - resilience-index + dependencies: [] + externalDeps: [] + plannedDeps: [] + lifecycle: production + adaptability: + score: 0.4 + constraints: [] + extensionPoints: [] + checksum: sha256:d1612c672e91a802924c4a61d8beade66d5740487ca0a244950a2c8249a70962 + lastVerified: '2026-05-09T07:56:57.020Z' + resilience-index: + path: .aiox-core/core/resilience/index.js + layer: L1 + type: module + purpose: Entity at .aiox-core/core/resilience/index.js + keywords: + - index + usedBy: + - index + dependencies: + - agent-immortality + externalDeps: [] + plannedDeps: [] + lifecycle: production + adaptability: + score: 0.4 + constraints: [] + extensionPoints: [] + checksum: sha256:5acbb55fbbf25fc052c0ee2eac5f35d30228144e9f426083b89aabc9e54d084f + lastVerified: '2026-05-09T07:56:57.021Z' 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 121b392238..f17dea0d55 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.1.17 -generated_at: "2026-05-09T07:21:15.059Z" +generated_at: "2026-05-09T08:01:26.461Z" generator: scripts/generate-install-manifest.js -file_count: 1125 +file_count: 1128 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -793,9 +793,9 @@ files: type: core size: 1486 - path: core/index.js - hash: sha256:7258643ec31b5389e1abb0f0af48e9f5e569336f5548c31cfe22e229e7254e13 + hash: sha256:b09d5546bdb1507c60ddc5dc9b48760b55e4e6a4ccc8fdcb63e168e8ea334b13 type: core - size: 2723 + size: 2824 - path: core/manifest/manifest-generator.js hash: sha256:81b796990dd747bbb9785d205dbe5f6d5556754fc51745fb84b7ce4675acbdbf type: core @@ -1084,6 +1084,18 @@ files: hash: sha256:d9805ce445661a3a2d9e6c73bf35cbd1bc2403419825442cd7b8f01cc1409cb3 type: core size: 9179 + - path: core/resilience/agent-immortality.js + hash: sha256:d1612c672e91a802924c4a61d8beade66d5740487ca0a244950a2c8249a70962 + type: core + size: 21663 + - path: core/resilience/index.js + hash: sha256:5acbb55fbbf25fc052c0ee2eac5f35d30228144e9f426083b89aabc9e54d084f + type: core + size: 118 + - path: core/resilience/README.md + hash: sha256:57b60c664aac51299a8576be57eff702d716fb48595af342e534c1191355eb85 + type: core + size: 1139 - path: core/session/context-detector.js hash: sha256:5537563d5dfc613e16fd610c9e1407e7811c4f19745a78a4fc81c34af20000f4 type: core @@ -1285,9 +1297,9 @@ files: type: data size: 9590 - path: data/entity-registry.yaml - hash: sha256:848caa6041aad92acc1fb30c6917bd06d26a91592f553e33e4b7b650ffb560b6 + hash: sha256:4b701001f233800095b8830d8406209d1074bb2847fd5c279261b52452a2d71d type: data - size: 527978 + size: 529270 - path: data/learned-patterns.yaml hash: sha256:24ac0b160615583a0ff783d3da8af80b7f94191575d6db2054ec8e10a3f945dc type: data diff --git a/docs/stories/epic-482-agent-immortality/EPIC-482-AGENT-IMMORTALITY.md b/docs/stories/epic-482-agent-immortality/EPIC-482-AGENT-IMMORTALITY.md new file mode 100644 index 0000000000..2b00dfb440 --- /dev/null +++ b/docs/stories/epic-482-agent-immortality/EPIC-482-AGENT-IMMORTALITY.md @@ -0,0 +1,36 @@ +# Epic 482: Agent Immortality Protocol + +## Metadata + +| Campo | Valor | +|-------|-------| +| Epic ID | 482 | +| Source Issue | #482 | +| Issue URL | https://github.com/SynkraAI/aiox-core/issues/482 | +| Status | Ready for Review | +| Priority | P2 | +| Repository | SynkraAI/aiox-core | + +## Objetivo + +Implementar uma base de resiliência que transforme falhas fatais de agentes em contexto de recuperação compacto, persistente e reutilizável pela próxima execução. + +## Evidência de triagem + +- Issue #482 segue aberto e é o único issue vivo após o merge do #717. +- PR #576 está fechado sem merge e sem commits recuperáveis pela API do PR. +- PR #590 está fechado sem merge; continha uma implementação grande de `agent-immortality.js`, mas não entrou na `main`. +- `main` já possui `CircuitBreaker` e `GotchasMemory`, mas não possui Autopsy Engine, Reincarnation Queue, State Commit delta nem Evolution Log. +- O anexo `Autopsy.Engine.js` é TypeScript/protótipo; a entrega precisa ser CommonJS, determinística e compatível com a estrutura atual. + +## Slice aprovado + +Story 482.1 entrega a fundação de Phase 1: + +- Autopsy Engine. +- Reincarnation Queue. +- State Commit delta. +- Evolution Log. +- Integração opcional com GotchasMemory. + +Heartbeat/auto-revival contínuo fica fora deste slice para evitar reintroduzir um PR antigo grande sem validação incremental. diff --git a/docs/stories/epic-482-agent-immortality/STORY-482.1-AUTOPSY-REINCARNATION-FOUNDATION.md b/docs/stories/epic-482-agent-immortality/STORY-482.1-AUTOPSY-REINCARNATION-FOUNDATION.md new file mode 100644 index 0000000000..a3c635f013 --- /dev/null +++ b/docs/stories/epic-482-agent-immortality/STORY-482.1-AUTOPSY-REINCARNATION-FOUNDATION.md @@ -0,0 +1,190 @@ +# Story 482.1: Autopsy and Reincarnation Foundation + +## Metadata + +| Campo | Valor | +|-------|-------| +| Story ID | 482.1 | +| Epic | [482 - Agent Immortality Protocol](./EPIC-482-AGENT-IMMORTALITY.md) | +| Status | Ready for Review | +| Executor | @dev | +| Quality Gate | @architect | +| quality_gate_tools | npm test focused, npm run lint, npm run typecheck, npm run validate:manifest | +| Points | 8 | +| Priority | P2 | +| Source Issue | #482 | +| Issue URL | https://github.com/SynkraAI/aiox-core/issues/482 | +| Implementation Repository | SynkraAI/aiox-core | + +## Status + +- [x] Draft +- [x] Ready for implementation +- [x] Ready for Review +- [ ] Done + +## Executor Assignment + +```yaml +executor: "@dev" +quality_gate: "@architect" +quality_gate_tools: + - npm test focused + - npm run lint + - npm run typecheck + - npm run validate:manifest +accountable: "pedro-valerio" +domain: "core-resilience" +deploy_type: "none" +``` + +## Story + +**As a** framework maintainer, +**I want** fatal agent failures to produce compact autopsy reports, reincarnation context and persistent learning events, +**so that** the next execution can resume with prevention directives instead of repeating the same failure mode. + +## Contexto + +Issue #482 requests a full Agent Immortality Protocol. The current `main` has foundational resilience pieces (`CircuitBreaker`, `GotchasMemory`) but no merged Autopsy Engine or Reincarnation Queue. The historical PR #590 is closed without merge, so it cannot be treated as delivered. + +This story implements the smallest complete runtime slice that produces durable recovery artifacts without starting background heartbeat loops. + +## Acceptance Criteria + +- [x] AC1: A canonical `AgentImmortalityProtocol` exists under `.aiox-core/core/resilience/` and is exported from the resilience surface. +- [x] AC2: `AutopsyEngine.recordDeath()` diagnoses cause of death for context overflow, tool failures, recursive loops, external API failures and unknown errors. +- [x] AC3: Autopsy reports include compact legacy summaries that keep only bounded memory tail and omit full failed context. +- [x] AC4: Autopsy reports include prevention directives and an immunity token with forbidden action and suggested pivot. +- [x] AC5: `ReincarnationQueue` persists queued recovery contexts and supports deterministic claim of the next queued item. +- [x] AC6: `StateCommitLog` persists delta commits with previous-id linkage per agent. +- [x] AC7: `EvolutionLog` records repeated failure patterns and toxic actions. +- [x] AC8: `AgentImmortalityProtocol.captureFailure()` coordinates autopsy, queue, state commit, evolution and optional GotchasMemory tracking. +- [x] AC9: Focused tests cover exports, autopsy compression, immunity token, state commits, queue claim, evolution and gotchas integration. +- [x] AC10: Manifest and story Dev Agent Record are updated. + +## CodeRabbit Integration + +### Story Type Analysis + +**Primary Type**: Core runtime resilience feature +**Secondary Type(s)**: Persistence, memory, recovery orchestration +**Complexity**: High + +### Specialized Agent Assignment + +**Primary Agents**: +- @dev +- @architect + +**Supporting Agents**: +- @qa +- @po + +### Quality Gate Tasks + +- [x] Pre-Commit (@dev): Run focused resilience tests. +- [x] Pre-PR (@devops): Confirm additive package surface and manifest update. +- [ ] Architecture Review (@architect): Confirm Phase 1 is intentionally narrower than closed PR #590. + +## Tasks / Subtasks + +- [x] T1: Triage issue #482, attachment and historical PRs #576/#590 against current `main`. +- [x] T2: Implement `AutopsyEngine` with deterministic cause diagnosis and compact legacy summary. +- [x] T3: Implement `ReincarnationQueue`. +- [x] T4: Implement `StateCommitLog`. +- [x] T5: Implement `EvolutionLog`. +- [x] T6: Implement `AgentImmortalityProtocol.captureFailure()` orchestration. +- [x] T7: Add focused tests. +- [x] T8: Update docs/story/manifest and run validation. + +## Dev Notes + +- Keep the implementation additive. +- Do not start background timers in this story. +- Do not copy closed PR #590 wholesale; reuse its intent only where it matches current `main`. +- Keep reincarnation context compact enough to avoid re-triggering context overflow. +- Use project-local `.aiox/immortality/` storage by default. + +## Testing + +Required focused validation: + +```bash +npm test -- --runTestsByPath tests/core/resilience/agent-immortality.test.js tests/core/resilience-regressions.test.js +npm run validate:manifest +npm run lint +npm run typecheck +``` + +## Dependencies + +- **Uses:** `GotchasMemory` as an optional integration. +- **Complements:** `CircuitBreaker` and IDS resilience primitives. +- **Follows:** Issue #483 / Semantic Handshake can later feed violations into the Evolution Log. + +## Definition of Ready + +- [x] Current issue status checked live. +- [x] Historical PRs checked and confirmed unmerged. +- [x] Prototype attachment reviewed. +- [x] Acceptance criteria are testable without external services. + +## Definition of Done + +- [x] All ACs complete. +- [x] Focused tests pass. +- [x] Manifest validation passes. +- [x] Story File List and Dev Agent Record are updated. + +## Dev Agent Record + +### Debug Log + +- Draft created from issue #482, issue attachment and live repo inspection on 2026-05-09. +- Confirmed #576 and #590 are closed without merge. +- Reviewed `Autopsy.Engine.js` attachment and mapped it to repo-native CommonJS. +- Implemented `AgentImmortalityProtocol`, `AutopsyEngine`, `ReincarnationQueue`, `StateCommitLog` and `EvolutionLog`. +- Added compact reincarnation context that avoids injecting full failed context. +- Added optional GotchasMemory tracking from `captureFailure()`. +- Added focused tests for the Phase 1 resilience foundation. +- Addressed CodeRabbit review with corrupt JSON quarantine, file-backed RMW locks, collision-resistant IDs, optional Gotchas isolation and public resilience package export. +- Validation completed: + - `npm test -- --runTestsByPath tests/core/resilience/agent-immortality.test.js tests/core/resilience-regressions.test.js` + - `npx eslint .aiox-core/core/resilience/agent-immortality.js .aiox-core/core/resilience/index.js tests/core/resilience/agent-immortality.test.js` + - `npm run generate:manifest && npm run validate:manifest` + - `npm run typecheck` + - `npm run lint` + - `npm run validate:publish` + +### Agent Model Used + +- GPT-5 Codex + +### Completion Notes + +- Phase 1 now turns fatal failures into durable autopsy reports, queued reincarnation contexts, delta state commits and evolution events. +- The implementation deliberately excludes heartbeat timers and automatic process revival to keep this slice deterministic, reviewable and safe. + +### File List + +| File | Action | +|------|--------| +| `docs/stories/epic-482-agent-immortality/EPIC-482-AGENT-IMMORTALITY.md` | Created | +| `docs/stories/epic-482-agent-immortality/STORY-482.1-AUTOPSY-REINCARNATION-FOUNDATION.md` | Created | +| `.aiox-core/core/resilience/agent-immortality.js` | Created | +| `.aiox-core/core/resilience/index.js` | Created | +| `.aiox-core/core/resilience/README.md` | Created | +| `.aiox-core/core/index.js` | Updated | +| `tests/core/resilience/agent-immortality.test.js` | Created | +| `.aiox-core/install-manifest.yaml` | Updated | +| `.aiox-core/data/entity-registry.yaml` | Updated | +| `package.json` | Updated | + +## Change Log + +| Data | Agente | Mudança | +|------|--------|---------| +| 2026-05-09 | @sm / Codex | Epic e story criados a partir do issue #482, attachment e PRs históricos fechados. | +| 2026-05-09 | @dev (Dex) | Implementada fundação Phase 1 do Agent Immortality Protocol com testes focados. | +| 2026-05-09 | @dev (Dex) | Ajustes pós-review: locks de persistência, quarentena de JSON corrompido, IDs únicos e export público de resilience. | diff --git a/package.json b/package.json index d36c32fc79..95c3e90c7c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "LICENSE" ], "exports": { + "./resilience": "./.aiox-core/core/resilience/index.js", + "./resilience/agent-immortality": "./.aiox-core/core/resilience/agent-immortality.js", "./installer/aiox-core-installer": "./packages/installer/src/installer/aiox-core-installer.js", "./installer/enterprise-detector": "./packages/installer/src/enterprise/enterprise-detector.js", "./installer/enterprise-errors": "./packages/installer/src/enterprise/enterprise-errors.js", diff --git a/tests/core/resilience/agent-immortality.test.js b/tests/core/resilience/agent-immortality.test.js new file mode 100644 index 0000000000..c5a7cd9427 --- /dev/null +++ b/tests/core/resilience/agent-immortality.test.js @@ -0,0 +1,242 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + AgentImmortalityProtocol, + AutopsyEngine, + CauseOfDeath, + EvolutionLog, + QueueStatus, + ReincarnationQueue, + StateCommitLog, +} = require('../../../.aiox-core/core/resilience'); + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'aip-test-')); +} + +function createState(overrides = {}) { + return { + id: 'dev-agent-1', + name: 'Dex', + lastGoal: 'Implement Story 482.1', + lastSuccessfulStep: 'Created failing test', + currentAction: 'retry same tool call', + workingMemory: [ + 'old context that must not be injected', + 'checkpoint one', + 'checkpoint two', + ], + criticalVariables: { + storyId: '482.1', + }, + diff: { + file: 'src/example.js', + operation: 'patch', + }, + ...overrides, + }; +} + +describe('Agent Immortality Protocol', () => { + let tempDir; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('exports the core resilience primitives', () => { + const core = require('../../../.aiox-core/core'); + const publishedResilience = require('@aiox-squads/core/resilience'); + + expect(AgentImmortalityProtocol).toBeDefined(); + expect(AutopsyEngine).toBeDefined(); + expect(ReincarnationQueue).toBeDefined(); + expect(StateCommitLog).toBeDefined(); + expect(EvolutionLog).toBeDefined(); + expect(CauseOfDeath.CONTEXT_OVERFLOW).toBe('CONTEXT_OVERFLOW'); + expect(core.resilience.AgentImmortalityProtocol).toBe(AgentImmortalityProtocol); + expect(publishedResilience.AgentImmortalityProtocol).toBe(AgentImmortalityProtocol); + }); + + test('records a compact autopsy and reincarnation context for context overflow', () => { + const engine = new AutopsyEngine(tempDir); + const report = engine.recordDeath( + createState({ currentAction: 'paste entire transcript into prompt' }), + new Error('Maximum context token length exceeded'), + ); + + expect(report.cause).toBe(CauseOfDeath.CONTEXT_OVERFLOW); + expect(report.legacy.memoryTail).toEqual(['checkpoint one', 'checkpoint two']); + expect(report.legacy.omittedMemoryItems).toBe(1); + expect(report.legacySummary).not.toContain('old context that must not be injected'); + expect(report.preventionDirectives).toEqual(expect.arrayContaining([ + 'Use the compressed legacy summary; do not rehydrate the full previous context.', + ])); + expect(report.reincarnationContext).toContain('[AIOX REINCARNATION CONTEXT]'); + expect(engine.getReincarnationContext()).toContain('CONTEXT_OVERFLOW'); + }); + + test('creates immunity tokens that forbid immediate repetition of failed actions', () => { + const engine = new AutopsyEngine(tempDir); + const report = engine.recordDeath( + createState({ currentAction: 'call broken tool with same args' }), + new Error('Tool execution failed: invalid function call schema'), + ); + + expect(report.cause).toBe(CauseOfDeath.TOOL_EXECUTION_FAILURE); + expect(report.immunityToken).toMatchObject({ + forbiddenAction: 'call broken tool with same args', + }); + expect(report.preventionDirectives.join('\n')).toContain( + 'Do not immediately repeat action: call broken tool with same args', + ); + }); + + test('persists delta state commits with previous commit linkage', () => { + const log = new StateCommitLog(tempDir); + const first = log.commitDelta('agent-1', { step: 1 }); + const second = log.commitDelta('agent-1', { step: 2 }); + + expect(second.previousId).toBe(first.id); + expect(log.latest('agent-1')).toMatchObject({ + id: second.id, + delta: { step: 2 }, + }); + }); + + test('queues and claims reincarnation contexts deterministically', () => { + const engine = new AutopsyEngine(tempDir); + const queue = new ReincarnationQueue(tempDir); + const report = engine.recordDeath(createState(), new Error('recursive loop detected')); + + const queued = queue.enqueue(report); + const claimed = queue.claimNext(); + + expect(queued.status).toBe(QueueStatus.QUEUED); + expect(claimed).toMatchObject({ + id: queued.id, + status: QueueStatus.CLAIMED, + reportId: report.id, + }); + expect(queue.claimNext()).toBeNull(); + }); + + test('rehydrates queued reincarnation context across protocol instances', () => { + const firstProtocol = new AgentImmortalityProtocol(tempDir); + const result = firstProtocol.captureFailure({ + agentState: createState(), + error: new Error('recursive loop detected'), + }); + const secondProtocol = new AgentImmortalityProtocol(tempDir); + + const claimed = secondProtocol.claimReincarnation(); + + expect(claimed).toMatchObject({ + id: result.queueItem.id, + status: QueueStatus.CLAIMED, + reportId: result.report.id, + }); + expect(firstProtocol.claimReincarnation()).toBeNull(); + }); + + test('claims a queued reincarnation only once across queue instances', () => { + const engine = new AutopsyEngine(tempDir); + const firstQueue = new ReincarnationQueue(tempDir); + const secondQueue = new ReincarnationQueue(tempDir); + const report = engine.recordDeath(createState(), new Error('recursive loop detected')); + const queued = firstQueue.enqueue(report); + + const claims = [firstQueue.claimNext(), secondQueue.claimNext()].filter(Boolean); + + expect(claims).toHaveLength(1); + expect(claims[0]).toMatchObject({ + id: queued.id, + status: QueueStatus.CLAIMED, + }); + }); + + test('quarantines corrupt persistence files and continues with an empty list', () => { + const dataDir = path.join(tempDir, '.aiox', 'immortality'); + const queueFile = path.join(dataDir, 'reincarnation-queue.json'); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(queueFile, '{not-json'); + + const queue = new ReincarnationQueue(tempDir); + + expect(queue.list()).toEqual([]); + expect(fs.readdirSync(dataDir).some(file => file.startsWith('reincarnation-queue.json.corrupt-'))).toBe(true); + }); + + test('records evolution patterns and toxic actions', () => { + const engine = new AutopsyEngine(tempDir); + const evolutionLog = new EvolutionLog(tempDir); + const report = engine.recordDeath(createState(), new Error('recursive loop detected')); + + const first = evolutionLog.record(report); + const second = evolutionLog.record(report); + + expect(first.event.cause).toBe(CauseOfDeath.RECURSIVE_LOOP); + expect(second.patterns[`${CauseOfDeath.RECURSIVE_LOOP}:retry same tool call`]).toBe(2); + expect(evolutionLog.getToxicActions()['retry same tool call']).toBe(2); + }); + + test('captureFailure coordinates autopsy, queue, state commit, evolution and gotchas', () => { + const gotchasMemory = { + trackError: jest.fn(), + }; + const protocol = new AgentImmortalityProtocol(tempDir, { gotchasMemory }); + const result = protocol.captureFailure({ + agentState: createState(), + error: new Error('network api timeout'), + metadata: { storyId: '482.1' }, + }); + + expect(result.report.cause).toBe(CauseOfDeath.EXTERNAL_API_ERROR); + expect(result.queueItem.status).toBe(QueueStatus.QUEUED); + expect(result.evolution.event.reportId).toBe(result.report.id); + expect(result.reincarnationContext).toContain('suggested_pivot'); + expect(gotchasMemory.trackError).toHaveBeenCalledWith(expect.objectContaining({ + message: 'network api timeout', + category: 'runtime', + context: expect.objectContaining({ + source: 'agent-immortality', + cause: CauseOfDeath.EXTERNAL_API_ERROR, + }), + })); + }); + + test('does not fail capture when optional gotchas tracking throws', () => { + const gotchasMemory = { + trackError: jest.fn(() => { + throw new Error('gotchas unavailable'); + }), + }; + const protocol = new AgentImmortalityProtocol(tempDir, { gotchasMemory }); + const gotchaErrors = []; + protocol.on('gotcha-track-failed', error => gotchaErrors.push(error.message)); + + const result = protocol.captureFailure({ + agentState: createState(), + error: new Error('network api timeout'), + }); + + expect(result.queueItem.status).toBe(QueueStatus.QUEUED); + expect(gotchasMemory.trackError).toHaveBeenCalled(); + expect(gotchaErrors).toEqual(['gotchas unavailable']); + }); + + test('throws clear errors when captureFailure lacks required inputs', () => { + const protocol = new AgentImmortalityProtocol(tempDir); + + expect(() => protocol.captureFailure({ error: new Error('x') })).toThrow('agentState is required'); + expect(() => protocol.captureFailure({ agentState: createState() })).toThrow('error is required'); + }); +});