aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js197
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-orchestrator.js132
-rw-r--r--scripts/test-agents-reload.js129
5 files changed, 372 insertions, 94 deletions
diff --git a/main.html b/main.html
index f3564e6..b9b4d8c 100644
--- a/main.html
+++ b/main.html
@@ -1349,13 +1349,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.40"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.44"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.40"></script>
+<script src="modules/core/module-loader.js?v=v4.6.44"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.40"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.44"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index f275a1f..ee2e9d0 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -597,6 +597,7 @@ class VirtualFileSystem {
const trash = this.root.content['.trash'];
const trashName = ts + '-' + name;
trash.content[trashName] = JSON.parse(JSON.stringify(node));
+ this._pruneTrash();
const resolved = this._resolvePath(path);
if (resolved) delete resolved.dir.content[resolved.name];
this.saveToStorage();
@@ -622,6 +623,7 @@ class VirtualFileSystem {
let ctr = 0;
while (trash.content[unique]) { unique = baseName + '-' + (++ctr); }
trash.content[unique] = JSON.parse(JSON.stringify(node));
+ this._pruneTrash();
delete resolved.dir.content[resolved.name];
this.saveToStorage();
this._emitChange(path);
@@ -630,6 +632,18 @@ class VirtualFileSystem {
}
return false;
}
+ _pruneTrash(maxEntries = 50) {
+ // Bounded trash: deletions are moves-to-trash, so without a cap / .trash/ was an
+ // unbounded second accumulator. Names start with a millis timestamp → sort = oldest first.
+ const trash = this.root.content['.trash'];
+ if (!trash) return 0;
+ const keys = Object.keys(trash.content);
+ if (keys.length <= maxEntries) return 0;
+ keys.sort();
+ const drop = keys.slice(0, keys.length - maxEntries);
+ drop.forEach(k => { delete trash.content[k]; });
+ return drop.length;
+ }
deleteDirRecursive(path) {
return this.trashDir(path);
}
@@ -2052,7 +2066,13 @@ class OllamaManager {
}
return {
- message: message ? { role: message.role || 'assistant', content: content } : null,
+ message: message ? {
+ role: message.role || 'assistant',
+ content: content,
+ // OpenAI-compatible providers require tool_calls INSIDE the assistant message;
+ // a bare { role:'tool' } with no preceding tool_calls is rejected with HTTP 400.
+ ...(Array.isArray(toolCalls) && toolCalls.length ? { tool_calls: toolCalls } : {}),
+ } : null,
tool_calls: toolCalls ? toolCalls.map(tc => ({
id: tc.id,
type: tc.type || 'function',
@@ -8080,7 +8100,7 @@ class ServerMonitorModule {
this._toolEpoch = 0;
this._mutatingTools = new Set(['write_file', 'edit_module', 'run_command', 'delete_file',
'make_directory', 'commit_module', 'ship_module', 'patch_skill', 'delegate_task',
- 'reload_module', 'create_skill']);
+ 'reload_module', 'create_skill', 'create_mindmap']);
// Only deterministic reads are cached — never status/log/polling tools.
this._cacheableTools = new Set(['read_file', 'list_files', 'list_modules', 'view_module',
'nvfs_resolve', 'get_help', 'list_skills', 'diff_module', 'get_module_status']);
@@ -8891,10 +8911,14 @@ buildSystemPrompt() {
}
}
} catch (e) { console.warn('[AI] Auto-routing error:', e.message); }
- // Re-read context from localStorage (auto-restore may have written it after init)
- this._loadContext();
- // Compress context if it exceeds the token threshold
- this._compressContext();
+ // Agent runs must not touch the user's main conversation: _loadContext()/ _compressContext()
+ // reassign this.context, which would detach the session captured below.
+ const _isAgentRun = !!opts.agentTag;
+ if (!_isAgentRun) {
+ this._loadContext();
+ await this._compressContext();
+ this._toolCache.clear(); // out-of-band VFS edits may have happened since the last chat
+ }
// Per-agent session: 'main' aliases this.context, agents get their own history.
const sess = this._sessionCtx(opts.agentTag);
let loopResult;
@@ -9018,8 +9042,8 @@ buildSystemPrompt() {
} finally {
this._chatLock = false;
this.terminal.setAiBusy(false);
- // Persist conversation context after each exchange
- this._saveContext();
+ // Persist only the user's conversation — agent sessions are ephemeral.
+ if (!_isAgentRun) this._saveContext();
// Log the AI interaction
this.terminal.log('INFO', 'AI', 'Query: ' + userMessage.substring(0, 100));
if (loopResult && loopResult.toolTurns) {
@@ -9437,6 +9461,7 @@ buildSystemPrompt() {
var baseTurns = maxTurns;
let _lastTurnSig = null, _progressStreak = 0, _stallStreak = 0;
const _logStart = (this._toolCallLog || []).length;
+ const _loopLog = []; // per-run tool log (the global one self-trims to 50 and breaks slicing)
// D3: extend the budget only while turns keep producing NEW output.
const _noteProgress = (results) => {
try {
@@ -9503,7 +9528,7 @@ buildSystemPrompt() {
for (let mi = messages.length - 1; mi >= 0; mi--) {
if (messages[mi] && messages[mi]._ledger) messages.splice(mi, 1);
}
- const _led = (this._toolCallLog || []).slice(_logStart).slice(-8).map(e =>
+ const _led = _loopLog.slice(-8).map(e =>
'- ' + e.name + '(' + String(typeof e.args === 'string' ? e.args : JSON.stringify(e.args || {})).slice(0, 60) + ') → ' +
(e.error ? 'ERROR' : this._summarizeObservation(e.result, 80))
).join('\n');
@@ -9546,8 +9571,9 @@ buildSystemPrompt() {
for (const tc of res.tool_calls) {
const sig = tc.function.name + ':' + (tc.function.arguments || '');
let entry = _seenCalls.get(sig);
- if (!entry || (now - entry.ts) > _loopWindow) {
- entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null };
+ if (!entry || (now - entry.ts) > _loopWindow || entry.epoch !== this._toolEpoch) {
+ // A mutation since the last attempt invalidates the block: re-run instead of replaying.
+ entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null, epoch: this._toolEpoch };
}
entry.count++;
entry.ts = now;
@@ -9577,8 +9603,9 @@ buildSystemPrompt() {
: JSON.stringify({ blocked: true, reason: 'identical call repeated with unchanged result', tool: tc.function.name }) });
}
this.terminal.print(`🛑 Loop guard: ${blocked.length} repeated call(s) skipped — agent continues with cached data`, 'info');
- if (blocked.some(b => b.entry.count >= _hardStopAt)) {
- this.terminal.print(`⛔ ${_agentTag}: no progress after ${_hardStopAt} identical calls — ending loop with partial result`, 'error');
+ const _hs = Math.min(_hardStopAt, maxTurns);
+ if (blocked.some(b => b.entry.count >= _hs)) {
+ this.terminal.print(`⛔ ${_agentTag}: no progress after ${_hs} identical calls — ending loop with partial result`, 'error');
return { finalAnswer: this._loopSummary(toolTurns), toolTurns };
}
continue;
@@ -9626,6 +9653,7 @@ buildSystemPrompt() {
duration: t1,
error: this._isToolError(toolResult) ? true : false
});
+ _loopLog.push(this._toolCallLog[this._toolCallLog.length - 1]);
// Auto-trim to 50 — prevents unbounded memory growth
if (this._toolCallLog.length > 50) {
this._toolCallLog = this._toolCallLog.slice(-50);
@@ -9634,9 +9662,9 @@ buildSystemPrompt() {
const sm = this.terminal.moduleManager.modules['ServerMonitorModule'];
if (sm && sm.logToolCall) sm.logToolCall(fn.name, (t1 * 1000) || 1, this._isToolError(toolResult));
if (this._debugPanelEnabled) this._updateDebugPanel();
- safeResults.push({ fn, toolResult, t1 });
+ safeResults.push({ tc, fn, toolResult, t1 });
}
- for (const { fn, toolResult, t1 } of safeResults) {
+ for (const { tc, fn, toolResult, t1 } of safeResults) {
this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info');
// Memory graph: record tool call + file access
try {
@@ -9673,7 +9701,7 @@ buildSystemPrompt() {
_eR.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
}
} catch (_e) {}
- messages.push({ role: 'tool', content: toolResult });
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult });
}
// Only block on destructive tools
if (destructiveTools.length > 0) {
@@ -9707,6 +9735,7 @@ buildSystemPrompt() {
duration: t1,
error: this._isToolError(toolResult) ? true : false
});
+ _loopLog.push(this._toolCallLog[this._toolCallLog.length - 1]);
// Auto-trim to 50 — prevents unbounded memory growth
if (this._toolCallLog.length > 50) {
this._toolCallLog = this._toolCallLog.slice(-50);
@@ -9746,7 +9775,7 @@ buildSystemPrompt() {
_eD.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
}
} catch (_e) {}
- messages.push({ role: 'tool', content: toolResult });
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResult });
}
} else {
messages.push({
@@ -14768,7 +14797,7 @@ class AgentRegistryModule {
unregisterAgent(id) {
// Unregister from A2A scratchpad
this.a2aUnregister(id);
- const ok = this.terminal.fs.deleteFile('agents/' + id);
+ const ok = this.terminal.fs.rm('agents/' + id);
if (ok) this.terminal.print(`✅ Agent '${id}' unregistered`, 'success');
else this.terminal.print(`❌ Agent '${id}' not found`, 'error');
}
@@ -14948,6 +14977,10 @@ class OrchestratorModule {
this.terminal = null;
this._agents = {};
this._SPAWN_LIMIT = 5;
+ // Boot-time agents — stable per-role ids so a reload re-adopts the existing VFS dir
+ // instead of minting a new timestamp id every boot (that was the /agents/ accumulation bug).
+ this._DEFAULT_ROLES = ['auto-learner', 'kernel-architect', 'searcher'];
+ this._BOOT_DELAY = 2000; // let other modules init before the boot team spawns (tests set 0)
this._SPAWN_STRATEGY = 'eager';
this._HEARTBEAT_TIMEOUT = 15000;
this._IDLE_TIMEOUT = 120000;
@@ -15114,8 +15147,10 @@ class OrchestratorModule {
if (registry) {
registry.registerAgent(['register', 'orchestrator-0', 'delegate', 'spawn', 'monitor', 'purge']);
}
- // Reanimate sleeping agents from VFS
- this._reanimateFromVFS();
+ // Reconcile + pre-spawn the default team. VirtualFileSystem.loadFromStorage() is ASYNC,
+ // so reconciliation MUST wait for it — running it synchronously saw an empty root and
+ // silently did nothing, which let duplicate agents accumulate on every reload.
+ this._bootAgents();
// A2A mailbox polling loop (500ms)
if (this._A2A_POLLING) {
this._a2aPollTimer = this.terminal.moduleManager._setInterval(() => this._pollAgentMailboxes(), this._A2A_POLL_INTERVAL);
@@ -15127,8 +15162,6 @@ class OrchestratorModule {
this.terminal.print('🧠 Orchestrator loaded — A2A mailbox transport active', 'success');
// Load recurring schedules from VFS
this._loadSchedules();
- // Pre-spawn default agents after a short delay (let other modules init first)
- setTimeout(() => this._preSpawnDefaultAgents(), 2000);
// Subscribe to event bus for in-memory A2A delivery (instant, no VFS poll)
this._on('a2a:message', (msg) => {
if (msg.type === 'heartbeat' && msg.from && this._agents[msg.from]) {
@@ -15274,7 +15307,7 @@ class OrchestratorModule {
_sanitizeId(name) {
return (name || 'worker').replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 32) || 'worker';
}
- spawnAgent(role, capabilities, modelName = null) {
+ spawnAgent(role, capabilities, modelName = null, idHint = null) {
if (this._spawnLock) {
this.terminal.print('⚠️ Spawn in progress — queued', 'warning');
return null;
@@ -15282,7 +15315,12 @@ class OrchestratorModule {
this._spawnLock = true;
try {
const safeRole = this._sanitizeId(role);
- const id = `agent-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}-${safeRole}`;
+ const id = idHint || `agent-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}-${safeRole}`;
+ // Idempotent: a stable-id agent that is already live is reused, never duplicated
+ if (this._agents[id]) {
+ this.terminal.print(`♻️ Agent '${id}' already live — reusing`, 'info');
+ return this._agents[id];
+ }
if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) {
this.terminal.print(`❌ Agent spawn limit (${this._SPAWN_LIMIT}) reached`, 'error');
return null;
@@ -15340,8 +15378,10 @@ class OrchestratorModule {
registry.a2aUnregister(id);
}
}
- // Clean up VFS
- this.terminal.fs.deleteFile('agents/' + id);
+ // Clean up VFS — rm() dispatches file|dir → trash (deleteFile() is file-only
+ // and silently returns false for directories, which leaked /agents/<id>/)
+ const removed = this.terminal.fs.rm('agents/' + id);
+ if (!removed) this.terminal.print(`⚠️ VFS cleanup failed for '${id}' — /agents/${id}/ still present`, 'warning');
delete this._agents[id];
delete this._restartBackoffs[id];
this.terminal.print(`💀 Agent '${id}' killed`, 'warning');
@@ -15379,31 +15419,79 @@ class OrchestratorModule {
this.terminal.print(`🔄 Agent '${id}' reanimated from VFS sleep`, 'success');
}
+ async _bootAgents() {
+ // Boot sequence for /agents/ — order is load-bearing:
+ // 1. await VFS hydration (loadFromStorage is async; a sync reconcile sees an empty root)
+ // 2. reconcile /agents/ (adopt what is already there, purge duplicates/orphans)
+ // 3. only then spawn any missing default agent
+ try {
+ await Promise.resolve(this.terminal.fs._ready);
+ } catch (e) {
+ this.terminal.print('⚠️ VFS hydration failed — agent reconciliation skipped', 'warning');
+ return this._agents;
+ }
+ this._reanimateFromVFS();
+ if (this._BOOT_DELAY > 0) await new Promise(r => setTimeout(r, this._BOOT_DELAY));
+ this._preSpawnDefaultAgents();
+ return this._agents;
+ }
+
_reanimateFromVFS() {
+ // Boot reconciliation for /agents/:
+ // 1. sleep.json → reanimate the agent (previous behaviour)
+ // 2. status.json + default role → ADOPT it (a reload left it behind, still wanted)
+ // 3. no usable state / duplicate → orphan → fs.rm(), so a reload cannot grow /agents/
+ // Only `agent-*` dirs are touched; /agents/register/ belongs to AgentRegistry.
const fs = this.terminal.fs;
const agentsDir = fs.readDir('/agents/');
if (!agentsDir) return;
- let reanimated = 0;
+
+ // Stable ids (agent-<role>) beat timestamped ones; among timestamped ids, newest wins.
+ const tsOf = (id) => {
+ const m = /^agent-([0-9a-z]+)-[0-9a-z]{4}-/.exec(id);
+ return m ? (parseInt(m[1], 36) || 0) : Number.MAX_SAFE_INTEGER;
+ };
+ const keep = new Map(); // role:<role> for default agents (singleton) | id:<id> for workers
+ const orphans = [];
+
for (const entry of agentsDir) {
- if (entry.type !== 'dir') continue;
- const sleepData = fs.fsReadJSON(`/agents/${entry.name}/sleep.json`);
- if (sleepData) {
- const agent = {
- ...sleepData,
- status: 'idle',
- currentTask: null,
- leaseId: null,
- lastHeartbeat: Date.now()
- };
- delete agent._sleepTime;
- this._agents[entry.name] = agent;
- fs.writeFile(`agents/${entry.name}/status.json`, JSON.stringify({ ...agent, status: 'idle' }));
- reanimated++;
+ if (entry.type !== 'dir' || !entry.name.startsWith('agent-')) continue;
+ const id = entry.name;
+ if (this._agents[id]) continue;
+ const sleepData = fs.fsReadJSON(`/agents/${id}/sleep.json`);
+ const statusData = fs.fsReadJSON(`/agents/${id}/status.json`);
+ const state = sleepData || null;
+ const role = (state || statusData || {}).role;
+ const wanted = !!state || (statusData && this._DEFAULT_ROLES.includes(role));
+ if (!wanted) { orphans.push(id); continue; }
+ // Default roles are singletons (one dir per role); worker agents stay unique per id.
+ const key = this._DEFAULT_ROLES.includes(role) ? 'role:' + role : 'id:' + id;
+ const prev = keep.get(key);
+ if (!prev || tsOf(id) > tsOf(prev.id)) {
+ if (prev) orphans.push(prev.id);
+ keep.set(key, { id, state: state || statusData });
+ } else {
+ orphans.push(id);
}
}
- if (reanimated > 0) {
- this.terminal.print(`🔄 Reanimated ${reanimated} agent(s) from VFS sleep`, 'info');
+
+ for (const { id, state } of keep.values()) {
+ const agent = {
+ ...state,
+ status: 'idle',
+ currentTask: null,
+ leaseId: null,
+ lastHeartbeat: Date.now()
+ };
+ delete agent._sleepTime;
+ this._agents[id] = agent;
+ fs.writeFile(`agents/${id}/status.json`, JSON.stringify({ ...agent, status: 'idle' }));
+ if (fs.readFile(`/agents/${id}/sleep.json`)) fs.rm(`agents/${id}/sleep.json`);
}
+ for (const id of orphans) fs.rm('agents/' + id); // rm() dispatches dir → trashDir
+
+ if (keep.size > 0) this.terminal.print(`🔄 Restored ${keep.size} agent(s) from VFS`, 'info');
+ if (orphans.length > 0) this.terminal.print(`🧹 Purged ${orphans.length} orphaned agent dir(s)`, 'warning');
}
// ─── Restart Policies ───
@@ -16224,22 +16312,23 @@ class OrchestratorModule {
}
_preSpawnDefaultAgents() {
- // Spawn 3 role-specific agents at boot
+ // Spawn the default team ONCE per browser profile. Stable ids (`agent-<role>`) mean a
+ // reload re-adopts the existing dir in _reanimateFromVFS() instead of writing a new one.
var roles = [
- { role: 'auto-learner', caps: ['list_files', 'read_file', 'write_file', 'view_module'], task: 'audit skills' },
- { role: 'kernel-architect', caps: ['list_files', 'read_file', 'write_file', 'run_command'], task: 'index VFS' },
- { role: 'searcher', caps: ['list_files', 'read_file', 'web_search'], task: 'check stale data' }
+ { role: 'auto-learner', caps: ['list_files', 'read_file', 'write_file', 'view_module'] },
+ { role: 'kernel-architect', caps: ['list_files', 'read_file', 'write_file', 'run_command'] },
+ { role: 'searcher', caps: ['list_files', 'read_file', 'web_search'] }
];
for (var i = 0; i < roles.length; i++) {
var r = roles[i];
- // Check if an agent with this role already exists
+ // Already live (fresh spawn this boot, or adopted from VFS) → just keep it alive
var existing = Object.values(this._agents).find(function(a) { return a.role === r.role; });
- if (!existing) {
- var agent = this.spawnAgent(r.role, r.caps);
- if (agent) {
- agent.restartPolicy = 'always';
- this.terminal.print('🤖 Pre-spawned agent: ' + r.role + ' (' + r.caps.length + ' caps)', 'info');
- }
+ if (existing) { existing.restartPolicy = 'always'; continue; }
+ var id = 'agent-' + this._sanitizeId(r.role);
+ var agent = this.spawnAgent(r.role, r.caps, null, id);
+ if (agent) {
+ agent.restartPolicy = 'always';
+ this.terminal.print('🤖 Pre-spawned agent: ' + r.role + ' (' + r.caps.length + ' caps)', 'info');
}
}
}
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 0913d6a..7416e02 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -4,7 +4,7 @@
// Core module bundle loaded synchronously in main.html.
// This loader only handles version cache-busting, splash, terminal init, and optional ext modules.
- const LATEST_VER = 'v4.6.40';
+ const LATEST_VER = 'v4.6.44';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
diff --git a/modules/core/module-orchestrator.js b/modules/core/module-orchestrator.js
index 5cff89e..ad9f020 100644
--- a/modules/core/module-orchestrator.js
+++ b/modules/core/module-orchestrator.js
@@ -8,6 +8,10 @@ class OrchestratorModule {
this.terminal = null;
this._agents = {};
this._SPAWN_LIMIT = 5;
+ // Boot-time agents — stable per-role ids so a reload re-adopts the existing VFS dir
+ // instead of minting a new timestamp id every boot (that was the /agents/ accumulation bug).
+ this._DEFAULT_ROLES = ['auto-learner', 'kernel-architect', 'searcher'];
+ this._BOOT_DELAY = 2000; // let other modules init before the boot team spawns (tests set 0)
this._SPAWN_STRATEGY = 'eager';
this._HEARTBEAT_TIMEOUT = 15000;
this._IDLE_TIMEOUT = 120000;
@@ -174,8 +178,10 @@ class OrchestratorModule {
if (registry) {
registry.registerAgent(['register', 'orchestrator-0', 'delegate', 'spawn', 'monitor', 'purge']);
}
- // Reanimate sleeping agents from VFS
- this._reanimateFromVFS();
+ // Reconcile + pre-spawn the default team. VirtualFileSystem.loadFromStorage() is ASYNC,
+ // so reconciliation MUST wait for it — running it synchronously saw an empty root and
+ // silently did nothing, which let duplicate agents accumulate on every reload.
+ this._bootAgents();
// A2A mailbox polling loop (500ms)
if (this._A2A_POLLING) {
this._a2aPollTimer = this.terminal.moduleManager._setInterval(() => this._pollAgentMailboxes(), this._A2A_POLL_INTERVAL);
@@ -187,8 +193,6 @@ class OrchestratorModule {
this.terminal.print('🧠 Orchestrator loaded — A2A mailbox transport active', 'success');
// Load recurring schedules from VFS
this._loadSchedules();
- // Pre-spawn default agents after a short delay (let other modules init first)
- setTimeout(() => this._preSpawnDefaultAgents(), 2000);
// Subscribe to event bus for in-memory A2A delivery (instant, no VFS poll)
this._on('a2a:message', (msg) => {
if (msg.type === 'heartbeat' && msg.from && this._agents[msg.from]) {
@@ -334,7 +338,7 @@ class OrchestratorModule {
_sanitizeId(name) {
return (name || 'worker').replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 32) || 'worker';
}
- spawnAgent(role, capabilities, modelName = null) {
+ spawnAgent(role, capabilities, modelName = null, idHint = null) {
if (this._spawnLock) {
this.terminal.print('⚠️ Spawn in progress — queued', 'warning');
return null;
@@ -342,7 +346,12 @@ class OrchestratorModule {
this._spawnLock = true;
try {
const safeRole = this._sanitizeId(role);
- const id = `agent-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}-${safeRole}`;
+ const id = idHint || `agent-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}-${safeRole}`;
+ // Idempotent: a stable-id agent that is already live is reused, never duplicated
+ if (this._agents[id]) {
+ this.terminal.print(`♻️ Agent '${id}' already live — reusing`, 'info');
+ return this._agents[id];
+ }
if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) {
this.terminal.print(`❌ Agent spawn limit (${this._SPAWN_LIMIT}) reached`, 'error');
return null;
@@ -400,8 +409,10 @@ class OrchestratorModule {
registry.a2aUnregister(id);
}
}
- // Clean up VFS
- this.terminal.fs.deleteFile('agents/' + id);
+ // Clean up VFS — rm() dispatches file|dir → trash (deleteFile() is file-only
+ // and silently returns false for directories, which leaked /agents/<id>/)
+ const removed = this.terminal.fs.rm('agents/' + id);
+ if (!removed) this.terminal.print(`⚠️ VFS cleanup failed for '${id}' — /agents/${id}/ still present`, 'warning');
delete this._agents[id];
delete this._restartBackoffs[id];
this.terminal.print(`💀 Agent '${id}' killed`, 'warning');
@@ -439,31 +450,79 @@ class OrchestratorModule {
this.terminal.print(`🔄 Agent '${id}' reanimated from VFS sleep`, 'success');
}
+ async _bootAgents() {
+ // Boot sequence for /agents/ — order is load-bearing:
+ // 1. await VFS hydration (loadFromStorage is async; a sync reconcile sees an empty root)
+ // 2. reconcile /agents/ (adopt what is already there, purge duplicates/orphans)
+ // 3. only then spawn any missing default agent
+ try {
+ await Promise.resolve(this.terminal.fs._ready);
+ } catch (e) {
+ this.terminal.print('⚠️ VFS hydration failed — agent reconciliation skipped', 'warning');
+ return this._agents;
+ }
+ this._reanimateFromVFS();
+ if (this._BOOT_DELAY > 0) await new Promise(r => setTimeout(r, this._BOOT_DELAY));
+ this._preSpawnDefaultAgents();
+ return this._agents;
+ }
+
_reanimateFromVFS() {
+ // Boot reconciliation for /agents/:
+ // 1. sleep.json → reanimate the agent (previous behaviour)
+ // 2. status.json + default role → ADOPT it (a reload left it behind, still wanted)
+ // 3. no usable state / duplicate → orphan → fs.rm(), so a reload cannot grow /agents/
+ // Only `agent-*` dirs are touched; /agents/register/ belongs to AgentRegistry.
const fs = this.terminal.fs;
const agentsDir = fs.readDir('/agents/');
if (!agentsDir) return;
- let reanimated = 0;
+
+ // Stable ids (agent-<role>) beat timestamped ones; among timestamped ids, newest wins.
+ const tsOf = (id) => {
+ const m = /^agent-([0-9a-z]+)-[0-9a-z]{4}-/.exec(id);
+ return m ? (parseInt(m[1], 36) || 0) : Number.MAX_SAFE_INTEGER;
+ };
+ const keep = new Map(); // role:<role> for default agents (singleton) | id:<id> for workers
+ const orphans = [];
+
for (const entry of agentsDir) {
- if (entry.type !== 'dir') continue;
- const sleepData = fs.fsReadJSON(`/agents/${entry.name}/sleep.json`);
- if (sleepData) {
- const agent = {
- ...sleepData,
- status: 'idle',
- currentTask: null,
- leaseId: null,
- lastHeartbeat: Date.now()
- };
- delete agent._sleepTime;
- this._agents[entry.name] = agent;
- fs.writeFile(`agents/${entry.name}/status.json`, JSON.stringify({ ...agent, status: 'idle' }));
- reanimated++;
+ if (entry.type !== 'dir' || !entry.name.startsWith('agent-')) continue;
+ const id = entry.name;
+ if (this._agents[id]) continue;
+ const sleepData = fs.fsReadJSON(`/agents/${id}/sleep.json`);
+ const statusData = fs.fsReadJSON(`/agents/${id}/status.json`);
+ const state = sleepData || null;
+ const role = (state || statusData || {}).role;
+ const wanted = !!state || (statusData && this._DEFAULT_ROLES.includes(role));
+ if (!wanted) { orphans.push(id); continue; }
+ // Default roles are singletons (one dir per role); worker agents stay unique per id.
+ const key = this._DEFAULT_ROLES.includes(role) ? 'role:' + role : 'id:' + id;
+ const prev = keep.get(key);
+ if (!prev || tsOf(id) > tsOf(prev.id)) {
+ if (prev) orphans.push(prev.id);
+ keep.set(key, { id, state: state || statusData });
+ } else {
+ orphans.push(id);
}
}
- if (reanimated > 0) {
- this.terminal.print(`🔄 Reanimated ${reanimated} agent(s) from VFS sleep`, 'info');
+
+ for (const { id, state } of keep.values()) {
+ const agent = {
+ ...state,
+ status: 'idle',
+ currentTask: null,
+ leaseId: null,
+ lastHeartbeat: Date.now()
+ };
+ delete agent._sleepTime;
+ this._agents[id] = agent;
+ fs.writeFile(`agents/${id}/status.json`, JSON.stringify({ ...agent, status: 'idle' }));
+ if (fs.readFile(`/agents/${id}/sleep.json`)) fs.rm(`agents/${id}/sleep.json`);
}
+ for (const id of orphans) fs.rm('agents/' + id); // rm() dispatches dir → trashDir
+
+ if (keep.size > 0) this.terminal.print(`🔄 Restored ${keep.size} agent(s) from VFS`, 'info');
+ if (orphans.length > 0) this.terminal.print(`🧹 Purged ${orphans.length} orphaned agent dir(s)`, 'warning');
}
// ─── Restart Policies ───
@@ -1284,22 +1343,23 @@ class OrchestratorModule {
}
_preSpawnDefaultAgents() {
- // Spawn 3 role-specific agents at boot
+ // Spawn the default team ONCE per browser profile. Stable ids (`agent-<role>`) mean a
+ // reload re-adopts the existing dir in _reanimateFromVFS() instead of writing a new one.
var roles = [
- { role: 'auto-learner', caps: ['list_files', 'read_file', 'write_file', 'view_module'], task: 'audit skills' },
- { role: 'kernel-architect', caps: ['list_files', 'read_file', 'write_file', 'run_command'], task: 'index VFS' },
- { role: 'searcher', caps: ['list_files', 'read_file', 'web_search'], task: 'check stale data' }
+ { role: 'auto-learner', caps: ['list_files', 'read_file', 'write_file', 'view_module'] },
+ { role: 'kernel-architect', caps: ['list_files', 'read_file', 'write_file', 'run_command'] },
+ { role: 'searcher', caps: ['list_files', 'read_file', 'web_search'] }
];
for (var i = 0; i < roles.length; i++) {
var r = roles[i];
- // Check if an agent with this role already exists
+ // Already live (fresh spawn this boot, or adopted from VFS) → just keep it alive
var existing = Object.values(this._agents).find(function(a) { return a.role === r.role; });
- if (!existing) {
- var agent = this.spawnAgent(r.role, r.caps);
- if (agent) {
- agent.restartPolicy = 'always';
- this.terminal.print('🤖 Pre-spawned agent: ' + r.role + ' (' + r.caps.length + ' caps)', 'info');
- }
+ if (existing) { existing.restartPolicy = 'always'; continue; }
+ var id = 'agent-' + this._sanitizeId(r.role);
+ var agent = this.spawnAgent(r.role, r.caps, null, id);
+ if (agent) {
+ agent.restartPolicy = 'always';
+ this.terminal.print('🤖 Pre-spawned agent: ' + r.role + ' (' + r.caps.length + ' caps)', 'info');
}
}
}
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);
+})();