Files
proffesor-for-testing__agen…/tests/unit/bridge/captured-experience-bridge.test.ts
Dragan Spiridonov 874ae39c6a feat(learning,bridge): dream_insights retention + bridge cursor guard (#488 Phase 3)
Two small defensive changes that close the remaining cleanup-and-race
gaps from #488. No new ADRs needed — both are additive within
already-documented subsystem boundaries.

C.2 — dream_insights retention pruner
=====================================

The dream_insights schema has no expires_at column and no foreign-key
cascade fires on regular cycle delete (cycles aren't routinely purged
either). Without pruning the table grows linearly with hook activity —
V0.03 evidence in #488 documents 120+ rows in 30h, most with
applied=0. Over months `getPendingInsights` queries get slower and
vector embeddings (if attached) grow unbounded.

src/learning/dream/dream-insights-pruner.ts (NEW)
  - pruneStaleDreamInsights(db, { retentionDays = 30 }):
      DELETE FROM dream_insights
        WHERE applied = 0
          AND created_at < datetime('now', '-N days')
  - Applied insights stay forever (audit trail).
  - Recently-created unapplied insights stay regardless of applied
    state — they might still be picked up by a future applyInsight.
  - Guards against the table not existing (fresh init before the
    dream schema is applied). Idempotent.

src/workers/workers/learning-consolidation.ts
  - new Step 8 in runContinuousLearningLoop after the mineExperiences
    sweep: prune insights, emit a finding if pruned > 0, surface
    `dreamInsightsPruned` in ConsolidationResult and domainMetrics.
  - Failures isolated per-try-block (worker continues with the rest
    of its lifecycle work).

C.3 — bridge cursor monotonic guard
===================================

CapturedExperienceBridge.drain wrote `this.cursor` to the kv unconditionally.
Cross-process race: two daemons (possible before #488 B.1 fixed the
pidfile, still possible if user manually spawns extras) drain
overlapping rows, then the "slower" process overwrites the kv with its
older value. Next drain re-publishes already-drained rows; downstream
plugins without event-ID dedup see duplicates.

src/bridge/captured-experience-bridge.ts
  - Before persisting the cursor, read back what's stored and write
    Math.max(this.cursor, persisted). The cursor never regresses.
  - Read-then-max-write is a TOCTOU but it collapses the regression
    window to milliseconds — sufficient for the duplicate-daemon edge
    case. Stronger guarantees (SQL UPDATE WHERE, advisory lock) would
    bypass the MemoryBackend abstraction without a real-world benefit
    post-B.1.
  - Cursor persistence is now best-effort (catch and warn). On failure
    the next bridge boot re-reads from kv and resumes — a missed write
    just means we re-publish a small batch, idempotent for ID-dedup'd
    consumers.

Tests
=====

tests/unit/learning/dream/dream-insights-pruner.test.ts (NEW)
  - 8 tests: deletes stale unapplied, keeps applied regardless of age,
    keeps recent unapplied, honors custom retention, no-op on empty
    table, no-op when table doesn't exist, default 30-day retention,
    idempotent re-run

tests/unit/bridge/captured-experience-bridge.test.ts (extended)
  - 3 new tests in describe('cursor monotonic guard'):
      - does not regress when another writer is ahead
      - advances normally when this bridge is ahead
      - sensible fallback when no persisted value exists

594/594 tests pass across CLI, workers, bridge, learning, init/phases,
architecture, and kernel suites; build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:39:41 +00:00

320 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Issue #479 — bridge that drains captured_experiences (written by hook
* subprocesses) into the kernel-side eventBus, where the 13 domain plugins'
* subscribeToEvents() handlers actually live.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';
import { randomUUID } from 'crypto';
import { CapturedExperienceBridge } from '../../../src/bridge/captured-experience-bridge';
import { InMemoryEventBus } from '../../../src/kernel/event-bus';
import { InMemoryBackend } from '../../../src/kernel/memory-backend';
import {
initializeUnifiedMemory,
resetUnifiedMemory,
getUnifiedMemory,
} from '../../../src/kernel/unified-memory';
import type { DomainEvent } from '../../../src/shared/types/index';
function freshDbPath(): string {
return path.join(
os.tmpdir(),
`aqe-bridge-test-${Date.now()}-${randomUUID().slice(0, 8)}.db`
);
}
function createExperiencesTable() {
const db = getUnifiedMemory().getDatabase();
db.exec(`
CREATE TABLE IF NOT EXISTS captured_experiences (
id TEXT PRIMARY KEY,
task TEXT,
agent TEXT,
domain TEXT NOT NULL DEFAULT '',
success INTEGER NOT NULL DEFAULT 0,
quality REAL NOT NULL DEFAULT 0.5,
duration_ms INTEGER NOT NULL DEFAULT 0,
source TEXT,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
}
function insertRow(opts: {
id: string;
domain: string;
success?: boolean;
agent?: string;
task?: string;
quality?: number;
source?: string;
}) {
const db = getUnifiedMemory().getDatabase();
db.prepare(`
INSERT INTO captured_experiences
(id, task, agent, domain, success, quality, duration_ms, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
opts.id,
opts.task ?? 'test task',
opts.agent ?? 'test-agent',
opts.domain,
opts.success === false ? 0 : 1,
opts.quality ?? 0.8,
100,
opts.source ?? 'cli-hook-post-task'
);
}
describe('Issue #479 — CapturedExperienceBridge', () => {
let dbPath: string;
let eventBus: InMemoryEventBus;
let memory: InMemoryBackend;
let bridge: CapturedExperienceBridge;
beforeEach(async () => {
dbPath = freshDbPath();
await initializeUnifiedMemory({ dbPath });
createExperiencesTable();
eventBus = new InMemoryEventBus();
memory = new InMemoryBackend();
await memory.initialize();
});
afterEach(async () => {
if (bridge) await bridge.stop();
await eventBus.dispose();
resetUnifiedMemory();
if (fs.existsSync(dbPath)) fs.rmSync(dbPath, { force: true });
});
it('publishes learning.ExperienceCaptured for every drained row', async () => {
insertRow({ id: 'e1', domain: 'test-execution' });
insertRow({ id: 'e2', domain: 'test-generation' });
insertRow({ id: 'e3', domain: 'coverage-analysis' });
const learningEvents: DomainEvent[] = [];
eventBus.subscribe('learning.ExperienceCaptured', async (event) => {
learningEvents.push(event);
});
bridge = new CapturedExperienceBridge(eventBus, memory);
const published = await bridge.drainOnce();
// 3 rows × (1 universal + 1 domain-specific each) = 6 events.
expect(published).toBe(6);
expect(learningEvents).toHaveLength(3);
expect(learningEvents[0].source).toBe('learning-optimization');
});
it('uses the canonical nested payload shape ' +
'({ experience: TaskExperience, reward }) — ' +
'the shape handleExperienceCaptured destructures', async () => {
insertRow({
id: 'shape-1',
domain: 'requirements-validation', // not in any fan-out list
agent: 'qe-bdd-generator',
task: 'validate testability',
success: true,
quality: 0.85,
});
let captured: DomainEvent | undefined;
eventBus.subscribe('learning.ExperienceCaptured', async (event) => {
captured = event;
});
bridge = new CapturedExperienceBridge(eventBus, memory);
await bridge.drainOnce();
expect(captured).toBeDefined();
const payload = captured!.payload as {
experience: { id: string; agent: string; domain: string; success: boolean; quality: number };
reward: number;
};
expect(payload.experience).toBeDefined();
expect(payload.experience.id).toBe('shape-1');
expect(payload.experience.agent).toBe('qe-bdd-generator');
expect(payload.experience.domain).toBe('requirements-validation');
expect(payload.experience.success).toBe(true);
expect(payload.experience.quality).toBe(0.85);
expect(payload.reward).toBe(1);
});
it('fans out domain-specific events for known domains (legacy flat shape)', async () => {
insertRow({ id: 'tg-1', domain: 'test-generation' });
insertRow({ id: 'te-1', domain: 'test-execution' });
insertRow({ id: 'cov-ok', domain: 'coverage-analysis', success: true });
insertRow({ id: 'cov-bad', domain: 'coverage-analysis', success: false });
const seen: string[] = [];
for (const type of [
'test-generation.TestGenerated',
'test-execution.TestRunCompleted',
'coverage-analysis.CoverageReportCreated',
'coverage-analysis.CoverageGapDetected',
]) {
eventBus.subscribe(type, async (event) => {
seen.push(event.type);
});
}
bridge = new CapturedExperienceBridge(eventBus, memory);
await bridge.drainOnce();
expect(seen).toContain('test-generation.TestGenerated');
expect(seen).toContain('test-execution.TestRunCompleted');
expect(seen).toContain('coverage-analysis.CoverageReportCreated');
expect(seen).toContain('coverage-analysis.CoverageGapDetected');
});
// Parametrized regression — Jordi's #484 suggestion. Catches the
// class of bug where a domain WITHOUT a domain-specific fan-out would
// expose the universal-event path being broken (test-execution masked
// it via the fan-out handler in v3.9.28).
describe.each([
'test-execution', // has fan-out — passes via two paths
'requirements-validation', // no fan-out — universal-only path
'security-compliance', // no fan-out — universal-only path
'visual-accessibility', // no fan-out — universal-only path
])(
'learning.ExperienceCaptured payload for domain=%s',
(domain) => {
it('uses the canonical nested { experience, reward } shape', async () => {
insertRow({ id: `e-${domain}`, domain });
let captured: DomainEvent | undefined;
eventBus.subscribe('learning.ExperienceCaptured', async (event) => {
captured = event;
});
bridge = new CapturedExperienceBridge(eventBus, memory);
await bridge.drainOnce();
expect(captured).toBeDefined();
const payload = captured!.payload as {
experience: { id: string; domain: string };
reward: number;
};
expect(payload.experience).toBeDefined();
expect(payload.experience.id).toBe(`e-${domain}`);
expect(payload.experience.domain).toBe(domain);
expect(payload.reward).toBeTypeOf('number');
});
}
);
it('persists the cursor and does not republish on the next drain', async () => {
insertRow({ id: 'e1', domain: 'test-execution' });
bridge = new CapturedExperienceBridge(eventBus, memory);
const seen: string[] = [];
eventBus.subscribe('learning.ExperienceCaptured', async (event) => {
seen.push((event.payload as { experience: { id: string } }).experience.id);
});
await bridge.drainOnce();
await bridge.drainOnce();
// Same row must not be republished even when the bridge runs again.
expect(seen).toEqual(['e1']);
});
it('picks up rows inserted between drains', async () => {
bridge = new CapturedExperienceBridge(eventBus, memory);
const seen: string[] = [];
eventBus.subscribe('learning.ExperienceCaptured', async (event) => {
seen.push((event.payload as { experience: { id: string } }).experience.id);
});
insertRow({ id: 'first', domain: 'test-execution' });
await bridge.drainOnce();
expect(seen).toEqual(['first']);
insertRow({ id: 'second', domain: 'test-execution' });
await bridge.drainOnce();
expect(seen).toEqual(['first', 'second']);
});
it('survives missing captured_experiences table without throwing', async () => {
getUnifiedMemory().getDatabase().exec('DROP TABLE captured_experiences');
bridge = new CapturedExperienceBridge(eventBus, memory);
await expect(bridge.drainOnce()).resolves.toBe(0);
});
it('resumes from stored cursor across instances', async () => {
insertRow({ id: 'e1', domain: 'test-execution' });
insertRow({ id: 'e2', domain: 'test-execution' });
const firstBridge = new CapturedExperienceBridge(eventBus, memory);
await firstBridge.drainOnce();
// New bridge instance, same memory backend — cursor must persist.
const secondBridge = new CapturedExperienceBridge(eventBus, memory);
const seen: string[] = [];
eventBus.subscribe('learning.ExperienceCaptured', async (event) => {
seen.push((event.payload as { experience: { id: string } }).experience.id);
});
await secondBridge.start();
await secondBridge.stop();
// The two pre-existing rows must NOT be republished by the second bridge.
expect(seen).toEqual([]);
});
// #488 C.3: monotonic cursor guard
describe('cursor monotonic guard (#488 C.3)', () => {
const CURSOR_KEY = 'aqe/bridge/captured-experiences/cursor';
it('does not regress the stored cursor when another writer is ahead', async () => {
// Simulate a second daemon that already drained further. The shared
// memory backend has cursor=10 from that other process. Our bridge
// is about to write cursor=2 (it only saw rows 1 and 2). Without
// the guard, the next drain would re-pick rows 3..10.
await memory.set(CURSOR_KEY, 10);
insertRow({ id: 'e1', domain: 'test-execution' });
insertRow({ id: 'e2', domain: 'test-execution' });
const bridgeUnderTest = new CapturedExperienceBridge(eventBus, memory);
await bridgeUnderTest.drainOnce();
const persisted = await memory.get<number>(CURSOR_KEY);
// The guard takes max(this.cursor, persisted) — the ahead-writer
// value wins.
expect(persisted).toBeGreaterThanOrEqual(10);
});
it('advances the cursor normally when this bridge is ahead', async () => {
insertRow({ id: 'e1', domain: 'test-execution' });
insertRow({ id: 'e2', domain: 'test-execution' });
insertRow({ id: 'e3', domain: 'test-execution' });
// No prior persisted cursor.
const bridgeUnderTest = new CapturedExperienceBridge(eventBus, memory);
await bridgeUnderTest.drainOnce();
const persisted = await memory.get<number>(CURSOR_KEY);
// 3 rows inserted, so cursor should advance to rowid 3.
expect(persisted).toBe(3);
});
it('falls back to a sensible cursor when persisted value is missing', async () => {
// No prior cursor and one row inserted — the bridge should drain
// and persist a cursor of 1 (no NaN, no undefined).
insertRow({ id: 'e1', domain: 'test-execution' });
const bridgeUnderTest = new CapturedExperienceBridge(eventBus, memory);
await bridgeUnderTest.drainOnce();
const persisted = await memory.get<number>(CURSOR_KEY);
expect(persisted).toBe(1);
});
});
});