#!/usr/bin/env node /** * scripts/test-agents-reload.js — regression test for agent accumulation on page reload. * * Bug history (two layers): * 1. spawnAgent() minted `agent---` on every boot, and * _preSpawnDefaultAgents() deduped against in-RAM _agents — which is empty after a * reload. Meanwhile _reanimateFromVFS() only restored dirs that had sleep.json. * → +3 dirs per reload, forever. * 2. VirtualFileSystem.loadFromStorage() is ASYNC. init() called _reanimateFromVFS() * synchronously, so it ran against an empty root, silently did nothing, and 2 s later * pre-spawn wrote brand-new dirs over the persisted tree. * * Fix: stable `agent-` ids + `_bootAgents()` (await VFS hydration → reconcile → * spawn only what is missing). * * A "reload" = a fresh OrchestratorModule instance sharing the same persisted VFS. * Run: node scripts/test-agents-reload.js (exit 0 = pass) */ const fs = require('fs'); const path = require('path'); const CORE = path.join(__dirname, '..', 'modules', 'core'); // ── browser shims ───────────────────────────────────────────────────── const store = new Map(); global.localStorage = { getItem: k => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: k => store.delete(k), clear: () => store.clear(), }; global.indexedDB = undefined; global.fetch = async () => ({ ok: false, json: async () => ({}) }); const el = { style: {}, classList: { add() {}, remove() {} }, addEventListener() {}, appendChild() {}, textContent: '', innerHTML: '' }; global.document = { readyState: 'complete', addEventListener() {}, getElementById: () => null, querySelector: () => null, querySelectorAll: () => [], createElement: () => ({ ...el }), body: { ...el, appendChild() {} }, head: { ...el, appendChild() {} }, }; global.window = global; global.navigator = { userAgent: 'node' }; global.performance = { now: () => Date.now() }; global.WebSocket = class { constructor() {} close() {} send() {} }; const src = fs.readFileSync(path.join(CORE, 'core-bundle.js'), 'utf8'); const { OrchestratorModule, VirtualFileSystem } = new Function('window', src + '\n;return { OrchestratorModule: (typeof OrchestratorModule!=="undefined"?OrchestratorModule:null), VirtualFileSystem: (typeof VirtualFileSystem!=="undefined"?VirtualFileSystem:window.VirtualFileSystem) };' )(global.window); let failures = 0; const check = (label, cond, detail = '') => { console.log(`${cond ? '✅ PASS' : '❌ FAIL'} ${label}${detail ? ' — ' + detail : ''}`); if (!cond) failures++; }; const mkTerminal = (vfs) => ({ fs: vfs, print: () => {}, moduleManager: { modules: {}, _setInterval: () => null, _clearInterval: () => {} } }); const agentDirs = (vfs) => (vfs.readDir('/agents/') || []).filter(e => e.type === 'dir' && e.name.startsWith('agent-')); const boot = async (vfs) => { const orch = new OrchestratorModule(); orch.terminal = mkTerminal(vfs); orch._BOOT_DELAY = 0; await orch._bootAgents(); // the real init() path return orch; }; (async () => { const vfs = new VirtualFileSystem(); // ── 1. five consecutive "reloads" ─────────────────────────────────── for (let n = 1; n <= 5; n++) { const orch = await boot(vfs); const dirs = agentDirs(vfs); check(`boot ${n}: 3 agent dirs`, dirs.length === 3, `${dirs.length}: ${dirs.map(d => d.name).join(', ')}`); check(`boot ${n}: 3 live agents`, Object.keys(orch._agents).length === 3, `${Object.keys(orch._agents).length}`); check(`boot ${n}: one per role`, new Set(Object.values(orch._agents).map(a => a.role)).size === 3); } check('stable ids after reloads', agentDirs(vfs).every(d => d.name === 'agent-auto-learner' || d.name === 'agent-kernel-architect' || d.name === 'agent-searcher'), agentDirs(vfs).map(d => d.name).join(', ')); // ── 2. pre-existing timestamped garbage is swept ──────────────────── const legacy = [ ['agent-aaa111-x1-auto-learner', 'auto-learner'], ['agent-zzz999-x2-auto-learner', 'auto-learner'], ['agent-mmm555-x3-searcher', 'searcher'], ]; for (const [id, role] of legacy) { vfs.mkdir(`agents/${id}`); vfs.writeFile(`agents/${id}/status.json`, JSON.stringify({ id, role, status: 'idle', restartPolicy: 'always' })); } check('legacy dirs staged', agentDirs(vfs).length === 6, `${agentDirs(vfs).length} dirs`); const orch2 = await boot(vfs); check('legacy duplicates purged', agentDirs(vfs).length === 3, `${agentDirs(vfs).length} dirs`); check('no duplicate roles live', new Set(Object.values(orch2._agents).map(a => a.role)).size === Object.keys(orch2._agents).length); // ── 3. non-default (worker) sleeping agents are NOT collapsed ─────── for (const w of ['agent-w1aaaaaaaa-aaaa-worker', 'agent-w2bbbbbbbb-bbbb-worker']) { vfs.mkdir(`agents/${w}`); vfs.writeFile(`agents/${w}/sleep.json`, JSON.stringify({ id: w, role: 'worker', status: 'sleeping', capabilities: ['read_file'] })); } const orch3 = await boot(vfs); check('both sleeping workers reanimated', Object.values(orch3._agents).filter(a => a.role === 'worker').length === 2, `${Object.values(orch3._agents).filter(a => a.role === 'worker').length} workers`); check('no sleep.json left behind', !vfs.readFile('/agents/agent-w1aaaaaaaa-aaaa-worker/sleep.json')); // ── 4. /agents/register (registry dir) is never GC'd ──────────────── vfs.mkdir('agents/register'); await boot(vfs); check('registry dir /agents/register/ survives GC', (vfs.readDir('/agents/') || []).some(e => e.name === 'register')); // ── 5. async hydration race (the layer-2 bug) ─────────────────────── // Persist the current tree, then "reload" into a NEW VFS whose localStorage hydration is // asynchronous — exactly what a browser does. A synchronous reconcile must not see it. const before = agentDirs(vfs).map(d => d.name).sort(); // persist exactly the way the app does (gzip+base64 via VirtualFileSystem._compress) localStorage.setItem('webx11_filesystem_v2', await vfs._compress(JSON.stringify({ root: vfs.root, currentPath: [] }))); const vfs2 = new VirtualFileSystem(); // hydration starts, not finished yet const orchReload = new OrchestratorModule(); orchReload.terminal = mkTerminal(vfs2); orchReload._BOOT_DELAY = 0; orchReload._reanimateFromVFS(); // old (buggy) ordering check('sync reconcile before hydration sees an empty VFS', Object.keys(orchReload._agents).length === 0, `${Object.keys(orchReload._agents).length} agents`); await orchReload._bootAgents(); // fixed ordering const after = agentDirs(vfs2).map(d => d.name).sort(); check('post-hydration boot restores exactly the persisted set', JSON.stringify(after) === JSON.stringify(before), `before=${before.join(',')} after=${after.join(',')}`); console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`); process.exit(failures === 0 ? 0 : 1); })();