aboutsummaryrefslogtreecommitdiff
path: root/scripts/test-agents-reload.js
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/test-agents-reload.js')
-rw-r--r--scripts/test-agents-reload.js129
1 files changed, 129 insertions, 0 deletions
diff --git a/scripts/test-agents-reload.js b/scripts/test-agents-reload.js
new file mode 100644
index 0000000..2441cf9
--- /dev/null
+++ b/scripts/test-agents-reload.js
@@ -0,0 +1,129 @@
+#!/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-<ts>-<rand>-<role>` 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-<role>` 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);
+})();