moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,309 @@
/**
* 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);
});
});