diff options
Diffstat (limited to 'modules/core/module-orchestrator.js')
| -rw-r--r-- | modules/core/module-orchestrator.js | 88 |
1 files changed, 61 insertions, 27 deletions
diff --git a/modules/core/module-orchestrator.js b/modules/core/module-orchestrator.js index 6b8ee18..05d7392 100644 --- a/modules/core/module-orchestrator.js +++ b/modules/core/module-orchestrator.js @@ -1084,6 +1084,18 @@ class OrchestratorModule { if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error'); this._delegations = (this._delegations || 0) + 1; + // Parallel fan-out bookkeeping. A sub-task carries its own id, and the batch that + // spawned it hands in a sink, so the harvest below matches results by identity — + // never by a task string that a later delegation on the same agent can overwrite. + const taskId = opts.taskId || null; + const resultSink = opts.resultSink || null; + const recordResult = (status, payload) => { + if (!taskId || !resultSink || typeof resultSink.set !== 'function') return; + resultSink.set(taskId, Object.assign({ + taskId, task, status, at: new Date().toISOString() + }, payload || {})); + }; + // Parallel fan-out: decompose and run sub-tasks concurrently if (opts.parallel) { const parts = this._decomposeGoal(task); @@ -1104,38 +1116,45 @@ class OrchestratorModule { } } - const results = await Promise.allSettled(parts.map(sub => this.delegateTask(sub.goal))); - const successes = results.filter(r => r.status === 'fulfilled').length; - const failures = results.filter(r => r.status === 'rejected').length; + const runId = 'pb' + Date.now().toString(36) + '-' + (this._parallelSeq = (this._parallelSeq || 0) + 1); + const resultSink = new Map(); + const results = await Promise.allSettled(parts.map((sub, i) => + this.delegateTask(sub.goal, { taskId: runId + ':' + i, resultSink }))); - // Result synthesis: collect outbox results from each sub-task's agent + // Result synthesis: one row per sub-task, looked up by the id handed to it. const summaries = []; for (let i = 0; i < parts.length; i++) { const sub = parts[i]; const r = results[i]; - if (r.status === 'fulfilled') { - // Find the agent that handled this sub-task - const agent = Object.values(this._agents).find(a => a.currentTask === sub.goal || (a.lastTask === sub.goal)); - if (!agent) { - // "ok / completed (agent recycled)" told the caller a task succeeded - // when we could not even find its agent. - summaries.push({ task: sub.goal, status: 'failed', error: 'agent not found — result unverifiable' }); - continue; - } - const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`); - const body = outbox && typeof outbox.result === 'string' ? outbox.result : ''; - const snippet = body.substring(0, 200); + const subTaskId = runId + ':' + i; + const rec = resultSink.get(subTaskId); + if (rec) { summaries.push({ - task: sub.goal, agent: agent.id, - status: body ? 'ok' : 'failed', - result: body || null, - error: body ? undefined : (outbox && outbox.error) || 'no result in outbox' + task: sub.goal, + taskId: subTaskId, + agent: rec.agentId, + status: rec.status === 'ok' ? 'ok' : 'failed', + result: rec.status === 'ok' ? (rec.result || null) : null, + error: rec.status === 'ok' ? undefined : (rec.error || 'no result recorded') }); - } else { - summaries.push({ task: sub.goal, status: 'failed', error: r.reason?.message || 'unknown error' }); + continue; } + // The call died before it could report itself (a throw from a path that never + // reached a recordResult call): the promise state is the only evidence left. + summaries.push({ + task: sub.goal, + taskId: subTaskId, + status: 'failed', + error: (r.status === 'rejected' && ((r.reason && r.reason.message) || String(r.reason))) || 'no result recorded' + }); } + // The tally IS the summaries — never the promise states. allSettled counts + // resolutions, and a spawn refusal returns terminal.print() (undefined), which + // FULFILS: that is how "3/3 succeeded" printed over three failed rows. + const successes = summaries.filter(s => s.status === 'ok').length; + const failures = summaries.length - successes; + // Store merged result to VFS for persistence const merged = { type: 'parallel_result', @@ -1157,7 +1176,8 @@ class OrchestratorModule { const snippet = s.result ? s.result.replace(/\n/g, ' ').substring(0, 60) : ''; this.terminal.print(' ' + icon + ' ' + s.task.substring(0, 35) + agentTag + ' → ' + snippet, 'info'); } - this.terminal.print('✅ Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), 'header'); + // The header must agree with the rows above it: ✅ only when nothing failed. + this.terminal.print((failures === 0 ? '✅' : '❌') + ' Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), failures === 0 ? 'header' : 'error'); return merged; } // Single sub-task — fall through to normal path @@ -1176,8 +1196,10 @@ class OrchestratorModule { // Auto-spawn with role-derived capabilities if (!agent) { if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) { - // Try finding any non-failed agent to reuse - const reuse = Object.values(this._agents).find(a => a.status !== 'failed' && a.status !== 'sleeping'); + // Reuse an agent that is NOT running. A BUSY agent must never be handed a + // second task: concurrent sub-tasks then overwrite currentTask/leaseId and + // erase each other's results (reproduced 4/4 — Defect 2b). + const reuse = Object.values(this._agents).find(a => a.status !== 'busy' && a.status !== 'failed'); if (reuse) { reuse.status = 'busy'; agent = reuse; @@ -1187,11 +1209,18 @@ class OrchestratorModule { const role = this._deriveRole(task); const caps = this._deriveCapabilities(task); agent = this.spawnAgent(role, caps); - if (!agent) return this.terminal.print('❌ No available agents — spawn limit reached', 'error'); + if (!agent) { + const msg = 'no idle agent — ' + Object.keys(this._agents).length + '/' + this._SPAWN_LIMIT + ' agents busy, refusing to share one'; + recordResult('failed', { error: msg }); + return this.terminal.print('❌ ' + msg, 'error'); + } agent.status = 'busy'; // claim immediately after spawn } } - if (!agent) return this.terminal.print('❌ No available agents', 'error'); + if (!agent) { + recordResult('failed', { error: 'no agent available' }); + return this.terminal.print('❌ No available agents', 'error'); + } // A2A Contract handshake const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; @@ -1261,6 +1290,9 @@ class OrchestratorModule { // Return result for callers that await delegateTask outboxResult = _answer; // '' when the worker returned nothing; callers see that + recordResult(_answer ? 'ok' : 'failed', _answer + ? { result: _answer, agentId: agent.id } + : { agentId: agent.id, error: 'agent produced no answer' }); // Check restart policy — if always, set back to idle for next task if (agent.restartPolicy === 'always') { @@ -1272,8 +1304,10 @@ class OrchestratorModule { } else { this.terminal.print(`ℹ️ AI module not available — task queued for ${agent.id}`, 'info'); outboxResult = 'queued — AI module not loaded, nothing executed'; + recordResult('failed', { agentId: agent.id, error: 'AI module not loaded — nothing executed' }); } } catch (e) { + recordResult('failed', { agentId: agent.id, error: e.message || String(e) }); this._handleAgentFailure(agent.id, e.message); } |