attocode 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -1
- package/dist/src/adapters.d.ts.map +1 -1
- package/dist/src/adapters.js +1 -0
- package/dist/src/adapters.js.map +1 -1
- package/dist/src/agent.d.ts +5 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +138 -31
- package/dist/src/agent.js.map +1 -1
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +37 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/init-commands.d.ts.map +1 -1
- package/dist/src/commands/init-commands.js +57 -0
- package/dist/src/commands/init-commands.js.map +1 -1
- package/dist/src/core/protocol/types.d.ts +14 -14
- package/dist/src/defaults.d.ts.map +1 -1
- package/dist/src/defaults.js +1 -0
- package/dist/src/defaults.js.map +1 -1
- package/dist/src/integrations/economics.d.ts +9 -0
- package/dist/src/integrations/economics.d.ts.map +1 -1
- package/dist/src/integrations/economics.js +25 -0
- package/dist/src/integrations/economics.js.map +1 -1
- package/dist/src/integrations/index.d.ts +2 -1
- package/dist/src/integrations/index.d.ts.map +1 -1
- package/dist/src/integrations/index.js +3 -1
- package/dist/src/integrations/index.js.map +1 -1
- package/dist/src/integrations/learning-store.d.ts.map +1 -1
- package/dist/src/integrations/learning-store.js +6 -0
- package/dist/src/integrations/learning-store.js.map +1 -1
- package/dist/src/integrations/smart-decomposer.d.ts.map +1 -1
- package/dist/src/integrations/smart-decomposer.js +7 -0
- package/dist/src/integrations/smart-decomposer.js.map +1 -1
- package/dist/src/integrations/swarm/index.d.ts +29 -0
- package/dist/src/integrations/swarm/index.d.ts.map +1 -0
- package/dist/src/integrations/swarm/index.js +29 -0
- package/dist/src/integrations/swarm/index.js.map +1 -0
- package/dist/src/integrations/swarm/model-selector.d.ts +55 -0
- package/dist/src/integrations/swarm/model-selector.d.ts.map +1 -0
- package/dist/src/integrations/swarm/model-selector.js +342 -0
- package/dist/src/integrations/swarm/model-selector.js.map +1 -0
- package/dist/src/integrations/swarm/request-throttle.d.ts +112 -0
- package/dist/src/integrations/swarm/request-throttle.d.ts.map +1 -0
- package/dist/src/integrations/swarm/request-throttle.js +263 -0
- package/dist/src/integrations/swarm/request-throttle.js.map +1 -0
- package/dist/src/integrations/swarm/swarm-budget.d.ts +31 -0
- package/dist/src/integrations/swarm/swarm-budget.d.ts.map +1 -0
- package/dist/src/integrations/swarm/swarm-budget.js +36 -0
- package/dist/src/integrations/swarm/swarm-budget.js.map +1 -0
- package/dist/src/integrations/swarm/swarm-config-loader.d.ts +51 -0
- package/dist/src/integrations/swarm/swarm-config-loader.d.ts.map +1 -0
- package/dist/src/integrations/swarm/swarm-config-loader.js +458 -0
- package/dist/src/integrations/swarm/swarm-config-loader.js.map +1 -0
- package/dist/src/integrations/swarm/swarm-event-bridge.d.ts +145 -0
- package/dist/src/integrations/swarm/swarm-event-bridge.d.ts.map +1 -0
- package/dist/src/integrations/swarm/swarm-event-bridge.js +443 -0
- package/dist/src/integrations/swarm/swarm-event-bridge.js.map +1 -0
- package/dist/src/integrations/swarm/swarm-events.d.ts +157 -0
- package/dist/src/integrations/swarm/swarm-events.d.ts.map +1 -0
- package/dist/src/integrations/swarm/swarm-events.js +81 -0
- package/dist/src/integrations/swarm/swarm-events.js.map +1 -0
- package/dist/src/integrations/swarm/swarm-orchestrator.d.ts +166 -0
- package/dist/src/integrations/swarm/swarm-orchestrator.d.ts.map +1 -0
- package/dist/src/integrations/swarm/swarm-orchestrator.js +1114 -0
- package/dist/src/integrations/swarm/swarm-orchestrator.js.map +1 -0
- package/dist/src/integrations/swarm/swarm-quality-gate.d.ts +29 -0
- package/dist/src/integrations/swarm/swarm-quality-gate.d.ts.map +1 -0
- package/dist/src/integrations/swarm/swarm-quality-gate.js +85 -0
- package/dist/src/integrations/swarm/swarm-quality-gate.js.map +1 -0
- package/dist/src/integrations/swarm/swarm-state-store.d.ts +31 -0
- package/dist/src/integrations/swarm/swarm-state-store.d.ts.map +1 -0
- package/dist/src/integrations/swarm/swarm-state-store.js +91 -0
- package/dist/src/integrations/swarm/swarm-state-store.js.map +1 -0
- package/dist/src/integrations/swarm/task-queue.d.ts +128 -0
- package/dist/src/integrations/swarm/task-queue.d.ts.map +1 -0
- package/dist/src/integrations/swarm/task-queue.js +379 -0
- package/dist/src/integrations/swarm/task-queue.js.map +1 -0
- package/dist/src/integrations/swarm/types.d.ts +425 -0
- package/dist/src/integrations/swarm/types.d.ts.map +1 -0
- package/dist/src/integrations/swarm/types.js +96 -0
- package/dist/src/integrations/swarm/types.js.map +1 -0
- package/dist/src/integrations/swarm/worker-pool.d.ts +96 -0
- package/dist/src/integrations/swarm/worker-pool.d.ts.map +1 -0
- package/dist/src/integrations/swarm/worker-pool.js +269 -0
- package/dist/src/integrations/swarm/worker-pool.js.map +1 -0
- package/dist/src/main.js +88 -0
- package/dist/src/main.js.map +1 -1
- package/dist/src/modes/repl.d.ts +1 -0
- package/dist/src/modes/repl.d.ts.map +1 -1
- package/dist/src/modes/repl.js +2 -1
- package/dist/src/modes/repl.js.map +1 -1
- package/dist/src/modes/tui.d.ts +1 -0
- package/dist/src/modes/tui.d.ts.map +1 -1
- package/dist/src/modes/tui.js +3 -1
- package/dist/src/modes/tui.js.map +1 -1
- package/dist/src/providers/adapters/openrouter.d.ts +14 -0
- package/dist/src/providers/adapters/openrouter.d.ts.map +1 -1
- package/dist/src/providers/adapters/openrouter.js +53 -1
- package/dist/src/providers/adapters/openrouter.js.map +1 -1
- package/dist/src/providers/resilient-fetch.d.ts +2 -0
- package/dist/src/providers/resilient-fetch.d.ts.map +1 -1
- package/dist/src/providers/resilient-fetch.js +27 -3
- package/dist/src/providers/resilient-fetch.js.map +1 -1
- package/dist/src/providers/types.d.ts +11 -0
- package/dist/src/providers/types.d.ts.map +1 -1
- package/dist/src/providers/types.js.map +1 -1
- package/dist/src/tools/bash.d.ts +2 -2
- package/dist/src/tools/file.d.ts +4 -4
- package/dist/src/tui/app.d.ts.map +1 -1
- package/dist/src/tui/app.js +75 -4
- package/dist/src/tui/app.js.map +1 -1
- package/dist/src/tui/components/SwarmStatusPanel.d.ts +27 -0
- package/dist/src/tui/components/SwarmStatusPanel.d.ts.map +1 -0
- package/dist/src/tui/components/SwarmStatusPanel.js +108 -0
- package/dist/src/tui/components/SwarmStatusPanel.js.map +1 -0
- package/dist/src/types.d.ts +3 -1
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swarm Orchestrator V2
|
|
3
|
+
*
|
|
4
|
+
* The main orchestration loop that ties together:
|
|
5
|
+
* - SmartDecomposer for task breakdown
|
|
6
|
+
* - SwarmTaskQueue for wave-based scheduling
|
|
7
|
+
* - SwarmWorkerPool for concurrent worker dispatch
|
|
8
|
+
* - SwarmQualityGate for output validation
|
|
9
|
+
* - ResultSynthesizer for merging outputs
|
|
10
|
+
*
|
|
11
|
+
* V2 additions:
|
|
12
|
+
* - Planning phase with acceptance criteria
|
|
13
|
+
* - Post-wave review with fix-up task generation
|
|
14
|
+
* - Integration verification with bash commands
|
|
15
|
+
* - Model health tracking and failover
|
|
16
|
+
* - State persistence and resume
|
|
17
|
+
* - Orchestrator decision logging
|
|
18
|
+
*/
|
|
19
|
+
import { createSmartDecomposer, parseDecompositionResponse } from '../smart-decomposer.js';
|
|
20
|
+
import { createResultSynthesizer } from '../result-synthesizer.js';
|
|
21
|
+
import { taskResultToAgentOutput, DEFAULT_SWARM_CONFIG, SUBTASK_TO_CAPABILITY } from './types.js';
|
|
22
|
+
import { createSwarmTaskQueue } from './task-queue.js';
|
|
23
|
+
import { createSwarmBudgetPool } from './swarm-budget.js';
|
|
24
|
+
import { createSwarmWorkerPool } from './worker-pool.js';
|
|
25
|
+
import { evaluateWorkerOutput } from './swarm-quality-gate.js';
|
|
26
|
+
import { ModelHealthTracker, selectAlternativeModel } from './model-selector.js';
|
|
27
|
+
import { SwarmStateStore } from './swarm-state-store.js';
|
|
28
|
+
// ─── Orchestrator ──────────────────────────────────────────────────────────
|
|
29
|
+
export class SwarmOrchestrator {
|
|
30
|
+
config;
|
|
31
|
+
provider;
|
|
32
|
+
blackboard;
|
|
33
|
+
taskQueue;
|
|
34
|
+
budgetPool;
|
|
35
|
+
workerPool;
|
|
36
|
+
_decomposer;
|
|
37
|
+
synthesizer;
|
|
38
|
+
listeners = [];
|
|
39
|
+
errors = [];
|
|
40
|
+
cancelled = false;
|
|
41
|
+
// M5: Explicit phase tracking for TUI status
|
|
42
|
+
currentPhase = 'decomposing';
|
|
43
|
+
// Stats tracking
|
|
44
|
+
totalTokens = 0;
|
|
45
|
+
totalCost = 0;
|
|
46
|
+
qualityRejections = 0;
|
|
47
|
+
retries = 0;
|
|
48
|
+
startTime = 0;
|
|
49
|
+
modelUsage = new Map();
|
|
50
|
+
// V2: Planning, review, verification, health, persistence
|
|
51
|
+
plan;
|
|
52
|
+
waveReviews = [];
|
|
53
|
+
verificationResult;
|
|
54
|
+
orchestratorDecisions = [];
|
|
55
|
+
healthTracker;
|
|
56
|
+
stateStore;
|
|
57
|
+
spawnAgentFn;
|
|
58
|
+
// Circuit breaker: pause all dispatch after too many 429s
|
|
59
|
+
recentRateLimits = [];
|
|
60
|
+
circuitBreakerUntil = 0;
|
|
61
|
+
static CIRCUIT_BREAKER_WINDOW_MS = 30_000;
|
|
62
|
+
static CIRCUIT_BREAKER_THRESHOLD = 3;
|
|
63
|
+
static CIRCUIT_BREAKER_PAUSE_MS = 15_000;
|
|
64
|
+
constructor(config, provider, agentRegistry, spawnAgentFn, blackboard) {
|
|
65
|
+
this.config = { ...DEFAULT_SWARM_CONFIG, ...config };
|
|
66
|
+
this.provider = provider;
|
|
67
|
+
this.blackboard = blackboard;
|
|
68
|
+
this.spawnAgentFn = spawnAgentFn;
|
|
69
|
+
this.healthTracker = new ModelHealthTracker();
|
|
70
|
+
this.taskQueue = createSwarmTaskQueue();
|
|
71
|
+
this.budgetPool = createSwarmBudgetPool(this.config);
|
|
72
|
+
this.workerPool = createSwarmWorkerPool(this.config, agentRegistry, spawnAgentFn, this.budgetPool);
|
|
73
|
+
// Initialize state store if persistence enabled
|
|
74
|
+
if (this.config.enablePersistence) {
|
|
75
|
+
this.stateStore = new SwarmStateStore(this.config.stateDir ?? '.agent/swarm-state', this.config.resumeSessionId);
|
|
76
|
+
}
|
|
77
|
+
// C1: Build LLM decompose function with explicit JSON schema
|
|
78
|
+
const llmDecompose = async (task, _context) => {
|
|
79
|
+
const systemPrompt = `You are a task decomposition expert. Break down the given task into well-defined subtasks with clear dependencies.
|
|
80
|
+
|
|
81
|
+
Respond with valid JSON matching this exact schema:
|
|
82
|
+
{
|
|
83
|
+
"subtasks": [
|
|
84
|
+
{
|
|
85
|
+
"description": "Clear description of what this subtask does",
|
|
86
|
+
"type": "implement" | "research" | "analysis" | "design" | "test" | "refactor" | "review" | "document" | "integrate" | "deploy" | "merge",
|
|
87
|
+
"complexity": 1-10,
|
|
88
|
+
"dependencies": ["description of dependency task or index like '0'"],
|
|
89
|
+
"parallelizable": true | false,
|
|
90
|
+
"relevantFiles": ["src/path/to/file.ts"]
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"strategy": "sequential" | "parallel" | "hierarchical" | "adaptive" | "pipeline",
|
|
94
|
+
"reasoning": "Brief explanation of why this decomposition was chosen"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
Rules:
|
|
98
|
+
- Each subtask must have a clear, actionable description
|
|
99
|
+
- Dependencies reference other subtask descriptions or zero-based indices
|
|
100
|
+
- Mark subtasks as parallelizable: true if they don't depend on each other
|
|
101
|
+
- Complexity 1-3: simple, 4-6: moderate, 7-10: complex
|
|
102
|
+
- Return at least 2 subtasks for non-trivial tasks`;
|
|
103
|
+
const response = await this.provider.chat([
|
|
104
|
+
{ role: 'system', content: systemPrompt },
|
|
105
|
+
{ role: 'user', content: task },
|
|
106
|
+
], {
|
|
107
|
+
model: this.config.orchestratorModel,
|
|
108
|
+
maxTokens: 4000,
|
|
109
|
+
temperature: 0.3,
|
|
110
|
+
});
|
|
111
|
+
// Use parseDecompositionResponse which handles markdown code blocks and edge cases
|
|
112
|
+
return parseDecompositionResponse(response.content);
|
|
113
|
+
};
|
|
114
|
+
// Configure decomposer for swarm use
|
|
115
|
+
const decomposer = createSmartDecomposer({
|
|
116
|
+
useLLM: true,
|
|
117
|
+
maxSubtasks: 30,
|
|
118
|
+
detectConflicts: true,
|
|
119
|
+
llmProvider: llmDecompose,
|
|
120
|
+
});
|
|
121
|
+
this._decomposer = decomposer;
|
|
122
|
+
this.synthesizer = createResultSynthesizer();
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Get the swarm budget pool (used by parent agent to override its own pool).
|
|
126
|
+
*/
|
|
127
|
+
getBudgetPool() {
|
|
128
|
+
return this.budgetPool;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Subscribe to swarm events.
|
|
132
|
+
*/
|
|
133
|
+
subscribe(listener) {
|
|
134
|
+
this.listeners.push(listener);
|
|
135
|
+
return () => {
|
|
136
|
+
const idx = this.listeners.indexOf(listener);
|
|
137
|
+
if (idx >= 0)
|
|
138
|
+
this.listeners.splice(idx, 1);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Emit a swarm event to all listeners.
|
|
143
|
+
*/
|
|
144
|
+
emit(event) {
|
|
145
|
+
for (const listener of this.listeners) {
|
|
146
|
+
try {
|
|
147
|
+
listener(event);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Don't let listener errors break the orchestrator
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Execute the full swarm pipeline for a task.
|
|
156
|
+
*
|
|
157
|
+
* V2 pipeline:
|
|
158
|
+
* 1. Check for resume
|
|
159
|
+
* 2. Decompose
|
|
160
|
+
* 3. Plan (acceptance criteria + verification plan)
|
|
161
|
+
* 4. Schedule into waves
|
|
162
|
+
* 5. Execute waves with review
|
|
163
|
+
* 6. Verify integration
|
|
164
|
+
* 7. Fix-up loop if verification fails
|
|
165
|
+
* 8. Synthesize
|
|
166
|
+
* 9. Checkpoint (final)
|
|
167
|
+
*/
|
|
168
|
+
async execute(task) {
|
|
169
|
+
this.startTime = Date.now();
|
|
170
|
+
try {
|
|
171
|
+
// V2: Check for resume
|
|
172
|
+
if (this.config.resumeSessionId && this.stateStore) {
|
|
173
|
+
return await this.resumeExecution(task);
|
|
174
|
+
}
|
|
175
|
+
// Phase 1: Decompose
|
|
176
|
+
this.currentPhase = 'decomposing';
|
|
177
|
+
const decomposition = await this.decompose(task);
|
|
178
|
+
if (!decomposition) {
|
|
179
|
+
this.currentPhase = 'failed';
|
|
180
|
+
return this.buildErrorResult('Decomposition failed — task may be too simple for swarm mode');
|
|
181
|
+
}
|
|
182
|
+
// Phase 2: Schedule into waves
|
|
183
|
+
this.currentPhase = 'scheduling';
|
|
184
|
+
this.taskQueue.loadFromDecomposition(decomposition, this.config);
|
|
185
|
+
const stats = this.taskQueue.getStats();
|
|
186
|
+
// V2: Phase 2.5: Plan execution (acceptance criteria)
|
|
187
|
+
if (this.config.enablePlanning) {
|
|
188
|
+
this.currentPhase = 'planning';
|
|
189
|
+
await this.planExecution(task, decomposition);
|
|
190
|
+
}
|
|
191
|
+
this.emit({
|
|
192
|
+
type: 'swarm.start',
|
|
193
|
+
taskCount: stats.total,
|
|
194
|
+
waveCount: this.taskQueue.getTotalWaves(),
|
|
195
|
+
config: {
|
|
196
|
+
maxConcurrency: this.config.maxConcurrency,
|
|
197
|
+
totalBudget: this.config.totalBudget,
|
|
198
|
+
maxCost: this.config.maxCost,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
// Emit tasks AFTER swarm.start so the bridge has already initialized
|
|
202
|
+
// (swarm.start clears tasks/edges, so loading before it would lose them)
|
|
203
|
+
this.emit({
|
|
204
|
+
type: 'swarm.tasks.loaded',
|
|
205
|
+
tasks: this.taskQueue.getAllTasks(),
|
|
206
|
+
});
|
|
207
|
+
// Phase 3: Execute waves (V2: with review after each wave)
|
|
208
|
+
this.currentPhase = 'executing';
|
|
209
|
+
await this.executeWaves();
|
|
210
|
+
// V2: Phase 3.5: Verify integration
|
|
211
|
+
if (this.config.enableVerification && this.plan?.integrationTestPlan) {
|
|
212
|
+
this.currentPhase = 'verifying';
|
|
213
|
+
const verification = await this.verifyIntegration(this.plan.integrationTestPlan);
|
|
214
|
+
if (!verification.passed) {
|
|
215
|
+
await this.handleVerificationFailure(verification, task);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Phase 4: Synthesize results
|
|
219
|
+
this.currentPhase = 'synthesizing';
|
|
220
|
+
const synthesisResult = await this.synthesize();
|
|
221
|
+
this.currentPhase = 'completed';
|
|
222
|
+
const executionStats = this.buildStats();
|
|
223
|
+
// V2: Final checkpoint
|
|
224
|
+
this.checkpoint('final');
|
|
225
|
+
this.emit({ type: 'swarm.complete', stats: executionStats, errors: this.errors });
|
|
226
|
+
return {
|
|
227
|
+
success: executionStats.completedTasks > 0,
|
|
228
|
+
synthesisResult: synthesisResult ?? undefined,
|
|
229
|
+
summary: this.buildSummary(executionStats),
|
|
230
|
+
tasks: this.taskQueue.getAllTasks(),
|
|
231
|
+
stats: executionStats,
|
|
232
|
+
errors: this.errors,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
this.currentPhase = 'failed';
|
|
237
|
+
const message = error.message;
|
|
238
|
+
this.errors.push({
|
|
239
|
+
phase: 'execution',
|
|
240
|
+
message,
|
|
241
|
+
recovered: false,
|
|
242
|
+
});
|
|
243
|
+
this.emit({ type: 'swarm.error', error: message, phase: 'execution' });
|
|
244
|
+
return this.buildErrorResult(message);
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
this.workerPool.cleanup();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Phase 1: Decompose the task into subtasks.
|
|
252
|
+
*/
|
|
253
|
+
async decompose(task) {
|
|
254
|
+
try {
|
|
255
|
+
const result = await this._decomposer.decompose(task);
|
|
256
|
+
if (result.subtasks.length < 2) {
|
|
257
|
+
// Too simple for swarm mode
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
this.errors.push({
|
|
264
|
+
phase: 'decomposition',
|
|
265
|
+
message: error.message,
|
|
266
|
+
recovered: false,
|
|
267
|
+
});
|
|
268
|
+
this.emit({ type: 'swarm.error', error: error.message, phase: 'decomposition' });
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// ─── V2: Planning Phase ───────────────────────────────────────────────
|
|
273
|
+
/**
|
|
274
|
+
* Create acceptance criteria and integration test plan.
|
|
275
|
+
* Graceful: if planning fails, continues without criteria.
|
|
276
|
+
*/
|
|
277
|
+
async planExecution(task, decomposition) {
|
|
278
|
+
try {
|
|
279
|
+
// V3: Manager role handles planning
|
|
280
|
+
const plannerModel = this.config.hierarchy?.manager?.model
|
|
281
|
+
?? this.config.plannerModel ?? this.config.orchestratorModel;
|
|
282
|
+
this.emit({ type: 'swarm.role.action', role: 'manager', action: 'plan', model: plannerModel });
|
|
283
|
+
this.logDecision('planning', `Creating acceptance criteria (manager: ${plannerModel})`, `Task has ${decomposition.subtasks.length} subtasks, planning to ensure quality`);
|
|
284
|
+
const taskList = decomposition.subtasks
|
|
285
|
+
.map(s => `- [${s.id}] (${s.type}): ${s.description}`)
|
|
286
|
+
.join('\n');
|
|
287
|
+
const response = await this.provider.chat([
|
|
288
|
+
{
|
|
289
|
+
role: 'system',
|
|
290
|
+
content: `You are a project quality planner. Given a task and its decomposition into subtasks, create:
|
|
291
|
+
1. Acceptance criteria for each subtask (what "done" looks like)
|
|
292
|
+
2. An integration test plan (bash commands to verify the combined result works)
|
|
293
|
+
|
|
294
|
+
Respond with valid JSON:
|
|
295
|
+
{
|
|
296
|
+
"acceptanceCriteria": [
|
|
297
|
+
{ "taskId": "st-0", "criteria": ["criterion 1", "criterion 2"] }
|
|
298
|
+
],
|
|
299
|
+
"integrationTestPlan": {
|
|
300
|
+
"description": "What this test plan verifies",
|
|
301
|
+
"steps": [
|
|
302
|
+
{ "description": "Check if files exist", "command": "ls src/parser.js", "expectedResult": "file listed", "required": true }
|
|
303
|
+
],
|
|
304
|
+
"successCriteria": "All required steps pass"
|
|
305
|
+
},
|
|
306
|
+
"reasoning": "Why this plan was chosen"
|
|
307
|
+
}`,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
role: 'user',
|
|
311
|
+
content: `Task: ${task}\n\nSubtasks:\n${taskList}`,
|
|
312
|
+
},
|
|
313
|
+
], {
|
|
314
|
+
model: plannerModel,
|
|
315
|
+
maxTokens: 3000,
|
|
316
|
+
temperature: 0.3,
|
|
317
|
+
});
|
|
318
|
+
const parsed = this.parseJSON(response.content);
|
|
319
|
+
if (parsed) {
|
|
320
|
+
this.plan = {
|
|
321
|
+
acceptanceCriteria: parsed.acceptanceCriteria ?? [],
|
|
322
|
+
integrationTestPlan: parsed.integrationTestPlan,
|
|
323
|
+
reasoning: parsed.reasoning ?? '',
|
|
324
|
+
};
|
|
325
|
+
this.emit({
|
|
326
|
+
type: 'swarm.plan.complete',
|
|
327
|
+
criteriaCount: this.plan.acceptanceCriteria.length,
|
|
328
|
+
hasIntegrationPlan: !!this.plan.integrationTestPlan,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
// Graceful fallback: continue without plan
|
|
334
|
+
this.errors.push({
|
|
335
|
+
phase: 'planning',
|
|
336
|
+
message: `Planning failed (non-fatal): ${error.message}`,
|
|
337
|
+
recovered: true,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// ─── V2: Wave Review ──────────────────────────────────────────────────
|
|
342
|
+
/**
|
|
343
|
+
* Review completed wave outputs against acceptance criteria.
|
|
344
|
+
* May spawn fix-up tasks for issues found.
|
|
345
|
+
*/
|
|
346
|
+
async reviewWave(waveIndex) {
|
|
347
|
+
if (!this.config.enableWaveReview)
|
|
348
|
+
return null;
|
|
349
|
+
try {
|
|
350
|
+
// V3: Manager role handles wave review
|
|
351
|
+
const managerModel = this.config.hierarchy?.manager?.model
|
|
352
|
+
?? this.config.plannerModel ?? this.config.orchestratorModel;
|
|
353
|
+
const managerPersona = this.config.hierarchy?.manager?.persona;
|
|
354
|
+
this.emit({ type: 'swarm.role.action', role: 'manager', action: 'review', model: managerModel, wave: waveIndex + 1 });
|
|
355
|
+
this.emit({ type: 'swarm.review.start', wave: waveIndex + 1 });
|
|
356
|
+
this.logDecision('review', `Reviewing wave ${waveIndex + 1} outputs (manager: ${managerModel})`, 'Checking task outputs against acceptance criteria');
|
|
357
|
+
const completedTasks = this.taskQueue.getAllTasks()
|
|
358
|
+
.filter(t => t.status === 'completed' && t.wave === waveIndex);
|
|
359
|
+
if (completedTasks.length === 0) {
|
|
360
|
+
return { wave: waveIndex, assessment: 'good', taskAssessments: [], fixupTasks: [] };
|
|
361
|
+
}
|
|
362
|
+
// Build review prompt
|
|
363
|
+
const taskSummaries = completedTasks.map(t => {
|
|
364
|
+
const criteria = this.plan?.acceptanceCriteria.find(c => c.taskId === t.id);
|
|
365
|
+
return `Task ${t.id}: ${t.description}
|
|
366
|
+
Output: ${t.result?.output?.slice(0, 500) ?? 'No output'}
|
|
367
|
+
Acceptance criteria: ${criteria?.criteria.join('; ') ?? 'None set'}`;
|
|
368
|
+
}).join('\n\n');
|
|
369
|
+
const reviewModel = managerModel;
|
|
370
|
+
const reviewSystemPrompt = managerPersona
|
|
371
|
+
? `${managerPersona}\n\nYou are reviewing completed worker outputs. Assess each task against its acceptance criteria.\nRespond with JSON:`
|
|
372
|
+
: `You are reviewing completed worker outputs. Assess each task against its acceptance criteria.\nRespond with JSON:`;
|
|
373
|
+
const response = await this.provider.chat([
|
|
374
|
+
{
|
|
375
|
+
role: 'system',
|
|
376
|
+
content: `${reviewSystemPrompt}
|
|
377
|
+
{
|
|
378
|
+
"assessment": "good" | "needs-fixes" | "critical-issues",
|
|
379
|
+
"taskAssessments": [
|
|
380
|
+
{ "taskId": "st-0", "passed": true, "feedback": "optional feedback" }
|
|
381
|
+
],
|
|
382
|
+
"fixupInstructions": [
|
|
383
|
+
{ "fixesTaskId": "st-0", "description": "What to fix", "instructions": "Specific fix instructions" }
|
|
384
|
+
]
|
|
385
|
+
}`,
|
|
386
|
+
},
|
|
387
|
+
{ role: 'user', content: `Review these wave ${waveIndex + 1} outputs:\n\n${taskSummaries}` },
|
|
388
|
+
], { model: reviewModel, maxTokens: 2000, temperature: 0.3 });
|
|
389
|
+
const parsed = this.parseJSON(response.content);
|
|
390
|
+
if (!parsed)
|
|
391
|
+
return null;
|
|
392
|
+
// Create fix-up tasks
|
|
393
|
+
const fixupTasks = [];
|
|
394
|
+
if (parsed.fixupInstructions) {
|
|
395
|
+
for (const fix of parsed.fixupInstructions) {
|
|
396
|
+
const fixupId = `fixup-${fix.fixesTaskId}-${Date.now()}`;
|
|
397
|
+
const originalTask = this.taskQueue.getTask(fix.fixesTaskId);
|
|
398
|
+
const fixupTask = {
|
|
399
|
+
id: fixupId,
|
|
400
|
+
description: fix.description,
|
|
401
|
+
type: originalTask?.type ?? 'implement',
|
|
402
|
+
dependencies: [fix.fixesTaskId],
|
|
403
|
+
status: 'ready',
|
|
404
|
+
complexity: 3,
|
|
405
|
+
wave: waveIndex,
|
|
406
|
+
attempts: 0,
|
|
407
|
+
fixesTaskId: fix.fixesTaskId,
|
|
408
|
+
fixInstructions: fix.instructions,
|
|
409
|
+
};
|
|
410
|
+
fixupTasks.push(fixupTask);
|
|
411
|
+
this.emit({ type: 'swarm.fixup.spawned', taskId: fixupId, fixesTaskId: fix.fixesTaskId, description: fix.description });
|
|
412
|
+
}
|
|
413
|
+
if (fixupTasks.length > 0) {
|
|
414
|
+
this.taskQueue.addFixupTasks(fixupTasks);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const result = {
|
|
418
|
+
wave: waveIndex,
|
|
419
|
+
assessment: parsed.assessment ?? 'good',
|
|
420
|
+
taskAssessments: parsed.taskAssessments ?? [],
|
|
421
|
+
fixupTasks,
|
|
422
|
+
};
|
|
423
|
+
this.waveReviews.push(result);
|
|
424
|
+
this.emit({
|
|
425
|
+
type: 'swarm.review.complete',
|
|
426
|
+
wave: waveIndex + 1,
|
|
427
|
+
assessment: result.assessment,
|
|
428
|
+
fixupCount: fixupTasks.length,
|
|
429
|
+
});
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
// Graceful: continue without review
|
|
434
|
+
this.errors.push({
|
|
435
|
+
phase: 'review',
|
|
436
|
+
message: `Wave review failed (non-fatal): ${error.message}`,
|
|
437
|
+
recovered: true,
|
|
438
|
+
});
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// ─── V2: Verification Phase ───────────────────────────────────────────
|
|
443
|
+
/**
|
|
444
|
+
* Run integration verification steps.
|
|
445
|
+
*/
|
|
446
|
+
async verifyIntegration(testPlan) {
|
|
447
|
+
// V3: Judge role handles verification
|
|
448
|
+
const verifyModel = this.config.hierarchy?.judge?.model
|
|
449
|
+
?? this.config.qualityGateModel ?? this.config.orchestratorModel;
|
|
450
|
+
this.emit({ type: 'swarm.role.action', role: 'judge', action: 'verify', model: verifyModel });
|
|
451
|
+
this.emit({ type: 'swarm.verify.start', stepCount: testPlan.steps.length });
|
|
452
|
+
this.logDecision('verification', `Running ${testPlan.steps.length} verification steps (judge: ${verifyModel})`, testPlan.description);
|
|
453
|
+
const stepResults = [];
|
|
454
|
+
let allRequiredPassed = true;
|
|
455
|
+
for (let i = 0; i < testPlan.steps.length; i++) {
|
|
456
|
+
const step = testPlan.steps[i];
|
|
457
|
+
try {
|
|
458
|
+
// Use spawnAgent to execute verification command safely
|
|
459
|
+
const verifierName = `swarm-verifier-${i}`;
|
|
460
|
+
const result = await this.spawnAgentFn(verifierName, `Run this command and report the result: ${step.command}\nExpected: ${step.expectedResult ?? 'success'}`);
|
|
461
|
+
const passed = result.success;
|
|
462
|
+
stepResults.push({ step, passed, output: result.output.slice(0, 500) });
|
|
463
|
+
if (!passed && step.required) {
|
|
464
|
+
allRequiredPassed = false;
|
|
465
|
+
}
|
|
466
|
+
this.emit({ type: 'swarm.verify.step', stepIndex: i, description: step.description, passed });
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
const output = `Error: ${error.message}`;
|
|
470
|
+
stepResults.push({ step, passed: false, output });
|
|
471
|
+
if (step.required)
|
|
472
|
+
allRequiredPassed = false;
|
|
473
|
+
this.emit({ type: 'swarm.verify.step', stepIndex: i, description: step.description, passed: false });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const verificationResult = {
|
|
477
|
+
passed: allRequiredPassed,
|
|
478
|
+
stepResults,
|
|
479
|
+
summary: allRequiredPassed
|
|
480
|
+
? `All ${stepResults.filter(r => r.passed).length}/${stepResults.length} steps passed`
|
|
481
|
+
: `${stepResults.filter(r => !r.passed).length}/${stepResults.length} steps failed`,
|
|
482
|
+
};
|
|
483
|
+
this.verificationResult = verificationResult;
|
|
484
|
+
this.emit({ type: 'swarm.verify.complete', result: verificationResult });
|
|
485
|
+
return verificationResult;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Handle verification failure: create fix-up tasks and re-verify.
|
|
489
|
+
*/
|
|
490
|
+
async handleVerificationFailure(verification, task) {
|
|
491
|
+
const maxRetries = this.config.maxVerificationRetries ?? 2;
|
|
492
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
493
|
+
this.logDecision('verification', `Verification failed, fix-up attempt ${attempt + 1}/${maxRetries}`, `${verification.stepResults.filter(r => !r.passed).length} steps failed`);
|
|
494
|
+
// Ask orchestrator what to fix
|
|
495
|
+
try {
|
|
496
|
+
const failedSteps = verification.stepResults
|
|
497
|
+
.filter(r => !r.passed)
|
|
498
|
+
.map(r => `- ${r.step.description}: ${r.output}`)
|
|
499
|
+
.join('\n');
|
|
500
|
+
const response = await this.provider.chat([
|
|
501
|
+
{
|
|
502
|
+
role: 'system',
|
|
503
|
+
content: `Verification failed. Analyze the failures and create fix-up tasks.
|
|
504
|
+
Respond with JSON: { "fixups": [{ "description": "what to fix", "type": "implement" }] }`,
|
|
505
|
+
},
|
|
506
|
+
{ role: 'user', content: `Original task: ${task}\n\nFailed verifications:\n${failedSteps}` },
|
|
507
|
+
], { model: this.config.plannerModel ?? this.config.orchestratorModel, maxTokens: 1500, temperature: 0.3 });
|
|
508
|
+
const parsed = this.parseJSON(response.content);
|
|
509
|
+
if (parsed?.fixups && parsed.fixups.length > 0) {
|
|
510
|
+
const fixupTasks = parsed.fixups.map((f, i) => ({
|
|
511
|
+
id: `verify-fix-${attempt}-${i}-${Date.now()}`,
|
|
512
|
+
description: f.description,
|
|
513
|
+
type: (f.type ?? 'implement'),
|
|
514
|
+
dependencies: [],
|
|
515
|
+
status: 'ready',
|
|
516
|
+
complexity: 4,
|
|
517
|
+
wave: this.taskQueue.getCurrentWave(),
|
|
518
|
+
attempts: 0,
|
|
519
|
+
fixesTaskId: 'verification',
|
|
520
|
+
fixInstructions: f.description,
|
|
521
|
+
}));
|
|
522
|
+
this.taskQueue.addFixupTasks(fixupTasks);
|
|
523
|
+
// Execute fix-up wave
|
|
524
|
+
this.currentPhase = 'executing';
|
|
525
|
+
await this.executeWave(fixupTasks);
|
|
526
|
+
// Re-verify
|
|
527
|
+
this.currentPhase = 'verifying';
|
|
528
|
+
verification = await this.verifyIntegration(this.plan.integrationTestPlan);
|
|
529
|
+
if (verification.passed)
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
// Continue to next attempt
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// ─── V2: Resume ───────────────────────────────────────────────────────
|
|
539
|
+
/**
|
|
540
|
+
* Resume execution from a saved checkpoint.
|
|
541
|
+
*/
|
|
542
|
+
async resumeExecution(task) {
|
|
543
|
+
const checkpoint = SwarmStateStore.loadLatest(this.config.stateDir ?? '.agent/swarm-state', this.config.resumeSessionId);
|
|
544
|
+
if (!checkpoint) {
|
|
545
|
+
this.logDecision('resume', 'No checkpoint found, starting fresh', `Session: ${this.config.resumeSessionId}`);
|
|
546
|
+
// Clear resume flag and execute normally
|
|
547
|
+
this.config.resumeSessionId = undefined;
|
|
548
|
+
return this.execute(task);
|
|
549
|
+
}
|
|
550
|
+
this.logDecision('resume', `Resuming from wave ${checkpoint.currentWave}`, `Session: ${checkpoint.sessionId}`);
|
|
551
|
+
this.emit({ type: 'swarm.state.resume', sessionId: checkpoint.sessionId, fromWave: checkpoint.currentWave });
|
|
552
|
+
// Restore state
|
|
553
|
+
if (checkpoint.plan)
|
|
554
|
+
this.plan = checkpoint.plan;
|
|
555
|
+
if (checkpoint.modelHealth.length > 0)
|
|
556
|
+
this.healthTracker.restore(checkpoint.modelHealth);
|
|
557
|
+
this.orchestratorDecisions = checkpoint.decisions ?? [];
|
|
558
|
+
this.errors = checkpoint.errors ?? [];
|
|
559
|
+
this.totalTokens = checkpoint.stats.totalTokens;
|
|
560
|
+
this.totalCost = checkpoint.stats.totalCost;
|
|
561
|
+
this.qualityRejections = checkpoint.stats.qualityRejections;
|
|
562
|
+
this.retries = checkpoint.stats.retries;
|
|
563
|
+
// Restore task queue
|
|
564
|
+
this.taskQueue.restoreFromCheckpoint({
|
|
565
|
+
taskStates: checkpoint.taskStates,
|
|
566
|
+
waves: checkpoint.waves,
|
|
567
|
+
currentWave: checkpoint.currentWave,
|
|
568
|
+
});
|
|
569
|
+
// Continue from where we left off
|
|
570
|
+
this.currentPhase = 'executing';
|
|
571
|
+
await this.executeWaves();
|
|
572
|
+
// Continue with verification and synthesis as normal
|
|
573
|
+
if (this.config.enableVerification && this.plan?.integrationTestPlan) {
|
|
574
|
+
this.currentPhase = 'verifying';
|
|
575
|
+
const verification = await this.verifyIntegration(this.plan.integrationTestPlan);
|
|
576
|
+
if (!verification.passed) {
|
|
577
|
+
await this.handleVerificationFailure(verification, task);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
this.currentPhase = 'synthesizing';
|
|
581
|
+
const synthesisResult = await this.synthesize();
|
|
582
|
+
this.currentPhase = 'completed';
|
|
583
|
+
const executionStats = this.buildStats();
|
|
584
|
+
this.checkpoint('final');
|
|
585
|
+
this.emit({ type: 'swarm.complete', stats: executionStats, errors: this.errors });
|
|
586
|
+
return {
|
|
587
|
+
success: executionStats.completedTasks > 0,
|
|
588
|
+
synthesisResult: synthesisResult ?? undefined,
|
|
589
|
+
summary: this.buildSummary(executionStats),
|
|
590
|
+
tasks: this.taskQueue.getAllTasks(),
|
|
591
|
+
stats: executionStats,
|
|
592
|
+
errors: this.errors,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
// ─── Wave Execution ───────────────────────────────────────────────────
|
|
596
|
+
/**
|
|
597
|
+
* Execute all waves in sequence, with review after each.
|
|
598
|
+
*/
|
|
599
|
+
async executeWaves() {
|
|
600
|
+
let waveIndex = this.taskQueue.getCurrentWave();
|
|
601
|
+
const totalWaves = this.taskQueue.getTotalWaves();
|
|
602
|
+
while (waveIndex < totalWaves && !this.cancelled) {
|
|
603
|
+
const readyTasks = this.taskQueue.getReadyTasks();
|
|
604
|
+
const queueStats = this.taskQueue.getStats();
|
|
605
|
+
this.emit({
|
|
606
|
+
type: 'swarm.wave.start',
|
|
607
|
+
wave: waveIndex + 1,
|
|
608
|
+
totalWaves,
|
|
609
|
+
taskCount: readyTasks.length,
|
|
610
|
+
});
|
|
611
|
+
// Dispatch tasks up to concurrency limit
|
|
612
|
+
await this.executeWave(readyTasks);
|
|
613
|
+
// Wave complete stats
|
|
614
|
+
const afterStats = this.taskQueue.getStats();
|
|
615
|
+
const waveCompleted = afterStats.completed - (queueStats.completed);
|
|
616
|
+
const waveFailed = afterStats.failed - (queueStats.failed);
|
|
617
|
+
const waveSkipped = afterStats.skipped - (queueStats.skipped);
|
|
618
|
+
this.emit({
|
|
619
|
+
type: 'swarm.wave.complete',
|
|
620
|
+
wave: waveIndex + 1,
|
|
621
|
+
totalWaves,
|
|
622
|
+
completed: waveCompleted,
|
|
623
|
+
failed: waveFailed,
|
|
624
|
+
skipped: waveSkipped,
|
|
625
|
+
});
|
|
626
|
+
// V2: Review wave outputs
|
|
627
|
+
const review = await this.reviewWave(waveIndex);
|
|
628
|
+
if (review && review.fixupTasks.length > 0) {
|
|
629
|
+
// Execute fix-up tasks immediately
|
|
630
|
+
await this.executeWave(review.fixupTasks);
|
|
631
|
+
}
|
|
632
|
+
// V2: Checkpoint after each wave
|
|
633
|
+
this.checkpoint(`wave-${waveIndex}`);
|
|
634
|
+
// Advance to next wave
|
|
635
|
+
if (!this.taskQueue.advanceWave())
|
|
636
|
+
break;
|
|
637
|
+
waveIndex++;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Execute a single wave's tasks with concurrency control.
|
|
642
|
+
*/
|
|
643
|
+
async executeWave(tasks) {
|
|
644
|
+
// Dispatch initial batch with stagger to avoid rate limit storms
|
|
645
|
+
let taskIndex = 0;
|
|
646
|
+
while (taskIndex < tasks.length && this.workerPool.availableSlots > 0 && !this.cancelled) {
|
|
647
|
+
// Circuit breaker: wait if tripped
|
|
648
|
+
if (this.isCircuitBreakerActive()) {
|
|
649
|
+
const waitMs = this.circuitBreakerUntil - Date.now();
|
|
650
|
+
if (waitMs > 0)
|
|
651
|
+
await new Promise(resolve => setTimeout(resolve, waitMs));
|
|
652
|
+
continue; // Re-check after wait
|
|
653
|
+
}
|
|
654
|
+
const task = tasks[taskIndex];
|
|
655
|
+
await this.dispatchTask(task);
|
|
656
|
+
taskIndex++;
|
|
657
|
+
// Stagger dispatches to avoid rate limit storms
|
|
658
|
+
if (taskIndex < tasks.length && this.workerPool.availableSlots > 0) {
|
|
659
|
+
await new Promise(resolve => setTimeout(resolve, this.config.dispatchStaggerMs ?? 500));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// Process completions and dispatch more tasks as slots open
|
|
663
|
+
while (this.workerPool.activeCount > 0 && !this.cancelled) {
|
|
664
|
+
const completed = await this.workerPool.waitForAny();
|
|
665
|
+
if (!completed)
|
|
666
|
+
break;
|
|
667
|
+
// H2: Use per-task startedAt for accurate duration (not orchestrator startTime)
|
|
668
|
+
await this.handleTaskCompletion(completed.taskId, completed.result, completed.startedAt);
|
|
669
|
+
// Emit budget update
|
|
670
|
+
this.emitBudgetUpdate();
|
|
671
|
+
// Emit status update
|
|
672
|
+
this.emitStatusUpdate();
|
|
673
|
+
// Dispatch more tasks if slots available and tasks remain
|
|
674
|
+
while (taskIndex < tasks.length && this.workerPool.availableSlots > 0 && !this.cancelled) {
|
|
675
|
+
const task = tasks[taskIndex];
|
|
676
|
+
if (task.status === 'ready') {
|
|
677
|
+
await this.dispatchTask(task);
|
|
678
|
+
// Stagger dispatches to avoid rate limit storms
|
|
679
|
+
if (taskIndex + 1 < tasks.length && this.workerPool.availableSlots > 0) {
|
|
680
|
+
await new Promise(resolve => setTimeout(resolve, this.config.dispatchStaggerMs ?? 500));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
taskIndex++;
|
|
684
|
+
}
|
|
685
|
+
// Also check for cross-wave ready tasks to fill slots (skip if circuit breaker active)
|
|
686
|
+
if (this.workerPool.availableSlots > 0 && !this.isCircuitBreakerActive()) {
|
|
687
|
+
const moreReady = this.taskQueue.getAllReadyTasks()
|
|
688
|
+
.filter(t => !this.workerPool.getActiveWorkerStatus().some(w => w.taskId === t.id));
|
|
689
|
+
for (let i = 0; i < moreReady.length; i++) {
|
|
690
|
+
if (this.workerPool.availableSlots <= 0)
|
|
691
|
+
break;
|
|
692
|
+
await this.dispatchTask(moreReady[i]);
|
|
693
|
+
// Stagger dispatches to avoid rate limit storms
|
|
694
|
+
if (i + 1 < moreReady.length && this.workerPool.availableSlots > 0) {
|
|
695
|
+
await new Promise(resolve => setTimeout(resolve, this.config.dispatchStaggerMs ?? 500));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Dispatch a single task to a worker.
|
|
703
|
+
*/
|
|
704
|
+
async dispatchTask(task) {
|
|
705
|
+
const worker = this.workerPool.selectWorker(task);
|
|
706
|
+
if (!worker) {
|
|
707
|
+
// M2: Emit error and mark task failed instead of silently returning
|
|
708
|
+
this.taskQueue.markFailed(task.id, 0);
|
|
709
|
+
this.emit({
|
|
710
|
+
type: 'swarm.task.failed',
|
|
711
|
+
taskId: task.id,
|
|
712
|
+
error: `No worker available for task type: ${task.type}`,
|
|
713
|
+
attempt: 0,
|
|
714
|
+
maxAttempts: 0,
|
|
715
|
+
willRetry: false,
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
this.taskQueue.markDispatched(task.id, worker.model);
|
|
721
|
+
await this.workerPool.dispatch(task);
|
|
722
|
+
this.emit({
|
|
723
|
+
type: 'swarm.task.dispatched',
|
|
724
|
+
taskId: task.id,
|
|
725
|
+
description: task.description,
|
|
726
|
+
model: worker.model,
|
|
727
|
+
workerName: worker.name,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
this.errors.push({
|
|
732
|
+
taskId: task.id,
|
|
733
|
+
phase: 'dispatch',
|
|
734
|
+
message: error.message,
|
|
735
|
+
recovered: false,
|
|
736
|
+
});
|
|
737
|
+
this.emit({
|
|
738
|
+
type: 'swarm.task.failed',
|
|
739
|
+
taskId: task.id,
|
|
740
|
+
error: error.message,
|
|
741
|
+
attempt: task.attempts,
|
|
742
|
+
maxAttempts: 1 + this.config.workerRetries,
|
|
743
|
+
willRetry: false,
|
|
744
|
+
});
|
|
745
|
+
this.taskQueue.markFailed(task.id, 0);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Handle a completed task: quality gate, bookkeeping, retry logic, model health, failover.
|
|
750
|
+
*/
|
|
751
|
+
async handleTaskCompletion(taskId, spawnResult, startedAt) {
|
|
752
|
+
const task = this.taskQueue.getTask(taskId);
|
|
753
|
+
if (!task)
|
|
754
|
+
return;
|
|
755
|
+
const durationMs = Date.now() - startedAt;
|
|
756
|
+
const taskResult = this.workerPool.toTaskResult(spawnResult, task, durationMs);
|
|
757
|
+
// Track model usage
|
|
758
|
+
const model = task.assignedModel ?? 'unknown';
|
|
759
|
+
const usage = this.modelUsage.get(model) ?? { tasks: 0, tokens: 0, cost: 0 };
|
|
760
|
+
usage.tasks++;
|
|
761
|
+
usage.tokens += taskResult.tokensUsed;
|
|
762
|
+
usage.cost += taskResult.costUsed;
|
|
763
|
+
this.modelUsage.set(model, usage);
|
|
764
|
+
this.totalTokens += taskResult.tokensUsed;
|
|
765
|
+
this.totalCost += taskResult.costUsed;
|
|
766
|
+
if (!spawnResult.success) {
|
|
767
|
+
// V2: Record model health
|
|
768
|
+
const errorMsg = spawnResult.output.toLowerCase();
|
|
769
|
+
const is429 = errorMsg.includes('429') || errorMsg.includes('rate');
|
|
770
|
+
const is402 = errorMsg.includes('402') || errorMsg.includes('spend limit');
|
|
771
|
+
const errorType = is429 ? '429' : is402 ? '402' : 'error';
|
|
772
|
+
this.healthTracker.recordFailure(model, errorType);
|
|
773
|
+
this.emit({ type: 'swarm.model.health', record: { model, ...this.getModelHealthSummary(model) } });
|
|
774
|
+
// Feed circuit breaker
|
|
775
|
+
if (is429 || is402) {
|
|
776
|
+
this.recordRateLimit();
|
|
777
|
+
}
|
|
778
|
+
// V2: Model failover on rate limits
|
|
779
|
+
if ((is429 || is402) && this.config.enableModelFailover) {
|
|
780
|
+
const capability = SUBTASK_TO_CAPABILITY[task.type] ?? 'code';
|
|
781
|
+
const alternative = selectAlternativeModel(this.config.workers, model, capability, this.healthTracker);
|
|
782
|
+
if (alternative) {
|
|
783
|
+
this.emit({
|
|
784
|
+
type: 'swarm.model.failover',
|
|
785
|
+
taskId,
|
|
786
|
+
fromModel: model,
|
|
787
|
+
toModel: alternative.model,
|
|
788
|
+
reason: errorType,
|
|
789
|
+
});
|
|
790
|
+
task.assignedModel = alternative.model;
|
|
791
|
+
this.logDecision('failover', `Switched ${taskId} from ${model} to ${alternative.model}`, `${errorType} error`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
// Worker failed — use higher retry limit for rate limit errors
|
|
795
|
+
const retryLimit = (is429 || is402)
|
|
796
|
+
? (this.config.rateLimitRetries ?? 3)
|
|
797
|
+
: this.config.workerRetries;
|
|
798
|
+
const canRetry = this.taskQueue.markFailed(taskId, retryLimit);
|
|
799
|
+
if (canRetry) {
|
|
800
|
+
this.retries++;
|
|
801
|
+
// Non-blocking cooldown: set retryAfter timestamp instead of blocking
|
|
802
|
+
if (is429 || is402) {
|
|
803
|
+
const baseDelay = this.config.retryBaseDelayMs ?? 5000;
|
|
804
|
+
const cooldownMs = Math.min(baseDelay * Math.pow(2, task.attempts - 1), 30000);
|
|
805
|
+
this.taskQueue.setRetryAfter(taskId, cooldownMs);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
this.emit({
|
|
809
|
+
type: 'swarm.task.failed',
|
|
810
|
+
taskId,
|
|
811
|
+
error: spawnResult.output.slice(0, 200),
|
|
812
|
+
attempt: task.attempts,
|
|
813
|
+
maxAttempts: 1 + this.config.workerRetries,
|
|
814
|
+
willRetry: canRetry,
|
|
815
|
+
});
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
// V2: Record model health on success
|
|
819
|
+
this.healthTracker.recordSuccess(model, durationMs);
|
|
820
|
+
// Run quality gate if enabled — skip under API pressure or on retried tasks
|
|
821
|
+
const recentRLCount = this.recentRateLimits.filter(t => t > Date.now() - 30_000).length;
|
|
822
|
+
const shouldRunQualityGate = this.config.qualityGates
|
|
823
|
+
&& task.attempts <= 1
|
|
824
|
+
&& Date.now() >= this.circuitBreakerUntil
|
|
825
|
+
&& recentRLCount < 2;
|
|
826
|
+
if (shouldRunQualityGate) {
|
|
827
|
+
// V3: Judge role handles quality gates
|
|
828
|
+
const judgeModel = this.config.hierarchy?.judge?.model
|
|
829
|
+
?? this.config.qualityGateModel ?? this.config.orchestratorModel;
|
|
830
|
+
const judgeConfig = {
|
|
831
|
+
model: judgeModel,
|
|
832
|
+
persona: this.config.hierarchy?.judge?.persona,
|
|
833
|
+
};
|
|
834
|
+
this.emit({ type: 'swarm.role.action', role: 'judge', action: 'quality-gate', model: judgeModel, taskId });
|
|
835
|
+
const quality = await evaluateWorkerOutput(this.provider, judgeModel, task, taskResult, judgeConfig);
|
|
836
|
+
taskResult.qualityScore = quality.score;
|
|
837
|
+
taskResult.qualityFeedback = quality.feedback;
|
|
838
|
+
if (!quality.passed) {
|
|
839
|
+
this.qualityRejections++;
|
|
840
|
+
const canRetry = this.taskQueue.markFailed(taskId, this.config.workerRetries);
|
|
841
|
+
if (canRetry) {
|
|
842
|
+
this.retries++;
|
|
843
|
+
}
|
|
844
|
+
// M1: Only emit quality.rejected (not duplicate task.failed)
|
|
845
|
+
this.emit({
|
|
846
|
+
type: 'swarm.quality.rejected',
|
|
847
|
+
taskId,
|
|
848
|
+
score: quality.score,
|
|
849
|
+
feedback: quality.feedback,
|
|
850
|
+
});
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// Task passed — mark completed
|
|
855
|
+
this.taskQueue.markCompleted(taskId, taskResult);
|
|
856
|
+
// H6: Post findings to blackboard with error handling
|
|
857
|
+
if (this.blackboard && taskResult.findings) {
|
|
858
|
+
try {
|
|
859
|
+
for (const finding of taskResult.findings) {
|
|
860
|
+
this.blackboard.post(`swarm-worker-${taskId}`, {
|
|
861
|
+
topic: `swarm.task.${task.type}`,
|
|
862
|
+
content: finding,
|
|
863
|
+
type: 'progress',
|
|
864
|
+
confidence: (taskResult.qualityScore ?? 3) / 5,
|
|
865
|
+
tags: ['swarm', task.type],
|
|
866
|
+
relatedFiles: task.targetFiles,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
// Don't crash orchestrator on blackboard failures
|
|
872
|
+
this.errors.push({
|
|
873
|
+
taskId,
|
|
874
|
+
phase: 'execution',
|
|
875
|
+
message: 'Failed to post findings to blackboard',
|
|
876
|
+
recovered: true,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
this.emit({
|
|
881
|
+
type: 'swarm.task.completed',
|
|
882
|
+
taskId,
|
|
883
|
+
success: true,
|
|
884
|
+
tokensUsed: taskResult.tokensUsed,
|
|
885
|
+
costUsed: taskResult.costUsed,
|
|
886
|
+
durationMs: taskResult.durationMs,
|
|
887
|
+
qualityScore: taskResult.qualityScore,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Phase 4: Synthesize all completed task outputs.
|
|
892
|
+
*/
|
|
893
|
+
async synthesize() {
|
|
894
|
+
const tasks = this.taskQueue.getAllTasks();
|
|
895
|
+
const outputs = tasks
|
|
896
|
+
.filter(t => t.status === 'completed')
|
|
897
|
+
.map(t => taskResultToAgentOutput(t))
|
|
898
|
+
.filter((o) => o !== null);
|
|
899
|
+
if (outputs.length === 0)
|
|
900
|
+
return null;
|
|
901
|
+
try {
|
|
902
|
+
return await this.synthesizer.synthesize(outputs);
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
this.errors.push({
|
|
906
|
+
phase: 'synthesis',
|
|
907
|
+
message: error.message,
|
|
908
|
+
recovered: true,
|
|
909
|
+
});
|
|
910
|
+
// Fallback: concatenate outputs
|
|
911
|
+
return this.synthesizer.synthesizeFindings(outputs);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Get live status for TUI.
|
|
916
|
+
*/
|
|
917
|
+
// M5: Use explicit phase tracking instead of inferring from queue state
|
|
918
|
+
getStatus() {
|
|
919
|
+
const stats = this.taskQueue.getStats();
|
|
920
|
+
return {
|
|
921
|
+
phase: this.cancelled ? 'failed' : this.currentPhase,
|
|
922
|
+
currentWave: this.taskQueue.getCurrentWave() + 1,
|
|
923
|
+
totalWaves: this.taskQueue.getTotalWaves(),
|
|
924
|
+
activeWorkers: this.workerPool.getActiveWorkerStatus(),
|
|
925
|
+
queue: stats,
|
|
926
|
+
budget: {
|
|
927
|
+
tokensUsed: this.totalTokens,
|
|
928
|
+
tokensTotal: this.config.totalBudget,
|
|
929
|
+
costUsed: this.totalCost,
|
|
930
|
+
costTotal: this.config.maxCost,
|
|
931
|
+
},
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Cancel the swarm execution.
|
|
936
|
+
* M6: Wait for active workers before cleanup.
|
|
937
|
+
*/
|
|
938
|
+
async cancel() {
|
|
939
|
+
this.cancelled = true;
|
|
940
|
+
this.currentPhase = 'failed';
|
|
941
|
+
await this.workerPool.cancelAll();
|
|
942
|
+
}
|
|
943
|
+
// ─── Circuit Breaker ────────────────────────────────────────────────
|
|
944
|
+
/**
|
|
945
|
+
* Record a rate limit hit and check if the circuit breaker should trip.
|
|
946
|
+
*/
|
|
947
|
+
recordRateLimit() {
|
|
948
|
+
const now = Date.now();
|
|
949
|
+
this.recentRateLimits.push(now);
|
|
950
|
+
// Prune entries older than the window
|
|
951
|
+
const cutoff = now - SwarmOrchestrator.CIRCUIT_BREAKER_WINDOW_MS;
|
|
952
|
+
this.recentRateLimits = this.recentRateLimits.filter(t => t > cutoff);
|
|
953
|
+
if (this.recentRateLimits.length >= SwarmOrchestrator.CIRCUIT_BREAKER_THRESHOLD) {
|
|
954
|
+
this.circuitBreakerUntil = now + SwarmOrchestrator.CIRCUIT_BREAKER_PAUSE_MS;
|
|
955
|
+
this.emit({
|
|
956
|
+
type: 'swarm.circuit.open',
|
|
957
|
+
recentCount: this.recentRateLimits.length,
|
|
958
|
+
pauseMs: SwarmOrchestrator.CIRCUIT_BREAKER_PAUSE_MS,
|
|
959
|
+
});
|
|
960
|
+
this.logDecision('circuit-breaker', 'Tripped — pausing all dispatch', `${this.recentRateLimits.length} rate limits in ${SwarmOrchestrator.CIRCUIT_BREAKER_WINDOW_MS / 1000}s window`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Check if the circuit breaker is currently active.
|
|
965
|
+
* Returns true if dispatch should be paused.
|
|
966
|
+
*/
|
|
967
|
+
isCircuitBreakerActive() {
|
|
968
|
+
if (Date.now() < this.circuitBreakerUntil)
|
|
969
|
+
return true;
|
|
970
|
+
if (this.circuitBreakerUntil > 0) {
|
|
971
|
+
// Circuit just closed
|
|
972
|
+
this.circuitBreakerUntil = 0;
|
|
973
|
+
this.emit({ type: 'swarm.circuit.closed' });
|
|
974
|
+
}
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
// ─── V2: Decision Logging ─────────────────────────────────────────────
|
|
978
|
+
logDecision(phase, decision, reasoning) {
|
|
979
|
+
const entry = {
|
|
980
|
+
timestamp: Date.now(),
|
|
981
|
+
phase,
|
|
982
|
+
decision,
|
|
983
|
+
reasoning,
|
|
984
|
+
};
|
|
985
|
+
this.orchestratorDecisions.push(entry);
|
|
986
|
+
this.emit({ type: 'swarm.orchestrator.decision', decision: entry });
|
|
987
|
+
}
|
|
988
|
+
// ─── V2: Persistence ──────────────────────────────────────────────────
|
|
989
|
+
checkpoint(_label) {
|
|
990
|
+
if (!this.config.enablePersistence || !this.stateStore)
|
|
991
|
+
return;
|
|
992
|
+
try {
|
|
993
|
+
const queueState = this.taskQueue.getCheckpointState();
|
|
994
|
+
this.stateStore.saveCheckpoint({
|
|
995
|
+
sessionId: this.stateStore.id,
|
|
996
|
+
timestamp: Date.now(),
|
|
997
|
+
phase: this.currentPhase,
|
|
998
|
+
plan: this.plan,
|
|
999
|
+
taskStates: queueState.taskStates,
|
|
1000
|
+
waves: queueState.waves,
|
|
1001
|
+
currentWave: queueState.currentWave,
|
|
1002
|
+
stats: {
|
|
1003
|
+
totalTokens: this.totalTokens,
|
|
1004
|
+
totalCost: this.totalCost,
|
|
1005
|
+
qualityRejections: this.qualityRejections,
|
|
1006
|
+
retries: this.retries,
|
|
1007
|
+
},
|
|
1008
|
+
modelHealth: this.healthTracker.getAllRecords(),
|
|
1009
|
+
decisions: this.orchestratorDecisions,
|
|
1010
|
+
errors: this.errors,
|
|
1011
|
+
});
|
|
1012
|
+
this.emit({
|
|
1013
|
+
type: 'swarm.state.checkpoint',
|
|
1014
|
+
sessionId: this.stateStore.id,
|
|
1015
|
+
wave: this.taskQueue.getCurrentWave(),
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
catch (error) {
|
|
1019
|
+
this.errors.push({
|
|
1020
|
+
phase: 'persistence',
|
|
1021
|
+
message: `Checkpoint failed (non-fatal): ${error.message}`,
|
|
1022
|
+
recovered: true,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
// ─── Private Helpers ───────────────────────────────────────────────────
|
|
1027
|
+
emitBudgetUpdate() {
|
|
1028
|
+
this.emit({
|
|
1029
|
+
type: 'swarm.budget.update',
|
|
1030
|
+
tokensUsed: this.totalTokens,
|
|
1031
|
+
tokensTotal: this.config.totalBudget,
|
|
1032
|
+
costUsed: this.totalCost,
|
|
1033
|
+
costTotal: this.config.maxCost,
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
emitStatusUpdate() {
|
|
1037
|
+
this.emit({ type: 'swarm.status', status: this.getStatus() });
|
|
1038
|
+
}
|
|
1039
|
+
buildStats() {
|
|
1040
|
+
const queueStats = this.taskQueue.getStats();
|
|
1041
|
+
return {
|
|
1042
|
+
totalTasks: queueStats.total,
|
|
1043
|
+
completedTasks: queueStats.completed,
|
|
1044
|
+
failedTasks: queueStats.failed,
|
|
1045
|
+
skippedTasks: queueStats.skipped,
|
|
1046
|
+
totalWaves: this.taskQueue.getTotalWaves(),
|
|
1047
|
+
totalTokens: this.totalTokens,
|
|
1048
|
+
totalCost: this.totalCost,
|
|
1049
|
+
totalDurationMs: Date.now() - this.startTime,
|
|
1050
|
+
qualityRejections: this.qualityRejections,
|
|
1051
|
+
retries: this.retries,
|
|
1052
|
+
modelUsage: this.modelUsage,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
buildSummary(stats) {
|
|
1056
|
+
const parts = [
|
|
1057
|
+
`Swarm execution complete:`,
|
|
1058
|
+
` Tasks: ${stats.completedTasks}/${stats.totalTasks} completed, ${stats.failedTasks} failed, ${stats.skippedTasks} skipped`,
|
|
1059
|
+
` Waves: ${stats.totalWaves}`,
|
|
1060
|
+
` Tokens: ${(stats.totalTokens / 1000).toFixed(0)}k`,
|
|
1061
|
+
` Cost: $${stats.totalCost.toFixed(4)}`,
|
|
1062
|
+
` Duration: ${(stats.totalDurationMs / 1000).toFixed(1)}s`,
|
|
1063
|
+
];
|
|
1064
|
+
if (stats.qualityRejections > 0) {
|
|
1065
|
+
parts.push(` Quality rejections: ${stats.qualityRejections}`);
|
|
1066
|
+
}
|
|
1067
|
+
if (stats.retries > 0) {
|
|
1068
|
+
parts.push(` Retries: ${stats.retries}`);
|
|
1069
|
+
}
|
|
1070
|
+
if (this.verificationResult) {
|
|
1071
|
+
parts.push(` Verification: ${this.verificationResult.passed ? 'PASSED' : 'FAILED'}`);
|
|
1072
|
+
}
|
|
1073
|
+
return parts.join('\n');
|
|
1074
|
+
}
|
|
1075
|
+
buildErrorResult(message) {
|
|
1076
|
+
return {
|
|
1077
|
+
success: false,
|
|
1078
|
+
summary: `Swarm failed: ${message}`,
|
|
1079
|
+
tasks: this.taskQueue.getAllTasks(),
|
|
1080
|
+
stats: this.buildStats(),
|
|
1081
|
+
errors: this.errors,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
/** Parse JSON from LLM response, handling markdown code blocks. */
|
|
1085
|
+
parseJSON(content) {
|
|
1086
|
+
try {
|
|
1087
|
+
// Strip markdown code blocks if present
|
|
1088
|
+
let json = content;
|
|
1089
|
+
const codeBlockMatch = content.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
1090
|
+
if (codeBlockMatch) {
|
|
1091
|
+
json = codeBlockMatch[1];
|
|
1092
|
+
}
|
|
1093
|
+
return JSON.parse(json);
|
|
1094
|
+
}
|
|
1095
|
+
catch {
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
/** Get a model health summary for emitting events. */
|
|
1100
|
+
getModelHealthSummary(model) {
|
|
1101
|
+
const records = this.healthTracker.getAllRecords();
|
|
1102
|
+
const record = records.find(r => r.model === model);
|
|
1103
|
+
return record
|
|
1104
|
+
? { successes: record.successes, failures: record.failures, rateLimits: record.rateLimits, lastRateLimit: record.lastRateLimit, averageLatencyMs: record.averageLatencyMs, healthy: record.healthy }
|
|
1105
|
+
: { successes: 0, failures: 0, rateLimits: 0, averageLatencyMs: 0, healthy: true };
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Factory function.
|
|
1110
|
+
*/
|
|
1111
|
+
export function createSwarmOrchestrator(config, provider, agentRegistry, spawnAgentFn, blackboard) {
|
|
1112
|
+
return new SwarmOrchestrator(config, provider, agentRegistry, spawnAgentFn, blackboard);
|
|
1113
|
+
}
|
|
1114
|
+
//# sourceMappingURL=swarm-orchestrator.js.map
|