Files
ruvnet__ruflo/v3/@claude-flow/memory/src/tiered-memory.test.ts
rUv 1d365d4a65 fix: silent memory-write loss + daemon lock cwd bypass (#2887, #2878, #2877) (#2966)
Three independent data-integrity/correctness bugs in the memory and daemon
subsystems:

#2887 — agentdb_hierarchical-store reported success:true for writes that
were never persisted. Root cause: agentdb dropped its HierarchicalMemory
export at 3.0.0-alpha.17, so every call was silently landing on an
in-process-only TieredMemoryStore fallback. The fallback now writes through
to a `tiered_memory` SQLite table when given a db handle, and the bridge
refuses to report success for a write that isn't durable — it names the
fallback controller and reason instead. agentdb_health now reports the real
persisted row count so a claimed write vs. an empty table is visible.

#2878 — concurrent sql.js writers lost updates while reporting success
(measured 10/12 lost in a 12-way concurrent repro), because storeEntry,
getEntry, deleteEntry, applyTemporalDecay, and ensureSchemaColumns did
unlocked whole-image read-modify-write against memory.db. All five now run
under the existing withMemoryDbLock primitive (previously only used by
purgeNamespace). Made the lock reentrant via AsyncLocalStorage, since every
mutator calls ensureSchemaColumns internally and a plain O_EXCL lock isn't
reentrant. Also fixed a path-normalization bug where different callers
spelled the same db path differently and so serialized against nothing.

#2877 — the daemon lock/PID file was keyed to raw process.cwd(), so
`daemon start` from a project subdirectory span its own lock/PID pair
instead of contending with one from the project root, bypassing the
#2407/#2484 dedup. Added resolveDaemonProjectRoot(), which walks up from
cwd to the nearest directory carrying a real Ruflo marker (not just any
package.json, so independently-initialized nested sub-projects in a
monorepo keep their own daemon), stopping at a .git boundary so the walk
can't escape into an unrelated ancestor project. Wired into every daemon
command that previously used process.cwd() directly.

Tests: 123 passing across affected suites (controller-registry.test.ts,
tiered-memory.test.ts, hierarchical-store-durability-2887.test.ts,
memory-concurrent-write-loss-2878.test.ts, daemon-autostart.test.ts).
Both packages typecheck clean. Pre-existing failures in unrelated suites
(ruvector wasm bindings, agenticow, memory-search-recall-2558) reproduce
identically with these changes reverted — confirmed not regressions.

Fixes #2887, #2878, #2877
2026-08-11 10:15:42 -02:30

269 lines
10 KiB
TypeScript

/**
* Tests for TieredMemoryStore temporal validity (Zep/Graphiti-style).
*
* Covers: supersede flow (invalidate, not delete), expired filtering,
* the includeExpired escape hatch, and backward compatibility for legacy
* entries without temporal fields.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { TieredMemoryStore, isTemporallyValid } from './tiered-memory.js';
describe('TieredMemoryStore — legacy behavior (backward compatibility)', () => {
let store: TieredMemoryStore;
beforeEach(() => {
store = new TieredMemoryStore();
});
it('stores and recalls entries without temporal fields exactly as before', () => {
store.store('auth-pattern', 'JWT with refresh tokens', 'semantic');
store.store('scratch', 'temp note', 'working');
const results = store.recall('jwt', 5);
expect(results).toHaveLength(1);
expect(results[0].key).toBe('auth-pattern');
expect(results[0].tier).toBe('semantic');
expect(results[0].validFrom).toBeUndefined();
expect(results[0].validUntil).toBeUndefined();
expect(results[0].supersededBy).toBeUndefined();
});
it('same-key store within a tier overwrites (legacy semantics)', () => {
store.store('k1', 'first value', 'working');
store.store('k1', 'second value', 'working');
const results = store.recall('value', 10);
expect(results).toHaveLength(1);
expect(results[0].value).toBe('second value');
});
it('defaults invalid tiers to working', () => {
store.store('k1', 'v1', 'nonsense-tier');
expect(store.getTierStats().working).toBe(1);
});
it('reports per-tier stats plus superseded archive size', () => {
store.store('a', 'v', 'working');
store.store('b', 'v', 'episodic');
const stats = store.getTierStats();
expect(stats.working).toBe(1);
expect(stats.episodic).toBe(1);
expect(stats.semantic).toBe(0);
expect(stats.superseded).toBe(0);
});
it('remove() hard-deletes an active entry by key', () => {
store.store('gone', 'delete me', 'working');
expect(store.remove('gone')).toBe(true);
expect(store.recall('delete me', 5)).toHaveLength(0);
expect(store.remove('gone')).toBe(false);
});
});
describe('TieredMemoryStore — supersede flow', () => {
let store: TieredMemoryStore;
beforeEach(() => {
store = new TieredMemoryStore();
});
it('invalidates (not deletes) the superseded entry and links supersededBy', () => {
const oldFact = store.store('ceo', 'The CEO is Alice', 'semantic');
const newFact = store.store('ceo-2026', 'The CEO is Bob', 'semantic', {
supersedes: oldFact.id,
});
expect(newFact.superseded).not.toBeNull();
expect(newFact.superseded!.id).toBe(oldFact.id);
expect(newFact.superseded!.validUntil).toBeTruthy();
// Default recall: only the new fact is visible
const current = store.recall('CEO', 10);
expect(current).toHaveLength(1);
expect(current[0].key).toBe('ceo-2026');
// Escape hatch: both facts visible, old one carries the invalidation stamp
const audit = store.recall('CEO', 10, { includeExpired: true });
expect(audit).toHaveLength(2);
const old = audit.find((e) => e.key === 'ceo');
expect(old).toBeDefined();
expect(old!.supersededBy).toBe(newFact.id);
expect(Date.parse(old!.validUntil!)).toBeLessThanOrEqual(Date.now());
});
it('resolves supersedes by key when no id matches', () => {
store.store('policy', 'Old policy text', 'semantic');
const result = store.store('policy-v2', 'New policy text', 'semantic', {
supersedes: 'policy',
});
expect(result.superseded).not.toBeNull();
expect(result.superseded!.key).toBe('policy');
expect(store.recall('policy text', 10)).toHaveLength(1);
expect(store.recall('policy text', 10, { includeExpired: true })).toHaveLength(2);
});
it('superseding with the SAME key preserves the old fact in the archive', () => {
const oldFact = store.store('config', 'timeout=30', 'semantic');
store.store('config', 'timeout=60', 'semantic', { supersedes: oldFact.id });
const current = store.recall('timeout', 10);
expect(current).toHaveLength(1);
expect(current[0].value).toBe('timeout=60');
const audit = store.recall('timeout', 10, { includeExpired: true });
expect(audit).toHaveLength(2);
expect(audit.map((e) => e.value).sort()).toEqual(['timeout=30', 'timeout=60']);
});
it('returns null superseded when the target does not exist', () => {
const result = store.store('k', 'v', 'working', { supersedes: 'no-such-entry' });
expect(result.superseded).toBeNull();
});
});
describe('TieredMemoryStore — validity-window filtering', () => {
let store: TieredMemoryStore;
beforeEach(() => {
store = new TieredMemoryStore();
});
it('filters entries whose validUntil is in the past by default', () => {
const past = new Date(Date.now() - 60_000).toISOString();
store.store('expired-fact', 'promo code SAVE20', 'semantic', { validUntil: past });
store.store('live-fact', 'promo code SAVE30', 'semantic');
const results = store.recall('promo code', 10);
expect(results).toHaveLength(1);
expect(results[0].key).toBe('live-fact');
});
it('includeExpired returns expired entries too (audit escape hatch)', () => {
const past = new Date(Date.now() - 60_000).toISOString();
store.store('expired-fact', 'promo code SAVE20', 'semantic', { validUntil: past });
store.store('live-fact', 'promo code SAVE30', 'semantic');
const results = store.recall('promo code', 10, { includeExpired: true });
expect(results).toHaveLength(2);
});
it('keeps entries whose validUntil is in the future', () => {
const future = new Date(Date.now() + 3_600_000).toISOString();
store.store('ttl-fact', 'valid for one hour', 'working', { validUntil: future });
expect(store.recall('one hour', 5)).toHaveLength(1);
});
it('hides not-yet-valid entries (validFrom in the future) by default', () => {
const future = new Date(Date.now() + 3_600_000).toISOString();
store.store('scheduled-fact', 'new pricing takes effect', 'semantic', { validFrom: future });
expect(store.recall('pricing', 5)).toHaveLength(0);
expect(store.recall('pricing', 5, { includeExpired: true })).toHaveLength(1);
});
it('treats unparseable timestamps as absent instead of hiding the entry', () => {
store.store('weird', 'garbage timestamp entry', 'working', { validUntil: 'not-a-date' });
expect(store.recall('garbage', 5)).toHaveLength(1);
});
});
describe('isTemporallyValid', () => {
const now = Date.parse('2026-07-03T12:00:00Z');
it('entries without fields are always valid', () => {
expect(isTemporallyValid({}, now)).toBe(true);
});
it('respects validFrom / validUntil boundaries', () => {
expect(isTemporallyValid({ validFrom: '2026-07-03T13:00:00Z' }, now)).toBe(false);
expect(isTemporallyValid({ validFrom: '2026-07-03T11:00:00Z' }, now)).toBe(true);
expect(isTemporallyValid({ validUntil: '2026-07-03T11:00:00Z' }, now)).toBe(false);
expect(isTemporallyValid({ validUntil: '2026-07-03T13:00:00Z' }, now)).toBe(true);
// validUntil exactly now → expired (window is half-open)
expect(isTemporallyValid({ validUntil: '2026-07-03T12:00:00Z' }, now)).toBe(false);
});
});
describe('TieredMemoryStore — durability (#2887)', () => {
const dbs: Database.Database[] = [];
const dirs: string[] = [];
function freshDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiered-durability-'));
dirs.push(dir);
return path.join(dir, 'agentdb-memory.db');
}
function open(dbPath: string): Database.Database {
const db = new Database(dbPath);
dbs.push(db);
return db;
}
afterEach(() => {
for (const db of dbs.splice(0)) { try { db.close(); } catch { /* already closed */ } }
for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
it('a successful store is queryable on disk, not just in the maps', () => {
const dbPath = freshDbPath();
const store = new TieredMemoryStore({ db: open(dbPath) });
const result = store.store('durable-key', 'DURABLE_VALUE', 'semantic');
expect(result.durable).toBe(true);
expect(result.persistence).toBe('sqlite');
// Read through an INDEPENDENT connection — proves the row is committed,
// not merely sitting in the writer's in-memory maps.
const rows = open(dbPath)
.prepare('SELECT key, value, tier FROM tiered_memory WHERE key = ?')
.all('durable-key');
expect(rows).toEqual([{ key: 'durable-key', value: 'DURABLE_VALUE', tier: 'semantic' }]);
});
it('rehydrates persisted entries into a brand-new store instance', () => {
const dbPath = freshDbPath();
new TieredMemoryStore({ db: open(dbPath) }).store('survives', 'ACROSS_INSTANCES', 'working');
const reopened = new TieredMemoryStore({ db: open(dbPath) });
expect(reopened.recall('ACROSS_INSTANCES')).toHaveLength(1);
expect(reopened.countPersisted()).toBe(1);
});
it('reports durable:false — never a bare success — when there is no backing db', () => {
const store = new TieredMemoryStore();
const result = store.store('volatile-key', 'LOST_ON_EXIT');
expect(store.isDurable()).toBe(false);
expect(result.durable).toBe(false);
expect(result.persistence).toBe('volatile');
// No table exists to count, so health checks can't be told a row landed.
expect(store.countPersisted()).toBeNull();
});
it('superseded entries stay auditable on disk, and removal deletes the row', () => {
const dbPath = freshDbPath();
const store = new TieredMemoryStore({ db: open(dbPath) });
const first = store.store('fact', 'v1', 'semantic');
store.store('fact', 'v2', 'semantic', { supersedes: first.id });
expect(store.countPersisted()).toBe(2);
expect(store.recall('fact', 5, { includeExpired: true })).toHaveLength(2);
expect(store.remove('fact')).toBe(true);
expect(
open(dbPath).prepare('SELECT COUNT(*) AS n FROM tiered_memory WHERE archived = 0').get(),
).toEqual({ n: 0 });
});
});