mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
1d365d4a65
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
269 lines
10 KiB
TypeScript
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 });
|
|
});
|
|
});
|