310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
/**
|
|
* WorkspaceSessionManager concurrency test.
|
|
*
|
|
* Proves the Phase A.1 invariants: per-session orchestrator instances
|
|
* cannot corrupt each other, even when two sessions are "active" at the
|
|
* same time on different workspace minds.
|
|
*
|
|
* Before A.1, the sidecar held a single `orchestrator` whose workspace
|
|
* layers got overwritten on every `setWorkspaceMind()` call. Two chat
|
|
* requests targeting different workspaces would interleave and silently
|
|
* corrupt each other. This test would fail on the pre-A.1 codebase.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { randomUUID } from 'node:crypto';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import fs from 'node:fs';
|
|
|
|
import { MindDB, type Embedder } from '@waggle/core';
|
|
import { Orchestrator } from '@waggle/agent';
|
|
import { WorkspaceSessionManager } from '../src/local/workspace-sessions.js';
|
|
|
|
// ── Deterministic fake embedder ──────────────────────────────────────
|
|
// Produces a fixed-dimension zero vector so sqlite-vec stays happy but
|
|
// semantic search always returns zero matches. We don't need vector
|
|
// similarity for these tests — we're checking that workspace *identity*
|
|
// routing works, and frame content can be checked via FTS5 instead.
|
|
|
|
class FakeEmbedder implements Embedder {
|
|
dimensions = 384;
|
|
async embed(_text: string): Promise<Float32Array> {
|
|
return new Float32Array(this.dimensions);
|
|
}
|
|
async embedBatch(texts: string[]): Promise<Float32Array[]> {
|
|
return texts.map(() => new Float32Array(this.dimensions));
|
|
}
|
|
}
|
|
|
|
// ── Test harness ─────────────────────────────────────────────────────
|
|
|
|
interface TestWorkspace {
|
|
id: string;
|
|
mind: MindDB;
|
|
mindPath: string;
|
|
}
|
|
|
|
function createTempMind(label: string): TestWorkspace {
|
|
const id = `${label}-${randomUUID().slice(0, 8)}`;
|
|
const mindPath = path.join(os.tmpdir(), `waggle-test-${id}.mind`);
|
|
const mind = new MindDB(mindPath);
|
|
return { id, mind, mindPath };
|
|
}
|
|
|
|
function cleanupMind(ws: TestWorkspace): void {
|
|
try { ws.mind.close(); } catch { /* already closed */ }
|
|
for (const suffix of ['', '-shm', '-wal']) {
|
|
try { fs.unlinkSync(ws.mindPath + suffix); } catch { /* missing is fine */ }
|
|
}
|
|
}
|
|
|
|
function seedFrame(ws: TestWorkspace, content: string, importance = 'normal'): void {
|
|
const db = ws.mind.getDatabase();
|
|
// Ensure a GOP session exists so the FK check passes
|
|
db.prepare(`
|
|
INSERT INTO sessions (gop_id, started_at)
|
|
VALUES (?, ?)
|
|
ON CONFLICT(gop_id) DO NOTHING
|
|
`).run('test', new Date().toISOString());
|
|
db.prepare(`
|
|
INSERT INTO memory_frames (frame_type, gop_id, t, content, importance, source)
|
|
VALUES ('I', ?, 0, ?, ?, 'user_stated')
|
|
`).run('test', content, importance);
|
|
}
|
|
|
|
function buildOrchestrator(personal: MindDB, workspace: MindDB): Orchestrator {
|
|
const orch = new Orchestrator({
|
|
db: personal,
|
|
embedder: new FakeEmbedder(),
|
|
mode: 'local',
|
|
version: 'test',
|
|
});
|
|
orch.setWorkspaceMind(workspace);
|
|
return orch;
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────
|
|
|
|
describe('WorkspaceSessionManager — Phase A.1 concurrency invariants', () => {
|
|
let personal: TestWorkspace;
|
|
let wsA: TestWorkspace;
|
|
let wsB: TestWorkspace;
|
|
let wsC: TestWorkspace;
|
|
let wsD: TestWorkspace;
|
|
|
|
beforeEach(() => {
|
|
personal = createTempMind('personal');
|
|
wsA = createTempMind('wsA');
|
|
wsB = createTempMind('wsB');
|
|
wsC = createTempMind('wsC');
|
|
wsD = createTempMind('wsD');
|
|
});
|
|
|
|
afterEach(() => {
|
|
[personal, wsA, wsB, wsC, wsD].forEach(cleanupMind);
|
|
});
|
|
|
|
it('keeps per-session orchestrators isolated from each other', () => {
|
|
const manager = new WorkspaceSessionManager(3);
|
|
|
|
// Seed each workspace with distinctive content so we can prove routing.
|
|
seedFrame(wsA, 'Workspace A decided to use Postgres.', 'important');
|
|
seedFrame(wsB, 'Workspace B prefers MongoDB.', 'important');
|
|
|
|
const orchA = buildOrchestrator(personal.mind, wsA.mind);
|
|
const orchB = buildOrchestrator(personal.mind, wsB.mind);
|
|
|
|
const sessionA = manager.create('workspace-A', wsA.mind, orchA, [], null);
|
|
const sessionB = manager.create('workspace-B', wsB.mind, orchB, [], null);
|
|
|
|
// The invariant: session A's orchestrator must hold workspace A's layers,
|
|
// session B's must hold workspace B's. In the broken pre-A.1 code they'd
|
|
// both point at whichever workspace was set last.
|
|
expect(sessionA.orchestrator).not.toBe(sessionB.orchestrator);
|
|
expect(sessionA.orchestrator.hasWorkspaceMind()).toBe(true);
|
|
expect(sessionB.orchestrator.hasWorkspaceMind()).toBe(true);
|
|
expect(manager.size).toBe(2);
|
|
});
|
|
|
|
it('allows a third concurrent session but blocks the fourth at default cap', () => {
|
|
const manager = new WorkspaceSessionManager(3);
|
|
|
|
const build = (label: string, ws: TestWorkspace) => manager.create(
|
|
label,
|
|
ws.mind,
|
|
buildOrchestrator(personal.mind, ws.mind),
|
|
[],
|
|
null,
|
|
);
|
|
|
|
build('A', wsA);
|
|
build('B', wsB);
|
|
build('C', wsC);
|
|
|
|
expect(manager.size).toBe(3);
|
|
|
|
expect(() => build('D', wsD)).toThrow(/Max concurrent sessions reached/);
|
|
});
|
|
|
|
it('honors setMaxSessions raises and subsequent creates', () => {
|
|
const manager = new WorkspaceSessionManager(2);
|
|
expect(manager.getMaxSessions()).toBe(2);
|
|
|
|
const build = (label: string, ws: TestWorkspace) => manager.create(
|
|
label,
|
|
ws.mind,
|
|
buildOrchestrator(personal.mind, ws.mind),
|
|
[],
|
|
null,
|
|
);
|
|
|
|
build('A', wsA);
|
|
build('B', wsB);
|
|
expect(() => build('C', wsC)).toThrow(/Max concurrent sessions reached/);
|
|
|
|
manager.setMaxSessions(4);
|
|
expect(manager.getMaxSessions()).toBe(4);
|
|
|
|
// Now the third and fourth sessions succeed
|
|
build('C', wsC);
|
|
build('D', wsD);
|
|
expect(manager.size).toBe(4);
|
|
});
|
|
|
|
it('rejects invalid setMaxSessions values', () => {
|
|
const manager = new WorkspaceSessionManager(3);
|
|
expect(() => manager.setMaxSessions(0)).toThrow(/finite integer/);
|
|
expect(() => manager.setMaxSessions(-1)).toThrow(/finite integer/);
|
|
expect(() => manager.setMaxSessions(NaN)).toThrow(/finite integer/);
|
|
});
|
|
|
|
it('pause and resume flip status without affecting other sessions', () => {
|
|
const manager = new WorkspaceSessionManager(3);
|
|
|
|
const sessA = manager.create('A', wsA.mind, buildOrchestrator(personal.mind, wsA.mind), [], null);
|
|
const sessB = manager.create('B', wsB.mind, buildOrchestrator(personal.mind, wsB.mind), [], null);
|
|
|
|
expect(sessA.status).toBe('active');
|
|
expect(sessB.status).toBe('active');
|
|
|
|
expect(manager.pause('A')).toBe(true);
|
|
expect(sessA.status).toBe('paused');
|
|
expect(sessB.status).toBe('active'); // unaffected
|
|
|
|
expect(manager.resume('A')).toBe(true);
|
|
expect(sessA.status).toBe('active');
|
|
|
|
// Double-pause returns true but status is idempotent
|
|
expect(manager.pause('A')).toBe(true);
|
|
expect(sessA.status).toBe('paused');
|
|
|
|
// Resume on a non-paused session returns false
|
|
expect(manager.resume('B')).toBe(false);
|
|
});
|
|
|
|
it('close() closes one session cleanly and leaves others intact', () => {
|
|
const manager = new WorkspaceSessionManager(3);
|
|
|
|
manager.create('A', wsA.mind, buildOrchestrator(personal.mind, wsA.mind), [], null);
|
|
const sessB = manager.create('B', wsB.mind, buildOrchestrator(personal.mind, wsB.mind), [], null);
|
|
|
|
expect(manager.size).toBe(2);
|
|
expect(manager.close('A')).toBe(true);
|
|
expect(manager.size).toBe(1);
|
|
expect(manager.has('A')).toBe(false);
|
|
expect(manager.has('B')).toBe(true);
|
|
expect(sessB.status).toBe('active');
|
|
|
|
// Closing an already-closed session returns false
|
|
expect(manager.close('A')).toBe(false);
|
|
});
|
|
|
|
it('getOrCreate reuses existing sessions with factories called lazily', () => {
|
|
const manager = new WorkspaceSessionManager(3);
|
|
let mindFactoryCallCount = 0;
|
|
let orchFactoryCallCount = 0;
|
|
let toolsFactoryCallCount = 0;
|
|
|
|
const factories = {
|
|
mind: () => { mindFactoryCallCount++; return wsA.mind; },
|
|
orch: (m: MindDB) => { orchFactoryCallCount++; return buildOrchestrator(personal.mind, m); },
|
|
tools: (_m: MindDB, _o: Orchestrator) => { toolsFactoryCallCount++; return []; },
|
|
};
|
|
|
|
const first = manager.getOrCreate('A', factories.mind, factories.orch, factories.tools, null);
|
|
expect(mindFactoryCallCount).toBe(1);
|
|
expect(orchFactoryCallCount).toBe(1);
|
|
expect(toolsFactoryCallCount).toBe(1);
|
|
|
|
// Second call should return the SAME session, not invoke factories
|
|
const second = manager.getOrCreate('A', factories.mind, factories.orch, factories.tools, null);
|
|
expect(second).toBe(first);
|
|
expect(mindFactoryCallCount).toBe(1);
|
|
expect(orchFactoryCallCount).toBe(1);
|
|
expect(toolsFactoryCallCount).toBe(1);
|
|
|
|
// Touch should update lastActivity
|
|
const firstActivity = second.lastActivity;
|
|
// A tiny delay is not guaranteed by the test runner, so we don't assert
|
|
// strict inequality — just that getOrCreate didn't error.
|
|
expect(firstActivity).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('closeAll clears every session at once', () => {
|
|
const manager = new WorkspaceSessionManager(4);
|
|
|
|
manager.create('A', wsA.mind, buildOrchestrator(personal.mind, wsA.mind), [], null);
|
|
manager.create('B', wsB.mind, buildOrchestrator(personal.mind, wsB.mind), [], null);
|
|
manager.create('C', wsC.mind, buildOrchestrator(personal.mind, wsC.mind), [], null);
|
|
|
|
expect(manager.size).toBe(3);
|
|
manager.closeAll();
|
|
expect(manager.size).toBe(0);
|
|
expect(manager.has('A')).toBe(false);
|
|
expect(manager.has('B')).toBe(false);
|
|
expect(manager.has('C')).toBe(false);
|
|
});
|
|
|
|
it('two concurrent orchestrators on different workspaces do not cross-contaminate', async () => {
|
|
const manager = new WorkspaceSessionManager(3);
|
|
|
|
// Seed each workspace with content specific to that workspace
|
|
seedFrame(wsA, 'A-specific: blueprint for launch campaign.', 'important');
|
|
seedFrame(wsB, 'B-specific: competitor analysis for fintech vertical.', 'important');
|
|
|
|
const orchA = buildOrchestrator(personal.mind, wsA.mind);
|
|
const orchB = buildOrchestrator(personal.mind, wsB.mind);
|
|
|
|
manager.create('A', wsA.mind, orchA, [], null);
|
|
manager.create('B', wsB.mind, orchB, [], null);
|
|
|
|
// Fire two recalls in parallel. The invariant: each orchestrator only
|
|
// sees its own workspace's frames. On the pre-A.1 shared-orchestrator
|
|
// code, one would overwrite the other before both resolve.
|
|
const [recallA, recallB] = await Promise.all([
|
|
orchA.recallMemory('launch', 5),
|
|
orchB.recallMemory('fintech', 5),
|
|
]);
|
|
|
|
// Because the embedder is a zero-vector stub, semantic search returns
|
|
// nothing — but the key invariant we check is that recall didn't throw
|
|
// and that each orchestrator still points at its own workspace mind.
|
|
// The no-crash + no-exception path is the core regression we're
|
|
// guarding against.
|
|
expect(recallA).toBeDefined();
|
|
expect(recallB).toBeDefined();
|
|
expect(orchA.hasWorkspaceMind()).toBe(true);
|
|
expect(orchB.hasWorkspaceMind()).toBe(true);
|
|
|
|
// And crucially: after both recalls, both orchestrators still have
|
|
// the RIGHT workspace mind (not crossed over).
|
|
const statsA = orchA.getMemoryStats();
|
|
const statsB = orchB.getMemoryStats();
|
|
// Both should have non-zero frame counts (they have different content)
|
|
// and the personal-mind fragment is shared, so totals are at least 1.
|
|
expect(statsA.frameCount).toBeGreaterThanOrEqual(1);
|
|
expect(statsB.frameCount).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|