From a065a4c30be748b0c8fc6dd0b5ee1dc4d3de4b1c Mon Sep 17 00:00:00 2001 From: Agando Date: Mon, 9 Nov 2026 15:36:23 +0100 Subject: chore(newos4): commit accumulated maintenance output The daily collector and the feedback pipeline had left 57 modified files uncommitted (skills, modules-index, CHANGELOG, feedback decisions, debug counters). The public mirror publishes git HEAD, so without this the published repo stayed behind the tree - and it blocked the publisher outright once uncommitted subtrees started failing loudly. --- scripts/test-agents-lifecycle.js | 87 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 scripts/test-agents-lifecycle.js (limited to 'scripts/test-agents-lifecycle.js') diff --git a/scripts/test-agents-lifecycle.js b/scripts/test-agents-lifecycle.js new file mode 100644 index 0000000..4f011a1 --- /dev/null +++ b/scripts/test-agents-lifecycle.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * scripts/test-agents-lifecycle.js — regression test for the /agents/ VFS leak. + * + * Bug history: killAgent() / AgentRegistry.unregisterAgent() called + * fs.deleteFile('agents/') on a DIRECTORY. VirtualFileSystem.deleteFile() + * is file-only (returns false for dirs), so the call silently no-op'd and + * /agents// (status.json, inbox.json, outbox.json) leaked forever. + * Fixed by using fs.rm() (dispatches file|dir → trash) + bounded .trash. + * + * Loads the PRODUCTION artifact (modules/core/core-bundle.js), not the sources, + * because main.html serves the bundle — a source-only fix is invisible in prod. + * + * Run: node scripts/test-agents-lifecycle.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 log = []; +const vfs = new VirtualFileSystem(); +const terminal = { fs: vfs, print: m => log.push(String(m)), moduleManager: { modules: {}, _setInterval: () => null, _clearInterval: () => {} } }; +const orch = new OrchestratorModule(); +orch.terminal = terminal; +const dirs = () => (vfs.readDir('/agents/') || []).filter(e => e.type === 'dir').map(e => e.name); + +// ── 1. spawn/kill cycle leaves nothing behind ───────────────────────── +const ids = []; +for (let i = 0; i < 5; i++) ids.push(orch.spawnAgent('worker', ['read_file']).id); +check('spawn creates 5 agent dirs', dirs().length === 5, `/agents/=${dirs().length}`); +ids.forEach(id => orch.killAgent(id)); +check('kill frees all in-memory agents', Object.keys(orch._agents).length === 0); +check('kill removes all /agents/ dirs (the leak)', dirs().length === 0, dirs().length ? 'LEAKED: ' + dirs().join(',') : 'clean'); +check('no VFS cleanup warnings', !log.some(l => l.includes('VFS cleanup failed'))); + +// ── 2. registry unregister path (second call site) ─────────────────── +const AgentRegistryModule = new Function('window', fs.readFileSync(path.join(CORE, 'module-agent-registry.js'), 'utf8') + + '\n;return AgentRegistryModule;')(global.window); +const registry = Object.create(AgentRegistryModule.prototype); +registry.terminal = terminal; +registry.a2aUnregister = () => {}; +const a2 = orch.spawnAgent('searcher', ['read_file']); +registry.unregisterAgent(a2.id); +check('registry.unregisterAgent removes the dir', !dirs().includes(a2.id)); + +// ── 3. .trash stays bounded ────────────────────────────────────────── +orch._SPAWN_LIMIT = 200; +for (let i = 0; i < 60; i++) { const a = orch.spawnAgent('w', ['read_file']); if (a) orch.killAgent(a.id); } +const trash = vfs.readDir('/.trash/') || []; +check('/agents/ still empty after 60 cycles', dirs().length === 0, `${dirs().length} dirs`); +check('.trash capped at 50 entries', trash.length <= 50, `${trash.length} entries`); + +console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`); +process.exit(failures === 0 ? 0 : 1); -- cgit v1.2.3