import { describe, it, expect } from 'vitest'; import { MultiMindCache } from '../src/multi-mind-cache.js'; import { FrameStore } from '../src/mind/frames.js'; import { SessionStore } from '../src/mind/sessions.js'; /** * Regression + fix coverage for the DB-flake root cause: MultiMindCache used to * close (evict) a MindDB that a live chat turn was still holding across the LLM * await, surfacing as a swallowed "database connection is not open" write. * * DIRECT SQLITE only — no embedder / vector search (CI-substrate-smoke rule). * Each workspace maps to its own in-memory DB; FTS5 (via createIFrame → indexFts) * is fine. A gop row is seeded via SessionStore.ensure() to satisfy the frame FK. */ function makeCache(maxOpen: number): MultiMindCache { // Every id opens a fresh :memory: DB; the cache keeps one handle per id until // it is evicted or closed. allowedRoot is left unset (no path guard needed). return new MultiMindCache({ maxOpen, getMindPath: () => ':memory:' }); } describe('MultiMindCache eviction / session-pinning', () => { it('REGRESSION: an unpinned in-use mind is evicted + closed under pressure', () => { const cache = makeCache(2); const dbA = cache.getOrOpen('A'); expect(dbA).not.toBeNull(); new SessionStore(dbA!).ensure('gop-1'); const frameId = new FrameStore(dbA!).createIFrame('gop-1', 'hello from A').id; // Fan-out pressure: B fills the cap, C forces an eviction. A is the LRU and // is UNPINNED, so it is the victim — exactly the flake trigger. cache.getOrOpen('B'); cache.getOrOpen('C'); expect(cache.has('A')).toBe(false); // A was evicted // The handle the "session" still holds is now closed → any read throws. expect(() => new FrameStore(dbA!).getById(frameId)).toThrow(/not open/i); cache.closeAll(); }); it('FIX: acquire() pins a mind so eviction skips it and picks an unpinned victim', () => { const cache = makeCache(2); const dbA = cache.acquire('A'); // pinned for the session lifetime new SessionStore(dbA).ensure('gop-1'); const frameId = new FrameStore(dbA).createIFrame('gop-1', 'hello pinned').id; // Same pressure as the regression case. B (unpinned) must be the victim; A // (pinned) must survive even though it is the LRU by access time. cache.getOrOpen('B'); cache.getOrOpen('C'); expect(cache.has('A')).toBe(true); // pinned mind survived expect(cache.has('B')).toBe(false); // an unpinned entry was evicted instead // A's handle is still live — read via the reference we already hold (do NOT // touch the cache, so A stays the LRU for the release assertion below). const row = new FrameStore(dbA).getById(frameId); expect(row?.id).toBe(frameId); // Releasing the pin makes A evictable again. cache.release('A'); cache.getOrOpen('D'); // fresh pressure now that A is unpinned expect(cache.has('A')).toBe(false); cache.closeAll(); }); it('shrinks an over-cap cache as soon as a pinned mind is released', () => { const cache = makeCache(2); const dbA = cache.acquire('A'); const dbB = cache.acquire('B'); const dbC = cache.acquire('C'); // All entries are pinned while C opens, so correctness temporarily wins // over the soft cap. Releasing A must immediately make it the eviction // candidate instead of leaving all three handles open indefinitely. expect(cache.size).toBe(3); cache.release('A'); expect(cache.size).toBe(2); expect(cache.has('A')).toBe(false); expect(dbA.isOpen()).toBe(false); expect(dbB.isOpen()).toBe(true); expect(dbC.isOpen()).toBe(true); cache.release('B'); cache.release('C'); cache.closeAll(); }); it('REOPEN-GUARD: a handle closed out-of-band is transparently reopened', () => { const cache = makeCache(2); const dbA = cache.getOrOpen('A'); expect(dbA).not.toBeNull(); // Simulate one of the extra close seams closing the underlying handle while // the entry is still in the cache map. dbA!.close(); expect(dbA!.isOpen()).toBe(false); const reopened = cache.getOrOpen('A'); expect(reopened).not.toBeNull(); expect(reopened!.isOpen()).toBe(true); expect(reopened).not.toBe(dbA); // a fresh, live handle // The reopened handle is usable (empty DB → getById returns undefined, no throw). expect(() => new FrameStore(reopened!).getById(1)).not.toThrow(); cache.closeAll(); }); });