diff --git a/.aiox-core/core/ideation/ideation-engine.js b/.aiox-core/core/ideation/ideation-engine.js index 5f7b9bcb9c..de011eb587 100644 --- a/.aiox-core/core/ideation/ideation-engine.js +++ b/.aiox-core/core/ideation/ideation-engine.js @@ -98,10 +98,10 @@ class IdeationEngine { let filtered = suggestions; if (this.gotchasMemory) { try { - const knownIssues = await this.gotchasMemory.getAll(); + const knownIssues = await this.gotchasMemory.listGotchas(); filtered = suggestions.filter((s) => !this.isKnownGotcha(s, knownIssues)); - } catch { - // Ignore + } catch (error) { + console.warn('[IdeationEngine] Gotchas filtering failed:', error.message); } } diff --git a/.aiox-core/core/memory/gotchas-memory.js b/.aiox-core/core/memory/gotchas-memory.js index f98f10f430..2fdae17f07 100644 --- a/.aiox-core/core/memory/gotchas-memory.js +++ b/.aiox-core/core/memory/gotchas-memory.js @@ -196,7 +196,7 @@ class GotchasMemory extends EventEmitter { // In-memory storage this.gotchas = new Map(); // id -> gotcha - this.errorTracking = new Map(); // errorHash -> { count, firstSeen, lastSeen, samples } + this.errorTracking = new Map(); // errorHash -> { count, firstSeen, lastSeen, timestamps, samples } // Load existing data this._loadGotchas(); @@ -253,15 +253,27 @@ class GotchasMemory extends EventEmitter { count: 0, firstSeen: now, lastSeen: now, + timestamps: [], samples: [], errorPattern: errorData.message, category: this._detectCategory(errorData.message + ' ' + (errorData.stack || '')), }; } - // Update tracking - tracking.count++; + // Keep only occurrences inside the configured rolling error window. + const windowStart = now - this.options.errorWindowMs; + const timestamps = this._normalizeErrorTimestamps(tracking).filter((timestamp) => timestamp >= windowStart); + timestamps.push(now); + + tracking.timestamps = timestamps; + tracking.count = timestamps.length; + tracking.firstSeen = timestamps[0]; tracking.lastSeen = now; + tracking.samples = (tracking.samples || []).filter((sample) => { + const timestamp = Date.parse(sample.timestamp); + return Number.isFinite(timestamp) && timestamp >= windowStart; + }); + if (tracking.samples.length < 5) { tracking.samples.push({ timestamp: new Date(now).toISOString(), @@ -314,7 +326,7 @@ class GotchasMemory extends EventEmitter { // Sort by severity (critical first), then by last occurrence const severityOrder = { critical: 0, warning: 1, info: 2 }; gotchas.sort((a, b) => { - const severityDiff = (severityOrder[a.severity] || 2) - (severityOrder[b.severity] || 2); + const severityDiff = (severityOrder[a.severity] ?? 2) - (severityOrder[b.severity] ?? 2); if (severityDiff !== 0) return severityDiff; return new Date(b.source.lastSeen) - new Date(a.source.lastSeen); }); @@ -712,6 +724,26 @@ class GotchasMemory extends EventEmitter { return null; } + /** + * Normalize persisted error occurrence timestamps. + * @private + * @param {Object} tracking - Error tracking entry + * @returns {number[]} Timestamp list in milliseconds + */ + _normalizeErrorTimestamps(tracking) { + const timestamps = Array.isArray(tracking.timestamps) ? tracking.timestamps : [tracking.lastSeen]; + + return timestamps + .map((timestamp) => { + if (typeof timestamp === 'number') { + return timestamp; + } + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) ? parsed : null; + }) + .filter((timestamp) => timestamp !== null); + } + /** * Generate title from error message * @private diff --git a/.aiox-core/core/orchestration/condition-evaluator.js b/.aiox-core/core/orchestration/condition-evaluator.js index 76349a5d62..5157962cc4 100644 --- a/.aiox-core/core/orchestration/condition-evaluator.js +++ b/.aiox-core/core/orchestration/condition-evaluator.js @@ -23,6 +23,23 @@ * @property {string} reason - Reason for the decision */ +const RUNTIME_CONDITION_DEFAULTS = Object.freeze({ + after_prd_creation: true, + architecture_changes_needed: true, + architecture_suggests_prd_changes: true, + based_on_classification: true, + documentation_inadequate: true, + epic_complete: true, + gate_approved: true, + major_enhancement_path: true, + po_checklist_issues: true, + qa_left_unchecked_items: true, + stories_remaining: true, + user_has_generated_ui: true, + user_wants_ai_generation: true, + user_wants_story_review: true, +}); + /** * Evaluates workflow conditions based on detected tech stack */ @@ -36,6 +53,7 @@ class ConditionEvaluator { // Context for QA approval tracking (updated externally) this._qaApproved = false; this._phaseOutputs = {}; + this._runtimeOverrides = new Map(); } /** @@ -54,6 +72,25 @@ class ConditionEvaluator { this._phaseOutputs = outputs; } + /** + * Set a runtime-resolved condition value. + * @param {string} name - Condition name + * @param {boolean} value - Runtime condition value + */ + setRuntimeCondition(name, value) { + this._runtimeOverrides.set(name, Boolean(value)); + } + + /** + * Set multiple runtime-resolved condition values. + * @param {Object} overrides - Map of condition names to values + */ + setRuntimeConditions(overrides = {}) { + for (const [name, value] of Object.entries(overrides)) { + this.setRuntimeCondition(name, value); + } + } + /** * Evaluate a condition string * @param {string} condition - Condition like 'project_has_database' @@ -65,6 +102,10 @@ class ConditionEvaluator { return true; } + if (this._runtimeOverrides.has(condition)) { + return this._runtimeOverrides.get(condition); + } + // Built-in condition evaluators const evaluators = { // Tech stack conditions @@ -105,6 +146,10 @@ class ConditionEvaluator { return evaluator(); } + if (Object.prototype.hasOwnProperty.call(RUNTIME_CONDITION_DEFAULTS, condition)) { + return RUNTIME_CONDITION_DEFAULTS[condition]; + } + // Handle negation first if (condition.startsWith('!')) { return !this.evaluate(condition.substring(1).trim()); @@ -145,9 +190,9 @@ class ConditionEvaluator { return this._evaluateDotNotation(condition); } - // Unknown condition - default to true (permissive) + // Unknown condition - fail safe so typos do not silently authorize execution. console.warn(`[ConditionEvaluator] Unknown condition: ${condition}`); - return true; + return false; } /** @@ -336,6 +381,14 @@ class ConditionEvaluator { project_has_backend: 'No backend framework detected', supabase_configured: 'Supabase not configured or missing environment variables', qa_review_approved: 'QA review not yet approved', + user_wants_ai_generation: 'User did not request AI generation', + architecture_suggests_prd_changes: 'Architecture did not request PRD changes', + po_checklist_issues: 'PO checklist has no blocking issues', + user_has_generated_ui: 'No generated UI artifact was provided', + user_wants_story_review: 'User did not request story review', + qa_left_unchecked_items: 'QA did not leave unchecked items', + stories_remaining: 'No stories remaining', + epic_complete: 'Epic is not complete', }; const reasons = failed.map((c) => explanations[c] || `Condition not met: ${c}`); diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index bd917b7c1b..874fc0f444 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -7,8 +7,8 @@ # - SHA256 hashes for change detection # - File types for categorization # -version: 5.1.9 -generated_at: "2026-05-07T15:10:49.402Z" +version: 5.1.13 +generated_at: "2026-05-07T16:42:59.200Z" generator: scripts/generate-install-manifest.js file_count: 1103 files: @@ -693,9 +693,9 @@ files: type: core size: 7755 - path: core/ideation/ideation-engine.js - hash: sha256:a154a0ce92f6b39b358529a030025b9709e0e4e2b85f6af02bd474e15c1aeb83 + hash: sha256:c91f7bc999eca74394eeae9bc7400e63e134de4092d6bdc08b92bf423e423ec3 type: core - size: 23808 + size: 23886 - path: core/ids/circuit-breaker.js hash: sha256:63be126f2e0d320daa60cb5b68b21df93cad4c19d1f959e06a6ac776213f8eba type: core @@ -793,9 +793,9 @@ files: type: core size: 8850 - path: core/memory/gotchas-memory.js - hash: sha256:0063eff42caf0dda759c0390ac323e7b102f5507f27b8beb7b0acc2fbec407c4 + hash: sha256:deeb90a6dff9ef284537a1dabf52566cec3f64244f31f4081432515985a94e25 type: core - size: 33058 + size: 34271 - path: core/migration/migration-config.yaml hash: sha256:c251213b99ce86c9b15311d51a0b464b7fc25179455c4c6c107cae76cf49d1ab type: core @@ -833,9 +833,9 @@ files: type: core size: 19293 - path: core/orchestration/condition-evaluator.js - hash: sha256:8bf565cf56194340ff4e1d642647150775277bce649411d0338faa2c96106745 + hash: sha256:daa6755c76359bb99a2e687c9c56f74b52b7151b0b0677a771b8fff24538d2ad type: core - size: 10845 + size: 12722 - path: core/orchestration/context-manager.js hash: sha256:7bf273723a2c08cb84e670b9d4c55aacec51819b1fbd5f3b0c46c4ccfa2ec192 type: core diff --git a/docs/stories/epic-123/STORY-123.18-resilience-runtime-cluster.md b/docs/stories/epic-123/STORY-123.18-resilience-runtime-cluster.md new file mode 100644 index 0000000000..5d3e3d8e3a --- /dev/null +++ b/docs/stories/epic-123/STORY-123.18-resilience-runtime-cluster.md @@ -0,0 +1,58 @@ +# STORY-123.18: Corrigir cluster runtime de resiliência + +## Status + +Done + +## Story + +Como mantenedor do AIOX Core, quero fechar o cluster de bugs de resiliência dos PRs antigos #584, #470, #474 e #477, para que ideação, condições de workflow, circuit breaker e memória de gotchas operem com comportamento fail-safe e sem regressões silenciosas. + +## Acceptance Criteria + +- [x] AC1. `IdeationEngine` usa a API existente `listGotchas()` e registra falhas de filtro com contexto. +- [x] AC2. `ConditionEvaluator` retorna `false` para condições desconhecidas e preserva condições runtime conhecidas com overrides explícitos. +- [x] AC3. `CircuitBreaker` preserva o timeout de recuperação quando novas falhas chegam no estado `OPEN`. +- [x] AC4. `GotchasMemory` aplica `errorWindowMs` como janela de contagem e ordena severidade `critical` antes de `warning` e `info`. +- [x] AC5. Testes regressivos cobrem o cluster e o manifest do core é regenerado. + +## Tasks + +- [x] Confrontar PRs antigos contra `main`. +- [x] Implementar apenas os resíduos ainda presentes no runtime. +- [x] Adicionar regressões focadas para o cluster. +- [x] Atualizar versão e manifest de instalação. +- [x] Rodar validações locais antes de PR. + +## Dev Notes + +- #470 já estava implementado no `main`, mas ganhou teste regressivo para permitir fechar o PR antigo com evidência. +- #517 já tinha o import nomeado corrigido em PR anterior, mas o fluxo ainda chamava `getAll()`, API inexistente em `GotchasMemory`. +- #584 continua dirty e carrega `entity-registry`/manifest antigo; esta story substitui o PR por uma correção atual e mínima. + +## Validation + +- `node -c .aiox-core/core/ideation/ideation-engine.js` -> PASS. +- `node -c .aiox-core/core/orchestration/condition-evaluator.js` -> PASS. +- `node -c .aiox-core/core/memory/gotchas-memory.js` -> PASS. +- `node -c tests/core/resilience-regressions.test.js` -> PASS. +- `npm test -- tests/core/resilience-regressions.test.js --runInBand --forceExit` -> PASS, 1 suite / 6 tests. +- `git diff --check` -> PASS. +- `npm run validate:manifest` -> PASS. +- `npm run validate:semantic-lint` -> PASS. +- `npm run validate:publish` -> PASS. +- `npm run lint -- --quiet` -> PASS. +- `npm run typecheck` -> PASS. +- `npm test -- tests/core/resilience-regressions.test.js tests/core/gotchas-memory-imports.test.js --runInBand --forceExit` -> PASS, 2 suites / 11 tests. +- `npm run test:ci` -> PASS, 316 suites / 7.853 tests. + +## File List + +- `.aiox-core/core/ideation/ideation-engine.js` +- `.aiox-core/core/orchestration/condition-evaluator.js` +- `.aiox-core/core/memory/gotchas-memory.js` +- `.aiox-core/install-manifest.yaml` +- `tests/core/resilience-regressions.test.js` +- `package.json` +- `package-lock.json` +- `docs/stories/epic-123/STORY-123.18-resilience-runtime-cluster.md` diff --git a/package-lock.json b/package-lock.json index 357672f63a..bf3b8323d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aiox-squads/core", - "version": "5.1.12", + "version": "5.1.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aiox-squads/core", - "version": "5.1.12", + "version": "5.1.13", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 6d712a9a6b..572c6ca2b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aiox-squads/core", - "version": "5.1.12", + "version": "5.1.13", "description": "Synkra AIOX: AI-Orchestrated System for Full Stack Development - Core Framework", "bin": { "aiox": "bin/aiox.js", diff --git a/tests/core/resilience-regressions.test.js b/tests/core/resilience-regressions.test.js new file mode 100644 index 0000000000..a7993d8118 --- /dev/null +++ b/tests/core/resilience-regressions.test.js @@ -0,0 +1,214 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '../..'); +const requireFromRoot = (modulePath) => require(path.join(repoRoot, modulePath)); + +const { + CircuitBreaker, + STATE_HALF_OPEN, + STATE_OPEN, +} = requireFromRoot('.aiox-core/core/ids/circuit-breaker'); +const ConditionEvaluator = requireFromRoot('.aiox-core/core/orchestration/condition-evaluator'); +const { GotchasMemory } = requireFromRoot('.aiox-core/core/memory/gotchas-memory'); +const IdeationEngine = requireFromRoot('.aiox-core/core/ideation/ideation-engine'); + +function createProfile(overrides = {}) { + return { + hasDatabase: false, + hasFrontend: false, + hasBackend: false, + hasTypeScript: false, + hasTests: false, + database: { + type: null, + envVarsConfigured: false, + hasRLS: false, + hasMigrations: false, + }, + frontend: { + framework: null, + styling: null, + }, + ...overrides, + }; +} + +function withMockedNow(startMs) { + let now = startMs; + const spy = jest.spyOn(Date, 'now').mockImplementation(() => now); + return { + advance: (ms) => { + now += ms; + return now; + }, + restore: () => spy.mockRestore(), + }; +} + +describe('resilience subsystem regressions', () => { + describe('CircuitBreaker', () => { + it('preserves the recovery timeout when failures continue while OPEN', () => { + const clock = withMockedNow(1_000); + const breaker = new CircuitBreaker({ failureThreshold: 1, resetTimeoutMs: 1_000 }); + + try { + breaker.recordFailure(); + const openedAt = breaker.getStats().lastFailureTime; + + clock.advance(500); + breaker.recordFailure(); + + expect(breaker.getState()).toBe(STATE_OPEN); + expect(breaker.getStats().lastFailureTime).toBe(openedAt); + + clock.advance(500); + expect(breaker.isAllowed()).toBe(true); + expect(breaker.getState()).toBe(STATE_HALF_OPEN); + } finally { + clock.restore(); + } + }); + }); + + describe('ConditionEvaluator', () => { + it('fails safe for unknown conditions', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const evaluator = new ConditionEvaluator(createProfile()); + + try { + expect(evaluator.evaluate('has_databse')).toBe(false); + expect(warnSpy).toHaveBeenCalledWith('[ConditionEvaluator] Unknown condition: has_databse'); + } finally { + warnSpy.mockRestore(); + } + }); + + it('keeps known runtime workflow conditions overridable', () => { + const evaluator = new ConditionEvaluator(createProfile()); + + expect(evaluator.evaluate('user_wants_ai_generation')).toBe(true); + + evaluator.setRuntimeCondition('user_wants_ai_generation', false); + expect(evaluator.evaluate('user_wants_ai_generation')).toBe(false); + + evaluator.setRuntimeConditions({ + stories_remaining: false, + epic_complete: true, + }); + expect(evaluator.evaluate('stories_remaining')).toBe(false); + expect(evaluator.evaluate('epic_complete')).toBe(true); + }); + }); + + describe('GotchasMemory', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gotchas-regression-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('counts repeated errors only inside errorWindowMs', () => { + const clock = withMockedNow(10_000); + const memory = new GotchasMemory(tmpDir, { + repeatThreshold: 2, + errorWindowMs: 100, + quiet: true, + }); + const error = { message: 'Database migration failed repeatedly', file: 'migrate.js' }; + + try { + expect(memory.trackError(error)).toBeNull(); + + clock.advance(150); + expect(memory.trackError(error)).toBeNull(); + expect([...memory.errorTracking.values()][0].count).toBe(1); + + clock.advance(50); + const captured = memory.trackError(error); + + expect(captured).not.toBeNull(); + expect(captured.source.occurrences).toBe(2); + } finally { + clock.restore(); + } + }); + + it('sorts critical gotchas before warnings and info', () => { + const memory = new GotchasMemory(tmpDir, { quiet: true }); + const lastSeen = '2026-01-01T00:00:00.000Z'; + + memory.addGotcha({ + title: 'Info', + description: 'Info issue', + severity: 'info', + lastSeen, + }); + memory.addGotcha({ + title: 'Critical', + description: 'Critical issue', + severity: 'critical', + lastSeen, + }); + memory.addGotcha({ + title: 'Warning', + description: 'Warning issue', + severity: 'warning', + lastSeen, + }); + + expect(memory.listGotchas().map((gotcha) => gotcha.severity)).toEqual([ + 'critical', + 'warning', + 'info', + ]); + }); + }); + + describe('IdeationEngine', () => { + it('uses listGotchas to filter known suggestions', async () => { + const gotchasMemory = { + listGotchas: jest.fn().mockResolvedValue([ + { + description: 'database migration repeated failure pattern', + }, + ]), + }; + const engine = new IdeationEngine({ + areas: ['architecture'], + gotchasMemory, + }); + engine.analyzers = { + architecture: { + analyze: jest.fn().mockResolvedValue([ + { + title: 'Database migration repeated failure pattern', + description: 'Avoid another database migration repeated failure pattern', + effort: 'low', + impact: 0.9, + }, + { + title: 'Cache project metadata', + description: 'Cache expensive project metadata lookups', + effort: 'low', + impact: 0.8, + }, + ]), + }, + }; + + const result = await engine.ideate({ focus: ['architecture'] }); + + expect(gotchasMemory.listGotchas).toHaveBeenCalledTimes(1); + expect(result.summary.totalSuggestions).toBe(1); + expect(result.quickWins[0].title).toBe('Cache project metadata'); + }); + }); +});