aboutsummaryrefslogtreecommitdiff
path: root/scripts/test-agents-reload.js
blob: 2441cf93c28771c9e9ca3ddf652e0b094b555485 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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);
})();