swarm-code 0.1.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/LICENSE +21 -0
- package/README.md +384 -0
- package/bin/swarm.mjs +45 -0
- package/dist/agents/aider.d.ts +12 -0
- package/dist/agents/aider.js +182 -0
- package/dist/agents/claude-code.d.ts +9 -0
- package/dist/agents/claude-code.js +216 -0
- package/dist/agents/codex.d.ts +14 -0
- package/dist/agents/codex.js +193 -0
- package/dist/agents/direct-llm.d.ts +9 -0
- package/dist/agents/direct-llm.js +78 -0
- package/dist/agents/mock.d.ts +9 -0
- package/dist/agents/mock.js +77 -0
- package/dist/agents/opencode.d.ts +23 -0
- package/dist/agents/opencode.js +571 -0
- package/dist/agents/provider.d.ts +11 -0
- package/dist/agents/provider.js +31 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +285 -0
- package/dist/compression/compressor.d.ts +28 -0
- package/dist/compression/compressor.js +265 -0
- package/dist/config.d.ts +42 -0
- package/dist/config.js +170 -0
- package/dist/core/repl.d.ts +69 -0
- package/dist/core/repl.js +336 -0
- package/dist/core/rlm.d.ts +63 -0
- package/dist/core/rlm.js +409 -0
- package/dist/core/runtime.py +335 -0
- package/dist/core/types.d.ts +131 -0
- package/dist/core/types.js +19 -0
- package/dist/env.d.ts +10 -0
- package/dist/env.js +75 -0
- package/dist/interactive-swarm.d.ts +20 -0
- package/dist/interactive-swarm.js +1041 -0
- package/dist/interactive.d.ts +10 -0
- package/dist/interactive.js +1765 -0
- package/dist/main.d.ts +15 -0
- package/dist/main.js +242 -0
- package/dist/mcp/server.d.ts +15 -0
- package/dist/mcp/server.js +72 -0
- package/dist/mcp/session.d.ts +73 -0
- package/dist/mcp/session.js +184 -0
- package/dist/mcp/tools.d.ts +15 -0
- package/dist/mcp/tools.js +377 -0
- package/dist/memory/episodic.d.ts +132 -0
- package/dist/memory/episodic.js +390 -0
- package/dist/prompts/orchestrator.d.ts +5 -0
- package/dist/prompts/orchestrator.js +191 -0
- package/dist/routing/model-router.d.ts +130 -0
- package/dist/routing/model-router.js +515 -0
- package/dist/swarm.d.ts +14 -0
- package/dist/swarm.js +557 -0
- package/dist/threads/cache.d.ts +58 -0
- package/dist/threads/cache.js +198 -0
- package/dist/threads/manager.d.ts +85 -0
- package/dist/threads/manager.js +659 -0
- package/dist/ui/banner.d.ts +14 -0
- package/dist/ui/banner.js +42 -0
- package/dist/ui/dashboard.d.ts +33 -0
- package/dist/ui/dashboard.js +151 -0
- package/dist/ui/index.d.ts +10 -0
- package/dist/ui/index.js +11 -0
- package/dist/ui/log.d.ts +39 -0
- package/dist/ui/log.js +126 -0
- package/dist/ui/onboarding.d.ts +14 -0
- package/dist/ui/onboarding.js +518 -0
- package/dist/ui/spinner.d.ts +25 -0
- package/dist/ui/spinner.js +113 -0
- package/dist/ui/summary.d.ts +18 -0
- package/dist/ui/summary.js +113 -0
- package/dist/ui/theme.d.ts +63 -0
- package/dist/ui/theme.js +97 -0
- package/dist/viewer.d.ts +12 -0
- package/dist/viewer.js +1284 -0
- package/dist/worktree/manager.d.ts +45 -0
- package/dist/worktree/manager.js +266 -0
- package/dist/worktree/merge.d.ts +28 -0
- package/dist/worktree/merge.js +138 -0
- package/package.json +69 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread manager — spawns and manages coding agent threads in isolated worktrees.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - AsyncSemaphore for proper concurrency gating (no polling)
|
|
6
|
+
* - AbortSignal propagation for thread cancellation
|
|
7
|
+
* - Per-thread retry logic with exponential backoff
|
|
8
|
+
* - Error classification: retryable (transient) vs fatal (permanent)
|
|
9
|
+
* - Agent/model re-routing on failure (fallback to alternative combos)
|
|
10
|
+
* - Budget tracking and enforcement
|
|
11
|
+
* - Per-thread error isolation
|
|
12
|
+
*/
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { getAgent, listAgents } from "../agents/provider.js";
|
|
15
|
+
import { compressResult } from "../compression/compressor.js";
|
|
16
|
+
import { MODEL_PRICING as PRICING } from "../core/types.js";
|
|
17
|
+
import { AGENT_CAPABILITIES } from "../routing/model-router.js";
|
|
18
|
+
import { WorktreeManager } from "../worktree/manager.js";
|
|
19
|
+
import { ThreadCache } from "./cache.js";
|
|
20
|
+
// ── Async Semaphore ─────────────────────────────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* Promise-based semaphore for concurrency control.
|
|
23
|
+
* acquire() resolves when a slot is available, release() frees a slot.
|
|
24
|
+
*/
|
|
25
|
+
export class AsyncSemaphore {
|
|
26
|
+
current = 0;
|
|
27
|
+
max;
|
|
28
|
+
waiters = [];
|
|
29
|
+
constructor(max) {
|
|
30
|
+
this.max = max;
|
|
31
|
+
}
|
|
32
|
+
async acquire() {
|
|
33
|
+
if (this.current < this.max) {
|
|
34
|
+
this.current++;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
await new Promise((resolve) => {
|
|
38
|
+
this.waiters.push(resolve);
|
|
39
|
+
});
|
|
40
|
+
// current already accounts for this slot — release() transferred it directly
|
|
41
|
+
}
|
|
42
|
+
release() {
|
|
43
|
+
const next = this.waiters.shift();
|
|
44
|
+
if (next) {
|
|
45
|
+
// Transfer the slot directly to the waiter (current stays the same)
|
|
46
|
+
next();
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
if (this.current <= 0)
|
|
50
|
+
return; // Guard against double-release
|
|
51
|
+
this.current--;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
get activeCount() {
|
|
55
|
+
return this.current;
|
|
56
|
+
}
|
|
57
|
+
get waitingCount() {
|
|
58
|
+
return this.waiters.length;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// ── Budget Tracker ──────────────────────────────────────────────────────────
|
|
62
|
+
class BudgetTracker {
|
|
63
|
+
totalSpent = 0;
|
|
64
|
+
threadCosts = new Map();
|
|
65
|
+
sessionLimit;
|
|
66
|
+
perThreadLimit;
|
|
67
|
+
totalInputTokens = 0;
|
|
68
|
+
totalOutputTokens = 0;
|
|
69
|
+
actualCostCount = 0;
|
|
70
|
+
estimatedCostCount = 0;
|
|
71
|
+
constructor(sessionLimit, perThreadLimit) {
|
|
72
|
+
this.sessionLimit = sessionLimit;
|
|
73
|
+
this.perThreadLimit = perThreadLimit;
|
|
74
|
+
}
|
|
75
|
+
/** Estimate cost for a thread based on model and assumed token usage. */
|
|
76
|
+
estimateThreadCost(model) {
|
|
77
|
+
const modelName = model.includes("/") ? model.split("/").pop() : model;
|
|
78
|
+
const pricing = PRICING[modelName];
|
|
79
|
+
if (!pricing)
|
|
80
|
+
return 0.05;
|
|
81
|
+
// Assume ~4K input tokens, ~2K output tokens per thread execution
|
|
82
|
+
const inputTokens = 4000;
|
|
83
|
+
const outputTokens = 2000;
|
|
84
|
+
return (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Calculate actual cost from real token usage.
|
|
88
|
+
* Returns null if pricing for the model is unknown.
|
|
89
|
+
*/
|
|
90
|
+
calculateActualCost(model, usage) {
|
|
91
|
+
const modelName = model.includes("/") ? model.split("/").pop() : model;
|
|
92
|
+
const pricing = PRICING[modelName];
|
|
93
|
+
if (!pricing)
|
|
94
|
+
return null;
|
|
95
|
+
return (usage.inputTokens * pricing.input + usage.outputTokens * pricing.output) / 1_000_000;
|
|
96
|
+
}
|
|
97
|
+
/** Check if we can afford to spawn a thread. */
|
|
98
|
+
canAfford(model) {
|
|
99
|
+
const estimate = this.estimateThreadCost(model);
|
|
100
|
+
if (this.totalSpent + estimate > this.sessionLimit) {
|
|
101
|
+
return {
|
|
102
|
+
allowed: false,
|
|
103
|
+
reason: `Session budget exceeded: $${this.totalSpent.toFixed(4)} spent of $${this.sessionLimit.toFixed(2)} limit (next thread ~$${estimate.toFixed(4)})`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (estimate > this.perThreadLimit) {
|
|
107
|
+
return {
|
|
108
|
+
allowed: false,
|
|
109
|
+
reason: `Thread cost estimate ($${estimate.toFixed(4)}) exceeds per-thread limit ($${this.perThreadLimit.toFixed(2)})`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return { allowed: true };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Record cost for a completed thread.
|
|
116
|
+
* Uses actual usage when available, falls back to estimate.
|
|
117
|
+
*/
|
|
118
|
+
recordCost(threadId, model, usage) {
|
|
119
|
+
let cost;
|
|
120
|
+
let isEstimate;
|
|
121
|
+
if (usage && (usage.inputTokens > 0 || usage.outputTokens > 0)) {
|
|
122
|
+
// Use real token counts
|
|
123
|
+
const actual = this.calculateActualCost(model, usage);
|
|
124
|
+
if (actual !== null) {
|
|
125
|
+
cost = actual;
|
|
126
|
+
isEstimate = false;
|
|
127
|
+
this.actualCostCount++;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
// Have tokens but no pricing — estimate
|
|
131
|
+
cost = this.estimateThreadCost(model);
|
|
132
|
+
isEstimate = true;
|
|
133
|
+
this.estimatedCostCount++;
|
|
134
|
+
}
|
|
135
|
+
this.totalInputTokens += usage.inputTokens;
|
|
136
|
+
this.totalOutputTokens += usage.outputTokens;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
// No usage data — estimate
|
|
140
|
+
cost = this.estimateThreadCost(model);
|
|
141
|
+
isEstimate = true;
|
|
142
|
+
this.estimatedCostCount++;
|
|
143
|
+
}
|
|
144
|
+
this.threadCosts.set(threadId, cost);
|
|
145
|
+
this.totalSpent += cost;
|
|
146
|
+
return { cost, isEstimate };
|
|
147
|
+
}
|
|
148
|
+
get spent() {
|
|
149
|
+
return this.totalSpent;
|
|
150
|
+
}
|
|
151
|
+
getState() {
|
|
152
|
+
return {
|
|
153
|
+
totalSpentUsd: this.totalSpent,
|
|
154
|
+
threadCosts: new Map(this.threadCosts),
|
|
155
|
+
sessionLimitUsd: this.sessionLimit,
|
|
156
|
+
perThreadLimitUsd: this.perThreadLimit,
|
|
157
|
+
totalTokens: {
|
|
158
|
+
input: this.totalInputTokens,
|
|
159
|
+
output: this.totalOutputTokens,
|
|
160
|
+
},
|
|
161
|
+
actualCostThreads: this.actualCostCount,
|
|
162
|
+
estimatedCostThreads: this.estimatedCostCount,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// ── Error Classification ────────────────────────────────────────────────────
|
|
167
|
+
/** Patterns that indicate transient/retryable errors. */
|
|
168
|
+
const RETRYABLE_PATTERNS = [
|
|
169
|
+
/timeout/i,
|
|
170
|
+
/timed?\s*out/i,
|
|
171
|
+
/ECONNRESET/i,
|
|
172
|
+
/ECONNREFUSED/i,
|
|
173
|
+
/EPIPE/i,
|
|
174
|
+
/rate limit/i,
|
|
175
|
+
/429/,
|
|
176
|
+
/503/,
|
|
177
|
+
/502/,
|
|
178
|
+
/500/,
|
|
179
|
+
/too many requests/i,
|
|
180
|
+
/temporarily unavailable/i,
|
|
181
|
+
/server error/i,
|
|
182
|
+
/overloaded/i,
|
|
183
|
+
/capacity/i,
|
|
184
|
+
/lock file/i,
|
|
185
|
+
/index\.lock/i,
|
|
186
|
+
];
|
|
187
|
+
/** Patterns that indicate permanent/fatal errors (don't retry). */
|
|
188
|
+
const FATAL_PATTERNS = [
|
|
189
|
+
/authentication/i,
|
|
190
|
+
/unauthorized/i,
|
|
191
|
+
/forbidden/i,
|
|
192
|
+
/invalid api key/i,
|
|
193
|
+
/model not found/i,
|
|
194
|
+
/permission denied/i,
|
|
195
|
+
/quota exceeded/i,
|
|
196
|
+
/billing/i,
|
|
197
|
+
];
|
|
198
|
+
/** Classify an error as retryable or fatal. Default: retryable (optimistic). */
|
|
199
|
+
function isRetryableError(error) {
|
|
200
|
+
// Check fatal patterns first (takes priority)
|
|
201
|
+
if (FATAL_PATTERNS.some((p) => p.test(error)))
|
|
202
|
+
return false;
|
|
203
|
+
// Check retryable patterns
|
|
204
|
+
if (RETRYABLE_PATTERNS.some((p) => p.test(error)))
|
|
205
|
+
return true;
|
|
206
|
+
// Default: retryable (be optimistic — the retry might work with a different agent)
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
/** Calculate exponential backoff delay with jitter. */
|
|
210
|
+
function backoffDelay(attempt, baseMs = 1000) {
|
|
211
|
+
// Exponential: 1s, 2s, 4s, 8s... capped at 30s
|
|
212
|
+
const exponential = Math.min(baseMs * 2 ** (attempt - 1), 30000);
|
|
213
|
+
// Add jitter (±25%)
|
|
214
|
+
const jitter = exponential * 0.25 * (Math.random() * 2 - 1);
|
|
215
|
+
return Math.max(100, exponential + jitter);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Pick an alternative agent/model combo for retry.
|
|
219
|
+
* Avoids the agent that just failed and prefers agents with different default models.
|
|
220
|
+
* Uses attempt number to cycle through alternatives on subsequent retries.
|
|
221
|
+
*/
|
|
222
|
+
function pickAlternativeAgent(failedAgent, failedModel, _config, attempt = 1) {
|
|
223
|
+
const available = listAgents().filter((name) => name !== failedAgent && name !== "mock");
|
|
224
|
+
if (available.length === 0)
|
|
225
|
+
return null;
|
|
226
|
+
// Build candidates, preferring agents with different default models
|
|
227
|
+
const candidates = [];
|
|
228
|
+
for (const name of available) {
|
|
229
|
+
const cap = AGENT_CAPABILITIES[name];
|
|
230
|
+
if (cap && cap.defaultModel !== failedModel) {
|
|
231
|
+
candidates.push({ agent: name, model: cap.defaultModel });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Also include agents with same model (but different agent) as lower priority
|
|
235
|
+
for (const name of available) {
|
|
236
|
+
const cap = AGENT_CAPABILITIES[name];
|
|
237
|
+
if (cap && cap.defaultModel === failedModel) {
|
|
238
|
+
candidates.push({ agent: name, model: cap.defaultModel });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// If no capabilities known, add all available with failedModel
|
|
242
|
+
if (candidates.length === 0) {
|
|
243
|
+
for (const name of available) {
|
|
244
|
+
candidates.push({ agent: name, model: failedModel });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (candidates.length === 0)
|
|
248
|
+
return null;
|
|
249
|
+
// Cycle through candidates based on attempt number
|
|
250
|
+
const idx = (attempt - 1) % candidates.length;
|
|
251
|
+
return candidates[idx];
|
|
252
|
+
}
|
|
253
|
+
export class ThreadManager {
|
|
254
|
+
threads = new Map();
|
|
255
|
+
totalSpawned = 0;
|
|
256
|
+
semaphore;
|
|
257
|
+
worktreeManager;
|
|
258
|
+
config;
|
|
259
|
+
budget;
|
|
260
|
+
threadCache;
|
|
261
|
+
episodicMemory;
|
|
262
|
+
onThreadProgress;
|
|
263
|
+
sessionAbort;
|
|
264
|
+
threadAbortControllers = new Map();
|
|
265
|
+
constructor(repoRoot, config, onThreadProgress, sessionAbort) {
|
|
266
|
+
this.config = config;
|
|
267
|
+
this.semaphore = new AsyncSemaphore(config.max_threads);
|
|
268
|
+
this.worktreeManager = new WorktreeManager(repoRoot, config.worktree_base_dir);
|
|
269
|
+
this.budget = new BudgetTracker(config.max_session_budget_usd, config.max_thread_budget_usd);
|
|
270
|
+
this.threadCache = new ThreadCache(100, config.thread_cache_persist ? config.thread_cache_dir : undefined, config.thread_cache_ttl_hours);
|
|
271
|
+
this.onThreadProgress = onThreadProgress;
|
|
272
|
+
this.sessionAbort = sessionAbort;
|
|
273
|
+
}
|
|
274
|
+
/** Set the episodic memory store for recording thread outcomes. */
|
|
275
|
+
setEpisodicMemory(memory) {
|
|
276
|
+
this.episodicMemory = memory;
|
|
277
|
+
}
|
|
278
|
+
async init() {
|
|
279
|
+
await this.worktreeManager.init();
|
|
280
|
+
await this.threadCache.init();
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Spawn a thread — creates a worktree, runs the agent, returns compressed result.
|
|
284
|
+
* Checks the subthread cache first; on cache hit, returns immediately (Slate-style reuse).
|
|
285
|
+
* Retries up to config.thread_retries times on failure.
|
|
286
|
+
* Error-isolated: a failure here never throws — always returns a CompressedResult.
|
|
287
|
+
*/
|
|
288
|
+
async spawnThread(threadConfig) {
|
|
289
|
+
// Subthread cache lookup — return cached result for identical tasks
|
|
290
|
+
const cacheAgent = threadConfig.agent.backend || this.config.default_agent;
|
|
291
|
+
const cacheModel = threadConfig.agent.model || this.config.default_model;
|
|
292
|
+
const cacheFiles = threadConfig.files || [];
|
|
293
|
+
const cached = this.threadCache.get(threadConfig.task, cacheFiles, cacheAgent, cacheModel);
|
|
294
|
+
if (cached) {
|
|
295
|
+
const threadId = threadConfig.id || randomBytes(6).toString("hex");
|
|
296
|
+
this.onThreadProgress?.(threadId, "completed", "cache hit");
|
|
297
|
+
return cached;
|
|
298
|
+
}
|
|
299
|
+
// Enforce total thread limit
|
|
300
|
+
if (this.totalSpawned >= this.config.max_total_threads) {
|
|
301
|
+
return {
|
|
302
|
+
success: false,
|
|
303
|
+
summary: `Thread limit reached (${this.config.max_total_threads} max per session)`,
|
|
304
|
+
filesChanged: [],
|
|
305
|
+
diffStats: "",
|
|
306
|
+
durationMs: 0,
|
|
307
|
+
estimatedCostUsd: 0,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
// Preliminary budget check (definitive check happens inside semaphore)
|
|
311
|
+
const model = threadConfig.agent.model || this.config.default_model;
|
|
312
|
+
const budgetCheck = this.budget.canAfford(model);
|
|
313
|
+
if (!budgetCheck.allowed) {
|
|
314
|
+
return {
|
|
315
|
+
success: false,
|
|
316
|
+
summary: `Budget exceeded: ${budgetCheck.reason}`,
|
|
317
|
+
filesChanged: [],
|
|
318
|
+
diffStats: "",
|
|
319
|
+
durationMs: 0,
|
|
320
|
+
estimatedCostUsd: 0,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
// Check session abort
|
|
324
|
+
if (this.sessionAbort?.aborted) {
|
|
325
|
+
return {
|
|
326
|
+
success: false,
|
|
327
|
+
summary: "Session aborted",
|
|
328
|
+
filesChanged: [],
|
|
329
|
+
diffStats: "",
|
|
330
|
+
durationMs: 0,
|
|
331
|
+
estimatedCostUsd: 0,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const threadId = threadConfig.id || randomBytes(6).toString("hex");
|
|
335
|
+
const maxAttempts = this.config.thread_retries + 1;
|
|
336
|
+
const state = {
|
|
337
|
+
id: threadId,
|
|
338
|
+
config: threadConfig,
|
|
339
|
+
status: "pending",
|
|
340
|
+
phase: "queued",
|
|
341
|
+
startedAt: Date.now(),
|
|
342
|
+
attempt: 0,
|
|
343
|
+
maxAttempts,
|
|
344
|
+
estimatedCostUsd: 0,
|
|
345
|
+
};
|
|
346
|
+
this.threads.set(threadId, state);
|
|
347
|
+
this.totalSpawned++;
|
|
348
|
+
// Create per-thread abort controller (linked to session abort)
|
|
349
|
+
const threadAc = new AbortController();
|
|
350
|
+
this.threadAbortControllers.set(threadId, threadAc);
|
|
351
|
+
const onSessionAbort = () => threadAc.abort();
|
|
352
|
+
if (this.sessionAbort) {
|
|
353
|
+
if (this.sessionAbort.aborted) {
|
|
354
|
+
threadAc.abort();
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
this.sessionAbort.addEventListener("abort", onSessionAbort, { once: true });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Retry loop with exponential backoff and agent re-routing
|
|
361
|
+
let lastResult;
|
|
362
|
+
let currentConfig = threadConfig;
|
|
363
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
364
|
+
state.attempt = attempt;
|
|
365
|
+
if (attempt > 1) {
|
|
366
|
+
// Exponential backoff before retry (abort-aware)
|
|
367
|
+
const delay = backoffDelay(attempt - 1);
|
|
368
|
+
state.phase = "retrying";
|
|
369
|
+
this.onThreadProgress?.(threadId, "retrying", `attempt ${attempt}/${maxAttempts}, backoff ${(delay / 1000).toFixed(1)}s`);
|
|
370
|
+
// Race the delay against the abort signal so cancellation is immediate
|
|
371
|
+
await new Promise((resolve) => {
|
|
372
|
+
const timer = setTimeout(resolve, delay);
|
|
373
|
+
if (threadAc.signal.aborted) {
|
|
374
|
+
clearTimeout(timer);
|
|
375
|
+
resolve();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const onAbort = () => {
|
|
379
|
+
clearTimeout(timer);
|
|
380
|
+
resolve();
|
|
381
|
+
};
|
|
382
|
+
threadAc.signal.addEventListener("abort", onAbort, { once: true });
|
|
383
|
+
});
|
|
384
|
+
if (threadAc.signal.aborted)
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
lastResult = await this.executeThread(threadId, currentConfig, state, threadAc.signal);
|
|
388
|
+
if (lastResult.success || threadAc.signal.aborted) {
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
// Don't retry on cancellation or budget issues
|
|
392
|
+
if (state.status === "cancelled")
|
|
393
|
+
break;
|
|
394
|
+
// Classify the error — don't retry fatal errors
|
|
395
|
+
const errorMsg = state.error || lastResult.summary || "";
|
|
396
|
+
if (!isRetryableError(errorMsg)) {
|
|
397
|
+
this.onThreadProgress?.(threadId, "failed", `fatal error, not retrying: ${errorMsg.slice(0, 80)}`);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
// Try re-routing to a different agent/model on retry
|
|
401
|
+
if (attempt < maxAttempts) {
|
|
402
|
+
const currentAgent = currentConfig.agent.backend || this.config.default_agent;
|
|
403
|
+
const currentModel = currentConfig.agent.model || this.config.default_model;
|
|
404
|
+
const alt = pickAlternativeAgent(currentAgent, currentModel, this.config, attempt);
|
|
405
|
+
if (alt) {
|
|
406
|
+
currentConfig = {
|
|
407
|
+
...currentConfig,
|
|
408
|
+
agent: { backend: alt.agent, model: alt.model },
|
|
409
|
+
};
|
|
410
|
+
this.onThreadProgress?.(threadId, "retrying", `re-routing: ${currentAgent} → ${alt.agent}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
this.sessionAbort?.removeEventListener("abort", onSessionAbort);
|
|
415
|
+
this.threadAbortControllers.delete(threadId);
|
|
416
|
+
return lastResult;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Execute a single thread attempt. Acquires semaphore, creates worktree,
|
|
420
|
+
* runs agent, captures diff, compresses result.
|
|
421
|
+
*/
|
|
422
|
+
async executeThread(threadId, threadConfig, state, signal) {
|
|
423
|
+
// Wait for a concurrency slot
|
|
424
|
+
state.phase = "queued";
|
|
425
|
+
this.onThreadProgress?.(threadId, "queued", this.semaphore.waitingCount > 0 ? `waiting (${this.semaphore.waitingCount} ahead)` : undefined);
|
|
426
|
+
await this.semaphore.acquire();
|
|
427
|
+
try {
|
|
428
|
+
if (signal.aborted) {
|
|
429
|
+
state.status = "cancelled";
|
|
430
|
+
state.phase = "cancelled";
|
|
431
|
+
state.completedAt = Date.now();
|
|
432
|
+
return this.failResult(state, "Thread cancelled before start");
|
|
433
|
+
}
|
|
434
|
+
// Definitive budget check inside semaphore (prevents race condition)
|
|
435
|
+
const threadModel = threadConfig.agent.model || this.config.default_model;
|
|
436
|
+
const budgetCheck = this.budget.canAfford(threadModel);
|
|
437
|
+
if (!budgetCheck.allowed) {
|
|
438
|
+
state.status = "failed";
|
|
439
|
+
state.phase = "failed";
|
|
440
|
+
state.completedAt = Date.now();
|
|
441
|
+
return {
|
|
442
|
+
success: false,
|
|
443
|
+
summary: `Budget exceeded: ${budgetCheck.reason}`,
|
|
444
|
+
filesChanged: [],
|
|
445
|
+
diffStats: "",
|
|
446
|
+
durationMs: 0,
|
|
447
|
+
estimatedCostUsd: 0,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
state.status = "running";
|
|
451
|
+
// Create worktree
|
|
452
|
+
state.phase = "creating_worktree";
|
|
453
|
+
this.onThreadProgress?.(threadId, "creating_worktree");
|
|
454
|
+
const wtInfo = await this.worktreeManager.create(threadId);
|
|
455
|
+
state.worktreePath = wtInfo.path;
|
|
456
|
+
state.branchName = wtInfo.branch;
|
|
457
|
+
// Run agent
|
|
458
|
+
state.phase = "agent_running";
|
|
459
|
+
this.onThreadProgress?.(threadId, "agent_running");
|
|
460
|
+
const agent = getAgent(threadConfig.agent.backend || this.config.default_agent);
|
|
461
|
+
let fullTask = threadConfig.task;
|
|
462
|
+
if (threadConfig.context) {
|
|
463
|
+
fullTask = `Context:\n${threadConfig.context}\n\nTask:\n${threadConfig.task}`;
|
|
464
|
+
}
|
|
465
|
+
// Combine thread timeout with cancellation signal
|
|
466
|
+
const timeoutSignal = AbortSignal.timeout(this.config.thread_timeout_ms);
|
|
467
|
+
const combinedAc = new AbortController();
|
|
468
|
+
const onAbort = () => combinedAc.abort();
|
|
469
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
470
|
+
timeoutSignal.addEventListener("abort", onAbort, { once: true });
|
|
471
|
+
let agentResult;
|
|
472
|
+
try {
|
|
473
|
+
agentResult = await agent.run({
|
|
474
|
+
task: fullTask,
|
|
475
|
+
workDir: wtInfo.path,
|
|
476
|
+
model: threadConfig.agent.model || this.config.default_model,
|
|
477
|
+
files: threadConfig.files,
|
|
478
|
+
signal: combinedAc.signal,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
signal.removeEventListener("abort", onAbort);
|
|
483
|
+
timeoutSignal.removeEventListener("abort", onAbort);
|
|
484
|
+
}
|
|
485
|
+
if (signal.aborted) {
|
|
486
|
+
state.status = "cancelled";
|
|
487
|
+
state.phase = "cancelled";
|
|
488
|
+
state.completedAt = Date.now();
|
|
489
|
+
await this.cleanupWorktree(threadId);
|
|
490
|
+
return this.failResult(state, "Thread cancelled during execution");
|
|
491
|
+
}
|
|
492
|
+
// Capture diff
|
|
493
|
+
state.phase = "capturing_diff";
|
|
494
|
+
this.onThreadProgress?.(threadId, "capturing_diff");
|
|
495
|
+
const diff = await this.worktreeManager.getDiff(threadId);
|
|
496
|
+
const diffStats = await this.worktreeManager.getDiffStats(threadId);
|
|
497
|
+
const filesChanged = await this.worktreeManager.getChangedFiles(threadId);
|
|
498
|
+
if (filesChanged.length > 0) {
|
|
499
|
+
await this.worktreeManager.commit(threadId, `swarm: ${threadConfig.task.slice(0, 72)}`);
|
|
500
|
+
}
|
|
501
|
+
// Compress
|
|
502
|
+
state.phase = "compressing";
|
|
503
|
+
this.onThreadProgress?.(threadId, "compressing");
|
|
504
|
+
const compressed = await compressResult({
|
|
505
|
+
agentOutput: agentResult.output,
|
|
506
|
+
diff,
|
|
507
|
+
diffStats,
|
|
508
|
+
filesChanged,
|
|
509
|
+
success: agentResult.success,
|
|
510
|
+
durationMs: agentResult.durationMs,
|
|
511
|
+
error: agentResult.error,
|
|
512
|
+
}, this.config.compression_strategy, this.config.compression_max_tokens);
|
|
513
|
+
// Record cost — uses real usage when available, falls back to estimate
|
|
514
|
+
const model = threadConfig.agent.model || this.config.default_model;
|
|
515
|
+
const { cost, isEstimate } = this.budget.recordCost(threadId, model, agentResult.usage);
|
|
516
|
+
state.estimatedCostUsd = cost;
|
|
517
|
+
const costLabel = isEstimate ? `~$${cost.toFixed(4)}` : `$${cost.toFixed(4)}`;
|
|
518
|
+
const usageLabel = agentResult.usage
|
|
519
|
+
? ` (${agentResult.usage.inputTokens}+${agentResult.usage.outputTokens} tokens)`
|
|
520
|
+
: "";
|
|
521
|
+
const result = {
|
|
522
|
+
success: agentResult.success,
|
|
523
|
+
summary: compressed,
|
|
524
|
+
filesChanged,
|
|
525
|
+
diffStats,
|
|
526
|
+
durationMs: Date.now() - state.startedAt,
|
|
527
|
+
estimatedCostUsd: cost,
|
|
528
|
+
usage: agentResult.usage,
|
|
529
|
+
costIsEstimate: isEstimate,
|
|
530
|
+
};
|
|
531
|
+
state.status = "completed";
|
|
532
|
+
state.phase = "completed";
|
|
533
|
+
state.result = result;
|
|
534
|
+
state.completedAt = Date.now();
|
|
535
|
+
this.onThreadProgress?.(threadId, "completed", `${filesChanged.length} files, ${costLabel}${usageLabel}`);
|
|
536
|
+
// Cache successful results for subthread reuse
|
|
537
|
+
if (result.success) {
|
|
538
|
+
const cfg = state.config;
|
|
539
|
+
this.threadCache.set(cfg.task, cfg.files || [], cfg.agent.backend || this.config.default_agent, cfg.agent.model || this.config.default_model, result);
|
|
540
|
+
// Record episode in episodic memory (fire-and-forget)
|
|
541
|
+
// Only records if auto-routing is NOT active (swarm.ts records richer episodes with slot/complexity)
|
|
542
|
+
if (this.episodicMemory && !this.config.auto_model_selection) {
|
|
543
|
+
this.episodicMemory
|
|
544
|
+
.record({
|
|
545
|
+
task: cfg.task,
|
|
546
|
+
agent: cfg.agent.backend || this.config.default_agent,
|
|
547
|
+
model: cfg.agent.model || this.config.default_model,
|
|
548
|
+
slot: "",
|
|
549
|
+
complexity: "",
|
|
550
|
+
success: true,
|
|
551
|
+
durationMs: result.durationMs,
|
|
552
|
+
estimatedCostUsd: cost,
|
|
553
|
+
filesChanged: filesChanged,
|
|
554
|
+
summary: compressed,
|
|
555
|
+
})
|
|
556
|
+
.catch(() => { }); // Non-fatal
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return result;
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
563
|
+
state.status = "failed";
|
|
564
|
+
state.phase = "failed";
|
|
565
|
+
state.error = errorMsg;
|
|
566
|
+
state.completedAt = Date.now();
|
|
567
|
+
this.onThreadProgress?.(threadId, "failed", errorMsg.slice(0, 100));
|
|
568
|
+
// Record estimated cost for failed threads (agent may have consumed tokens before failure)
|
|
569
|
+
const errModel = threadConfig.agent.model || this.config.default_model;
|
|
570
|
+
const { cost } = this.budget.recordCost(threadId, errModel);
|
|
571
|
+
state.estimatedCostUsd = cost;
|
|
572
|
+
// Cleanup worktree on failure
|
|
573
|
+
await this.cleanupWorktree(threadId);
|
|
574
|
+
return this.failResult(state, errorMsg);
|
|
575
|
+
}
|
|
576
|
+
finally {
|
|
577
|
+
this.semaphore.release();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** Cancel a specific running thread. */
|
|
581
|
+
cancelThread(threadId) {
|
|
582
|
+
const ac = this.threadAbortControllers.get(threadId);
|
|
583
|
+
if (!ac)
|
|
584
|
+
return false;
|
|
585
|
+
ac.abort();
|
|
586
|
+
const state = this.threads.get(threadId);
|
|
587
|
+
if (state) {
|
|
588
|
+
state.status = "cancelled";
|
|
589
|
+
state.phase = "cancelled";
|
|
590
|
+
}
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
/** Cancel all running threads. */
|
|
594
|
+
cancelAll() {
|
|
595
|
+
for (const [id, ac] of this.threadAbortControllers) {
|
|
596
|
+
ac.abort();
|
|
597
|
+
const state = this.threads.get(id);
|
|
598
|
+
if (state && (state.status === "running" || state.status === "pending")) {
|
|
599
|
+
state.status = "cancelled";
|
|
600
|
+
state.phase = "cancelled";
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/** Get all thread states. */
|
|
605
|
+
getThreads() {
|
|
606
|
+
return [...this.threads.values()];
|
|
607
|
+
}
|
|
608
|
+
/** Get a specific thread's state. */
|
|
609
|
+
getThread(threadId) {
|
|
610
|
+
return this.threads.get(threadId);
|
|
611
|
+
}
|
|
612
|
+
/** Get the worktree manager for merge operations. */
|
|
613
|
+
getWorktreeManager() {
|
|
614
|
+
return this.worktreeManager;
|
|
615
|
+
}
|
|
616
|
+
/** Get current budget state. */
|
|
617
|
+
getBudgetState() {
|
|
618
|
+
return this.budget.getState();
|
|
619
|
+
}
|
|
620
|
+
/** Get subthread cache stats. */
|
|
621
|
+
getCacheStats() {
|
|
622
|
+
return this.threadCache.getStats();
|
|
623
|
+
}
|
|
624
|
+
/** Get concurrency stats. */
|
|
625
|
+
getConcurrencyStats() {
|
|
626
|
+
return {
|
|
627
|
+
active: this.semaphore.activeCount,
|
|
628
|
+
waiting: this.semaphore.waitingCount,
|
|
629
|
+
total: this.totalSpawned,
|
|
630
|
+
max: this.config.max_threads,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/** Cleanup all worktrees. */
|
|
634
|
+
async cleanup() {
|
|
635
|
+
this.cancelAll();
|
|
636
|
+
if (this.config.auto_cleanup_worktrees) {
|
|
637
|
+
await this.worktreeManager.destroyAll();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
async cleanupWorktree(threadId) {
|
|
641
|
+
try {
|
|
642
|
+
await this.worktreeManager.destroy(threadId, true);
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
// Non-fatal
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
failResult(state, message) {
|
|
649
|
+
return {
|
|
650
|
+
success: false,
|
|
651
|
+
summary: `Thread failed (attempt ${state.attempt}/${state.maxAttempts}): ${message}`,
|
|
652
|
+
filesChanged: [],
|
|
653
|
+
diffStats: "",
|
|
654
|
+
durationMs: Date.now() - (state.startedAt || Date.now()),
|
|
655
|
+
estimatedCostUsd: state.estimatedCostUsd,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
//# sourceMappingURL=manager.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup banner — the first thing users see when running swarm.
|
|
3
|
+
*/
|
|
4
|
+
/** Render the swarm startup banner. */
|
|
5
|
+
export declare function renderBanner(config: {
|
|
6
|
+
dir: string;
|
|
7
|
+
model: string;
|
|
8
|
+
provider: string;
|
|
9
|
+
agent: string;
|
|
10
|
+
routing: string;
|
|
11
|
+
query: string;
|
|
12
|
+
dryRun: boolean;
|
|
13
|
+
memorySize?: number;
|
|
14
|
+
}): void;
|