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);
})();
|