contextos-mcp 0.4.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.
Files changed (223) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -0
  3. package/bin/mcp.mjs +61 -0
  4. package/bin/swarm.mjs +45 -0
  5. package/dist/agents/aider.d.ts +12 -0
  6. package/dist/agents/aider.js +192 -0
  7. package/dist/agents/aider.js.map +1 -0
  8. package/dist/agents/claude-code.d.ts +9 -0
  9. package/dist/agents/claude-code.js +236 -0
  10. package/dist/agents/claude-code.js.map +1 -0
  11. package/dist/agents/codex.d.ts +14 -0
  12. package/dist/agents/codex.js +203 -0
  13. package/dist/agents/codex.js.map +1 -0
  14. package/dist/agents/direct-llm.d.ts +22 -0
  15. package/dist/agents/direct-llm.js +254 -0
  16. package/dist/agents/direct-llm.js.map +1 -0
  17. package/dist/agents/mock.d.ts +10 -0
  18. package/dist/agents/mock.js +170 -0
  19. package/dist/agents/mock.js.map +1 -0
  20. package/dist/agents/opencode.d.ts +23 -0
  21. package/dist/agents/opencode.js +631 -0
  22. package/dist/agents/opencode.js.map +1 -0
  23. package/dist/agents/provider.d.ts +11 -0
  24. package/dist/agents/provider.js +31 -0
  25. package/dist/agents/provider.js.map +1 -0
  26. package/dist/compression/compressor.d.ts +28 -0
  27. package/dist/compression/compressor.js +268 -0
  28. package/dist/compression/compressor.js.map +1 -0
  29. package/dist/config.d.ts +43 -0
  30. package/dist/config.js +172 -0
  31. package/dist/config.js.map +1 -0
  32. package/dist/contextos/loader.d.ts +46 -0
  33. package/dist/contextos/loader.js +120 -0
  34. package/dist/contextos/loader.js.map +1 -0
  35. package/dist/contextos/selector.d.ts +46 -0
  36. package/dist/contextos/selector.js +80 -0
  37. package/dist/contextos/selector.js.map +1 -0
  38. package/dist/core/action-dispatcher.d.ts +15 -0
  39. package/dist/core/action-dispatcher.js +28 -0
  40. package/dist/core/action-dispatcher.js.map +1 -0
  41. package/dist/core/action-schema.d.ts +72 -0
  42. package/dist/core/action-schema.js +86 -0
  43. package/dist/core/action-schema.js.map +1 -0
  44. package/dist/core/repl-interface.d.ts +48 -0
  45. package/dist/core/repl-interface.js +5 -0
  46. package/dist/core/repl-interface.js.map +1 -0
  47. package/dist/core/repl.d.ts +59 -0
  48. package/dist/core/repl.js +353 -0
  49. package/dist/core/repl.js.map +1 -0
  50. package/dist/core/rlm.d.ts +65 -0
  51. package/dist/core/rlm.js +564 -0
  52. package/dist/core/rlm.js.map +1 -0
  53. package/dist/core/runtime.py +470 -0
  54. package/dist/core/types.d.ts +265 -0
  55. package/dist/core/types.js +19 -0
  56. package/dist/core/types.js.map +1 -0
  57. package/dist/env.d.ts +10 -0
  58. package/dist/env.js +75 -0
  59. package/dist/env.js.map +1 -0
  60. package/dist/hooks/runner.d.ts +43 -0
  61. package/dist/hooks/runner.js +173 -0
  62. package/dist/hooks/runner.js.map +1 -0
  63. package/dist/labs/cli.d.ts +15 -0
  64. package/dist/labs/cli.js +295 -0
  65. package/dist/labs/cli.js.map +1 -0
  66. package/dist/labs/episodic-memory.d.ts +132 -0
  67. package/dist/labs/episodic-memory.js +391 -0
  68. package/dist/labs/episodic-memory.js.map +1 -0
  69. package/dist/labs/interactive-swarm.d.ts +20 -0
  70. package/dist/labs/interactive-swarm.js +1173 -0
  71. package/dist/labs/interactive-swarm.js.map +1 -0
  72. package/dist/labs/interactive.d.ts +10 -0
  73. package/dist/labs/interactive.js +2099 -0
  74. package/dist/labs/interactive.js.map +1 -0
  75. package/dist/labs/ts-analyzer.d.ts +74 -0
  76. package/dist/labs/ts-analyzer.js +379 -0
  77. package/dist/labs/ts-analyzer.js.map +1 -0
  78. package/dist/labs/viewer.d.ts +12 -0
  79. package/dist/labs/viewer.js +1284 -0
  80. package/dist/labs/viewer.js.map +1 -0
  81. package/dist/main.d.ts +15 -0
  82. package/dist/main.js +247 -0
  83. package/dist/main.js.map +1 -0
  84. package/dist/mcp/server.d.ts +16 -0
  85. package/dist/mcp/server.js +192775 -0
  86. package/dist/mcp/server.js.map +1 -0
  87. package/dist/mcp/session.d.ts +117 -0
  88. package/dist/mcp/session.js +450 -0
  89. package/dist/mcp/session.js.map +1 -0
  90. package/dist/mcp/state.d.ts +42 -0
  91. package/dist/mcp/state.js +537 -0
  92. package/dist/mcp/state.js.map +1 -0
  93. package/dist/mcp/tools/contextos.d.ts +17 -0
  94. package/dist/mcp/tools/contextos.js +583 -0
  95. package/dist/mcp/tools/contextos.js.map +1 -0
  96. package/dist/mcp/tools.d.ts +15 -0
  97. package/dist/mcp/tools.js +393 -0
  98. package/dist/mcp/tools.js.map +1 -0
  99. package/dist/orchestration/reviewer-gate.d.ts +43 -0
  100. package/dist/orchestration/reviewer-gate.js +280 -0
  101. package/dist/orchestration/reviewer-gate.js.map +1 -0
  102. package/dist/orchestration/reviewer-prompt.d.ts +18 -0
  103. package/dist/orchestration/reviewer-prompt.js +67 -0
  104. package/dist/orchestration/reviewer-prompt.js.map +1 -0
  105. package/dist/orchestration/verification-runner.d.ts +53 -0
  106. package/dist/orchestration/verification-runner.js +352 -0
  107. package/dist/orchestration/verification-runner.js.map +1 -0
  108. package/dist/plugins/adapter.d.ts +11 -0
  109. package/dist/plugins/adapter.js +11 -0
  110. package/dist/plugins/adapter.js.map +1 -0
  111. package/dist/plugins/core/archive.d.ts +9 -0
  112. package/dist/plugins/core/archive.js +128 -0
  113. package/dist/plugins/core/archive.js.map +1 -0
  114. package/dist/plugins/core/digest.d.ts +11 -0
  115. package/dist/plugins/core/digest.js +70 -0
  116. package/dist/plugins/core/digest.js.map +1 -0
  117. package/dist/plugins/core/grants.d.ts +22 -0
  118. package/dist/plugins/core/grants.js +90 -0
  119. package/dist/plugins/core/grants.js.map +1 -0
  120. package/dist/plugins/core/index.d.ts +10 -0
  121. package/dist/plugins/core/index.js +11 -0
  122. package/dist/plugins/core/index.js.map +1 -0
  123. package/dist/plugins/core/pinning.d.ts +15 -0
  124. package/dist/plugins/core/pinning.js +86 -0
  125. package/dist/plugins/core/pinning.js.map +1 -0
  126. package/dist/plugins/core/types.d.ts +105 -0
  127. package/dist/plugins/core/types.js +22 -0
  128. package/dist/plugins/core/types.js.map +1 -0
  129. package/dist/plugins/core/updater.d.ts +16 -0
  130. package/dist/plugins/core/updater.js +64 -0
  131. package/dist/plugins/core/updater.js.map +1 -0
  132. package/dist/plugins/loader.d.ts +65 -0
  133. package/dist/plugins/loader.js +110 -0
  134. package/dist/plugins/loader.js.map +1 -0
  135. package/dist/prompts/orchestrator.d.ts +6 -0
  136. package/dist/prompts/orchestrator.js +114 -0
  137. package/dist/prompts/orchestrator.js.map +1 -0
  138. package/dist/routing/model-resolver.d.ts +27 -0
  139. package/dist/routing/model-resolver.js +265 -0
  140. package/dist/routing/model-resolver.js.map +1 -0
  141. package/dist/routing/model-router.d.ts +130 -0
  142. package/dist/routing/model-router.js +515 -0
  143. package/dist/routing/model-router.js.map +1 -0
  144. package/dist/runtime/attestations.cjs +260 -0
  145. package/dist/runtime/reviewer-pipeline.cjs +319 -0
  146. package/dist/runtime/sandbox.cjs +468 -0
  147. package/dist/runtime/state-machine.cjs +885 -0
  148. package/dist/runtime/thread-store.cjs +175 -0
  149. package/dist/runtime/transactional-git.cjs +315 -0
  150. package/dist/runtime/verification-pipeline.cjs +435 -0
  151. package/dist/sandbox/execution.d.ts +165 -0
  152. package/dist/sandbox/execution.js +497 -0
  153. package/dist/sandbox/execution.js.map +1 -0
  154. package/dist/security/file-policy.d.ts +22 -0
  155. package/dist/security/file-policy.js +53 -0
  156. package/dist/security/file-policy.js.map +1 -0
  157. package/dist/security/repository-boundary.d.ts +21 -0
  158. package/dist/security/repository-boundary.js +119 -0
  159. package/dist/security/repository-boundary.js.map +1 -0
  160. package/dist/security/secret-filter.d.ts +36 -0
  161. package/dist/security/secret-filter.js +149 -0
  162. package/dist/security/secret-filter.js.map +1 -0
  163. package/dist/swarm.d.ts +14 -0
  164. package/dist/swarm.js +624 -0
  165. package/dist/swarm.js.map +1 -0
  166. package/dist/threads/cache.d.ts +64 -0
  167. package/dist/threads/cache.js +241 -0
  168. package/dist/threads/cache.js.map +1 -0
  169. package/dist/threads/manager.d.ts +94 -0
  170. package/dist/threads/manager.js +914 -0
  171. package/dist/threads/manager.js.map +1 -0
  172. package/dist/ui/banner.d.ts +14 -0
  173. package/dist/ui/banner.js +42 -0
  174. package/dist/ui/banner.js.map +1 -0
  175. package/dist/ui/dashboard.d.ts +33 -0
  176. package/dist/ui/dashboard.js +135 -0
  177. package/dist/ui/dashboard.js.map +1 -0
  178. package/dist/ui/index.d.ts +11 -0
  179. package/dist/ui/index.js +12 -0
  180. package/dist/ui/index.js.map +1 -0
  181. package/dist/ui/log.d.ts +39 -0
  182. package/dist/ui/log.js +126 -0
  183. package/dist/ui/log.js.map +1 -0
  184. package/dist/ui/onboarding.d.ts +14 -0
  185. package/dist/ui/onboarding.js +755 -0
  186. package/dist/ui/onboarding.js.map +1 -0
  187. package/dist/ui/run-log.d.ts +50 -0
  188. package/dist/ui/run-log.js +60 -0
  189. package/dist/ui/run-log.js.map +1 -0
  190. package/dist/ui/spinner.d.ts +41 -0
  191. package/dist/ui/spinner.js +172 -0
  192. package/dist/ui/spinner.js.map +1 -0
  193. package/dist/ui/streaming-feed.d.ts +72 -0
  194. package/dist/ui/streaming-feed.js +386 -0
  195. package/dist/ui/streaming-feed.js.map +1 -0
  196. package/dist/ui/summary.d.ts +18 -0
  197. package/dist/ui/summary.js +113 -0
  198. package/dist/ui/summary.js.map +1 -0
  199. package/dist/ui/text-input.d.ts +19 -0
  200. package/dist/ui/text-input.js +266 -0
  201. package/dist/ui/text-input.js.map +1 -0
  202. package/dist/ui/theme.d.ts +63 -0
  203. package/dist/ui/theme.js +97 -0
  204. package/dist/ui/theme.js.map +1 -0
  205. package/dist/utils/command-exists.d.ts +11 -0
  206. package/dist/utils/command-exists.js +41 -0
  207. package/dist/utils/command-exists.js.map +1 -0
  208. package/dist/utils/process-security.d.ts +19 -0
  209. package/dist/utils/process-security.js +90 -0
  210. package/dist/utils/process-security.js.map +1 -0
  211. package/dist/utils/rolling-buffer.d.ts +16 -0
  212. package/dist/utils/rolling-buffer.js +30 -0
  213. package/dist/utils/rolling-buffer.js.map +1 -0
  214. package/dist/utils/safe-spawn.d.ts +13 -0
  215. package/dist/utils/safe-spawn.js +49 -0
  216. package/dist/utils/safe-spawn.js.map +1 -0
  217. package/dist/worktree/manager.d.ts +69 -0
  218. package/dist/worktree/manager.js +593 -0
  219. package/dist/worktree/manager.js.map +1 -0
  220. package/dist/worktree/merge.d.ts +58 -0
  221. package/dist/worktree/merge.js +389 -0
  222. package/dist/worktree/merge.js.map +1 -0
  223. package/package.json +63 -0
@@ -0,0 +1,914 @@
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 { execFile } from "node:child_process";
14
+ import { createHash, randomBytes } from "node:crypto";
15
+ import * as path from "node:path";
16
+ import { promisify } from "node:util";
17
+ import { getAgent, listAgents } from "../agents/provider.js";
18
+ const execFileAsync = promisify(execFile);
19
+ import { compressResult } from "../compression/compressor.js";
20
+ import { MODEL_PRICING as PRICING } from "../core/types.js";
21
+ import { evaluateReviewerGate } from "../orchestration/reviewer-gate.js";
22
+ import { runWorktreeVerification } from "../orchestration/verification-runner.js";
23
+ import { AGENT_CAPABILITIES } from "../routing/model-router.js";
24
+ import { normalizeGitPath, WorktreeManager } from "../worktree/manager.js";
25
+ import { ThreadCache } from "./cache.js";
26
+ // ── Async Semaphore ─────────────────────────────────────────────────────────
27
+ /**
28
+ * Promise-based semaphore for concurrency control.
29
+ * acquire() resolves when a slot is available, release() frees a slot.
30
+ */
31
+ export class AsyncSemaphore {
32
+ current = 0;
33
+ max;
34
+ waiters = [];
35
+ constructor(max) {
36
+ this.max = max;
37
+ }
38
+ async acquire() {
39
+ if (this.current < this.max) {
40
+ this.current++;
41
+ return;
42
+ }
43
+ await new Promise((resolve) => {
44
+ this.waiters.push(resolve);
45
+ });
46
+ // current already accounts for this slot — release() transferred it directly
47
+ }
48
+ release() {
49
+ const next = this.waiters.shift();
50
+ if (next) {
51
+ // Transfer the slot directly to the waiter (current stays the same)
52
+ next();
53
+ }
54
+ else {
55
+ if (this.current <= 0)
56
+ return; // Guard against double-release
57
+ this.current--;
58
+ }
59
+ }
60
+ get activeCount() {
61
+ return this.current;
62
+ }
63
+ get waitingCount() {
64
+ return this.waiters.length;
65
+ }
66
+ }
67
+ // ── Budget Tracker ──────────────────────────────────────────────────────────
68
+ class BudgetTracker {
69
+ totalSpent = 0;
70
+ totalReserved = 0;
71
+ threadCosts = new Map();
72
+ reservations = new Map();
73
+ sessionLimit;
74
+ perThreadLimit;
75
+ totalInputTokens = 0;
76
+ totalOutputTokens = 0;
77
+ actualCostCount = 0;
78
+ estimatedCostCount = 0;
79
+ constructor(sessionLimit, perThreadLimit) {
80
+ this.sessionLimit = sessionLimit;
81
+ this.perThreadLimit = perThreadLimit;
82
+ }
83
+ /** Estimate cost for a thread based on model and assumed token usage. */
84
+ estimateThreadCost(model) {
85
+ const modelName = model.includes("/") ? model.split("/").pop() : model;
86
+ const pricing = PRICING[modelName];
87
+ if (!pricing)
88
+ return 0.05;
89
+ // Assume ~4K input tokens, ~2K output tokens per thread execution
90
+ const inputTokens = 4000;
91
+ const outputTokens = 2000;
92
+ return (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;
93
+ }
94
+ /**
95
+ * Calculate actual cost from real token usage.
96
+ * Returns null if pricing for the model is unknown.
97
+ */
98
+ calculateActualCost(model, usage) {
99
+ const modelName = model.includes("/") ? model.split("/").pop() : model;
100
+ const pricing = PRICING[modelName];
101
+ if (!pricing)
102
+ return null;
103
+ return (usage.inputTokens * pricing.input + usage.outputTokens * pricing.output) / 1_000_000;
104
+ }
105
+ /** Check if we can afford to spawn a thread. */
106
+ canAfford(model) {
107
+ const estimate = this.estimateThreadCost(model);
108
+ if (this.totalSpent + this.totalReserved + estimate > this.sessionLimit) {
109
+ return {
110
+ allowed: false,
111
+ reason: `Session budget exceeded: $${this.totalSpent.toFixed(4)} spent and $${this.totalReserved.toFixed(4)} reserved of $${this.sessionLimit.toFixed(2)} limit (next thread ~$${estimate.toFixed(4)})`,
112
+ };
113
+ }
114
+ if (estimate > this.perThreadLimit) {
115
+ return {
116
+ allowed: false,
117
+ reason: `Thread cost estimate ($${estimate.toFixed(4)}) exceeds per-thread limit ($${this.perThreadLimit.toFixed(2)})`,
118
+ };
119
+ }
120
+ return { allowed: true };
121
+ }
122
+ /** Atomically reserve the expected cost before dispatching a thread attempt. */
123
+ reserve(threadId, model) {
124
+ if (this.reservations.has(threadId))
125
+ return { allowed: true };
126
+ const check = this.canAfford(model);
127
+ if (!check.allowed)
128
+ return check;
129
+ const estimate = this.estimateThreadCost(model);
130
+ this.reservations.set(threadId, estimate);
131
+ this.totalReserved += estimate;
132
+ return { allowed: true };
133
+ }
134
+ releaseReservation(threadId) {
135
+ const reserved = this.reservations.get(threadId);
136
+ if (reserved === undefined)
137
+ return;
138
+ this.totalReserved = Math.max(0, this.totalReserved - reserved);
139
+ this.reservations.delete(threadId);
140
+ }
141
+ /**
142
+ * Record cost for a completed thread.
143
+ * Uses actual usage when available, falls back to estimate.
144
+ */
145
+ recordCost(threadId, model, usage) {
146
+ this.releaseReservation(threadId);
147
+ let cost;
148
+ let isEstimate;
149
+ if (usage && (usage.inputTokens > 0 || usage.outputTokens > 0)) {
150
+ // Use real token counts
151
+ const actual = this.calculateActualCost(model, usage);
152
+ if (actual !== null) {
153
+ cost = actual;
154
+ isEstimate = false;
155
+ this.actualCostCount++;
156
+ }
157
+ else {
158
+ // Have tokens but no pricing — estimate
159
+ cost = this.estimateThreadCost(model);
160
+ isEstimate = true;
161
+ this.estimatedCostCount++;
162
+ }
163
+ this.totalInputTokens += usage.inputTokens;
164
+ this.totalOutputTokens += usage.outputTokens;
165
+ }
166
+ else {
167
+ // No usage data — estimate
168
+ cost = this.estimateThreadCost(model);
169
+ isEstimate = true;
170
+ this.estimatedCostCount++;
171
+ }
172
+ this.threadCosts.set(threadId, (this.threadCosts.get(threadId) || 0) + cost);
173
+ this.totalSpent += cost;
174
+ return { cost, isEstimate };
175
+ }
176
+ get spent() {
177
+ return this.totalSpent;
178
+ }
179
+ getState() {
180
+ return {
181
+ totalSpentUsd: this.totalSpent,
182
+ totalReservedUsd: this.totalReserved,
183
+ threadCosts: new Map(this.threadCosts),
184
+ sessionLimitUsd: this.sessionLimit,
185
+ perThreadLimitUsd: this.perThreadLimit,
186
+ totalTokens: {
187
+ input: this.totalInputTokens,
188
+ output: this.totalOutputTokens,
189
+ },
190
+ actualCostThreads: this.actualCostCount,
191
+ estimatedCostThreads: this.estimatedCostCount,
192
+ };
193
+ }
194
+ }
195
+ // ── Error Classification ────────────────────────────────────────────────────
196
+ /** Patterns that indicate transient/retryable errors. */
197
+ const RETRYABLE_PATTERNS = [
198
+ /timeout/i,
199
+ /timed?\s*out/i,
200
+ /ECONNRESET/i,
201
+ /ECONNREFUSED/i,
202
+ /EPIPE/i,
203
+ /rate limit/i,
204
+ /429/,
205
+ /503/,
206
+ /502/,
207
+ /500/,
208
+ /too many requests/i,
209
+ /temporarily unavailable/i,
210
+ /server error/i,
211
+ /overloaded/i,
212
+ /capacity/i,
213
+ /lock file/i,
214
+ /index\.lock/i,
215
+ ];
216
+ /** Patterns that indicate permanent/fatal errors (don't retry). */
217
+ const FATAL_PATTERNS = [
218
+ /authentication/i,
219
+ /unauthorized/i,
220
+ /forbidden/i,
221
+ /invalid api key/i,
222
+ /model not found/i,
223
+ /permission denied/i,
224
+ /quota exceeded/i,
225
+ /billing/i,
226
+ /NOT_CONFIGURED/i,
227
+ ];
228
+ /** Classify an error as retryable or fatal. Default: retryable (optimistic). */
229
+ function isRetryableError(error) {
230
+ // Check fatal patterns first (takes priority)
231
+ if (FATAL_PATTERNS.some((p) => p.test(error)))
232
+ return false;
233
+ // Check retryable patterns
234
+ if (RETRYABLE_PATTERNS.some((p) => p.test(error)))
235
+ return true;
236
+ // Default: retryable (be optimistic — the retry might work with a different agent)
237
+ return true;
238
+ }
239
+ /** Calculate exponential backoff delay with jitter. */
240
+ function backoffDelay(attempt, baseMs = 1000) {
241
+ // Exponential: 1s, 2s, 4s, 8s... capped at 30s
242
+ const exponential = Math.min(baseMs * 2 ** (attempt - 1), 30000);
243
+ // Add jitter (±25%)
244
+ const jitter = exponential * 0.25 * (Math.random() * 2 - 1);
245
+ return Math.max(100, exponential + jitter);
246
+ }
247
+ /**
248
+ * Pick an alternative agent/model combo for retry.
249
+ * Avoids the agent that just failed and prefers agents with different default models.
250
+ * Uses attempt number to cycle through alternatives on subsequent retries.
251
+ */
252
+ function pickAlternativeAgent(failedAgent, failedModel, _config, attempt = 1) {
253
+ const available = listAgents().filter((name) => name !== failedAgent && name !== "mock");
254
+ if (available.length === 0)
255
+ return null;
256
+ // Build candidates, preferring agents with different default models
257
+ const candidates = [];
258
+ for (const name of available) {
259
+ const cap = AGENT_CAPABILITIES[name];
260
+ if (cap && cap.defaultModel !== failedModel) {
261
+ candidates.push({ agent: name, model: cap.defaultModel });
262
+ }
263
+ }
264
+ // Also include agents with same model (but different agent) as lower priority
265
+ for (const name of available) {
266
+ const cap = AGENT_CAPABILITIES[name];
267
+ if (cap && cap.defaultModel === failedModel) {
268
+ candidates.push({ agent: name, model: cap.defaultModel });
269
+ }
270
+ }
271
+ // If no capabilities known, add all available with failedModel
272
+ if (candidates.length === 0) {
273
+ for (const name of available) {
274
+ candidates.push({ agent: name, model: failedModel });
275
+ }
276
+ }
277
+ if (candidates.length === 0)
278
+ return null;
279
+ // Cycle through candidates based on attempt number
280
+ const idx = (attempt - 1) % candidates.length;
281
+ return candidates[idx];
282
+ }
283
+ export class ThreadManager {
284
+ repoRoot;
285
+ threads = new Map();
286
+ totalSpawned = 0;
287
+ semaphore;
288
+ worktreeManager;
289
+ config;
290
+ budget;
291
+ threadCache;
292
+ episodicMemory;
293
+ onThreadProgress;
294
+ onThreadOutput;
295
+ sessionAbort;
296
+ threadAbortControllers = new Map();
297
+ constructor(repoRoot, config, onThreadProgress, sessionAbort) {
298
+ this.repoRoot = path.resolve(repoRoot);
299
+ this.config = config;
300
+ this.semaphore = new AsyncSemaphore(config.max_threads);
301
+ this.worktreeManager = new WorktreeManager(repoRoot, config.worktree_base_dir);
302
+ this.budget = new BudgetTracker(config.max_session_budget_usd, config.max_thread_budget_usd);
303
+ this.threadCache = new ThreadCache(100, config.thread_cache_persist ? config.thread_cache_dir : undefined, config.thread_cache_ttl_hours);
304
+ this.onThreadProgress = onThreadProgress;
305
+ this.sessionAbort = sessionAbort;
306
+ }
307
+ /** Set the output streaming callback for live agent output. */
308
+ setThreadOutputCallback(cb) {
309
+ this.onThreadOutput = cb;
310
+ }
311
+ /** Set the episodic memory store for recording thread outcomes. */
312
+ setEpisodicMemory(memory) {
313
+ this.episodicMemory = memory;
314
+ }
315
+ async init() {
316
+ await this.worktreeManager.init();
317
+ await this.threadCache.init();
318
+ }
319
+ async getCurrentCommitSha() {
320
+ try {
321
+ const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: this.repoRoot });
322
+ return stdout.trim();
323
+ }
324
+ catch {
325
+ return "";
326
+ }
327
+ }
328
+ /**
329
+ * Spawn a thread — creates a worktree, runs the agent, returns compressed result.
330
+ * Checks the subthread cache first; on cache hit, returns immediately (Slate-style reuse).
331
+ * Retries up to config.thread_retries times on failure.
332
+ * Error-isolated: a failure here never throws — always returns a CompressedResult.
333
+ */
334
+ async spawnThread(threadConfig) {
335
+ // Subthread cache lookup — return cached result for identical tasks scoped by repo and commit
336
+ const commitSha = await this.getCurrentCommitSha();
337
+ const cacheAgent = threadConfig.agent.backend || this.config.default_agent;
338
+ const cacheModel = threadConfig.agent.model || this.config.default_model;
339
+ const cacheFiles = threadConfig.taskBrief?.writeScope
340
+ ? [...threadConfig.taskBrief.writeScope]
341
+ : threadConfig.writeScope?.length
342
+ ? [...threadConfig.writeScope]
343
+ : [];
344
+ const cached = this.threadCache.get(threadConfig.task, cacheFiles, cacheAgent, cacheModel, this.repoRoot, commitSha);
345
+ if (cached) {
346
+ const threadId = threadConfig.id || randomBytes(6).toString("hex");
347
+ this.onThreadProgress?.(threadId, "completed", "cache hit");
348
+ return cached;
349
+ }
350
+ // Enforce total thread limit
351
+ if (this.totalSpawned >= this.config.max_total_threads) {
352
+ return {
353
+ success: false,
354
+ summary: `Thread limit reached (${this.config.max_total_threads} max per session)`,
355
+ filesChanged: [],
356
+ diffStats: "",
357
+ durationMs: 0,
358
+ estimatedCostUsd: 0,
359
+ };
360
+ }
361
+ // Check session abort
362
+ if (this.sessionAbort?.aborted) {
363
+ return {
364
+ success: false,
365
+ summary: "Session aborted",
366
+ filesChanged: [],
367
+ diffStats: "",
368
+ durationMs: 0,
369
+ estimatedCostUsd: 0,
370
+ };
371
+ }
372
+ const threadId = threadConfig.id || randomBytes(6).toString("hex");
373
+ const maxAttempts = this.config.thread_retries + 1;
374
+ const writeScope = threadConfig.taskBrief?.writeScope || threadConfig.writeScope || [];
375
+ if (writeScope.length === 0) {
376
+ throw new Error("ScopeViolationError: task must have an explicit writeScope. Implicit '.' is prohibited.");
377
+ }
378
+ const writeScopeDeny = threadConfig.taskBrief?.writeScopeDeny || threadConfig.writeScopeDeny || [];
379
+ const allowRepositoryWide = threadConfig.taskBrief?.allowRepositoryWide ??
380
+ threadConfig.allowRepositoryWide ??
381
+ this.config.allow_repository_wide ??
382
+ true;
383
+ if (writeScope.some((scope) => normalizeGitPath(scope) === ".") && allowRepositoryWide === false) {
384
+ throw new Error("ScopeViolationError: repository-wide writeScope '.' requires allowRepositoryWide: true.");
385
+ }
386
+ const taskBrief = Object.freeze({
387
+ taskId: threadId,
388
+ baseSha: commitSha,
389
+ objective: threadConfig.taskBrief?.objective || threadConfig.task,
390
+ writeScope: Object.freeze([...writeScope]),
391
+ writeScopeDeny: Object.freeze([...writeScopeDeny]),
392
+ allowRepositoryWide,
393
+ focusFiles: Object.freeze([...(threadConfig.focusFiles || threadConfig.files || [])]),
394
+ testCommand: threadConfig.taskBrief?.testCommand || threadConfig.testCommand || "",
395
+ verificationSpec: threadConfig.taskBrief?.verificationSpec || threadConfig.verificationSpec,
396
+ expectedResult: threadConfig.taskBrief?.expectedResult || "Task completes successfully",
397
+ maxAttempts: threadConfig.taskBrief?.maxAttempts || maxAttempts,
398
+ sandboxMode: threadConfig.taskBrief?.sandboxMode || threadConfig.sandboxMode || "host-unsafe",
399
+ });
400
+ const normalizedConfig = {
401
+ ...threadConfig,
402
+ id: threadId,
403
+ files: [...(threadConfig.focusFiles || [])],
404
+ writeScope: taskBrief.writeScope,
405
+ writeScopeDeny: taskBrief.writeScopeDeny,
406
+ allowRepositoryWide: taskBrief.allowRepositoryWide,
407
+ taskBrief,
408
+ };
409
+ const state = {
410
+ id: threadId,
411
+ config: normalizedConfig,
412
+ status: "pending",
413
+ phase: "queued",
414
+ startedAt: Date.now(),
415
+ attempt: 0,
416
+ maxAttempts,
417
+ estimatedCostUsd: 0,
418
+ taskBrief,
419
+ verification: taskBrief.testCommand ? "PENDING" : "NOT_CONFIGURED",
420
+ scopeViolation: false,
421
+ };
422
+ this.threads.set(threadId, state);
423
+ this.totalSpawned++;
424
+ // Create per-thread abort controller (linked to session abort)
425
+ const threadAc = new AbortController();
426
+ this.threadAbortControllers.set(threadId, threadAc);
427
+ const onSessionAbort = () => threadAc.abort();
428
+ if (this.sessionAbort) {
429
+ if (this.sessionAbort.aborted) {
430
+ threadAc.abort();
431
+ }
432
+ else {
433
+ this.sessionAbort.addEventListener("abort", onSessionAbort, { once: true });
434
+ }
435
+ }
436
+ // Retry loop with exponential backoff and agent re-routing
437
+ let lastResult;
438
+ let currentConfig = normalizedConfig;
439
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
440
+ state.attempt = attempt;
441
+ if (attempt > 1) {
442
+ // Exponential backoff before retry (abort-aware)
443
+ const delay = backoffDelay(attempt - 1);
444
+ state.phase = "retrying";
445
+ this.onThreadProgress?.(threadId, "retrying", `attempt ${attempt}/${maxAttempts}, backoff ${(delay / 1000).toFixed(1)}s`);
446
+ // Race the delay against the abort signal so cancellation is immediate
447
+ await new Promise((resolve) => {
448
+ const timer = setTimeout(resolve, delay);
449
+ if (threadAc.signal.aborted) {
450
+ clearTimeout(timer);
451
+ resolve();
452
+ return;
453
+ }
454
+ const onAbort = () => {
455
+ clearTimeout(timer);
456
+ resolve();
457
+ };
458
+ threadAc.signal.addEventListener("abort", onAbort, { once: true });
459
+ });
460
+ if (threadAc.signal.aborted)
461
+ break;
462
+ }
463
+ const attemptModel = currentConfig.agent.model || this.config.default_model;
464
+ const reservation = this.budget.reserve(threadId, attemptModel);
465
+ if (!reservation.allowed) {
466
+ state.status = "failed";
467
+ state.phase = "failed";
468
+ state.error = `Budget exceeded: ${reservation.reason}`;
469
+ state.completedAt = Date.now();
470
+ lastResult = this.failResult(state, state.error);
471
+ break;
472
+ }
473
+ lastResult = await this.executeThread(threadId, currentConfig, state, threadAc.signal);
474
+ if (lastResult.success || threadAc.signal.aborted) {
475
+ break;
476
+ }
477
+ // Don't retry on cancellation or budget issues
478
+ if (state.status === "cancelled")
479
+ break;
480
+ // Classify the error — don't retry fatal errors
481
+ const errorMsg = state.error || lastResult.summary || "";
482
+ if (!isRetryableError(errorMsg)) {
483
+ this.onThreadProgress?.(threadId, "failed", `fatal error, not retrying: ${errorMsg.slice(0, 80)}`);
484
+ break;
485
+ }
486
+ // Try re-routing to a different agent/model on retry
487
+ if (attempt < maxAttempts) {
488
+ const currentAgent = currentConfig.agent.backend || this.config.default_agent;
489
+ const currentModel = currentConfig.agent.model || this.config.default_model;
490
+ const alt = pickAlternativeAgent(currentAgent, currentModel, this.config, attempt);
491
+ const reviewFeedback = state.review?.summary
492
+ ? `\n\nPrevious attempt was rejected by reviewer:\n${state.review.summary}\nPlease fix these issues and stay strictly within declared writeScope.`
493
+ : state.error
494
+ ? `\n\nPrevious attempt error: ${state.error}`
495
+ : "";
496
+ currentConfig = {
497
+ ...currentConfig,
498
+ context: `${currentConfig.context || ""}${reviewFeedback}`.trim(),
499
+ agent: alt ? { backend: alt.agent, model: alt.model } : currentConfig.agent,
500
+ };
501
+ if (alt) {
502
+ this.onThreadProgress?.(threadId, "retrying", `re-routing: ${currentAgent} → ${alt.agent}`);
503
+ }
504
+ }
505
+ }
506
+ this.sessionAbort?.removeEventListener("abort", onSessionAbort);
507
+ this.threadAbortControllers.delete(threadId);
508
+ this.budget.releaseReservation(threadId);
509
+ return lastResult || this.failResult(state, "Thread cancelled");
510
+ }
511
+ /**
512
+ * Execute a single thread attempt. Acquires semaphore, creates worktree,
513
+ * runs agent, captures diff, compresses result.
514
+ */
515
+ async executeThread(threadId, threadConfig, state, signal) {
516
+ // Wait for a concurrency slot
517
+ state.phase = "queued";
518
+ this.onThreadProgress?.(threadId, "queued", this.semaphore.waitingCount > 0 ? `waiting (${this.semaphore.waitingCount} ahead)` : undefined);
519
+ await this.semaphore.acquire();
520
+ try {
521
+ if (signal.aborted) {
522
+ this.budget.releaseReservation(threadId);
523
+ state.status = "cancelled";
524
+ state.phase = "cancelled";
525
+ state.completedAt = Date.now();
526
+ return this.failResult(state, "Thread cancelled before start");
527
+ }
528
+ state.status = "running";
529
+ // Create worktree
530
+ state.phase = "creating_worktree";
531
+ this.onThreadProgress?.(threadId, "creating_worktree");
532
+ const wtInfo = await this.worktreeManager.create(threadId);
533
+ state.worktreePath = wtInfo.path;
534
+ state.branchName = wtInfo.branch;
535
+ // Run agent
536
+ state.phase = "agent_running";
537
+ if (state.verificationAttestation)
538
+ state.verificationAttestation.status = "STALE";
539
+ if (state.reviewAttestation)
540
+ state.reviewAttestation.status = "STALE";
541
+ state.verification = "STALE";
542
+ state.verificationStatus = "STALE";
543
+ state.reviewStatus = "STALE";
544
+ state.mergeStatus = "BLOCKED";
545
+ this.onThreadProgress?.(threadId, "agent_running");
546
+ const agent = getAgent(threadConfig.agent.backend || this.config.default_agent);
547
+ let fullTask = threadConfig.task;
548
+ if (threadConfig.context) {
549
+ fullTask = `Context:\n${threadConfig.context}\n\nTask:\n${threadConfig.task}`;
550
+ }
551
+ // Combine thread timeout with cancellation signal
552
+ const threadTimeoutMs = this.config.thread_timeout_ms;
553
+ const timeoutAc = new AbortController();
554
+ const timeoutTimer = setTimeout(() => timeoutAc.abort(), threadTimeoutMs);
555
+ const combinedAc = new AbortController();
556
+ const onAbort = () => combinedAc.abort();
557
+ signal.addEventListener("abort", onAbort, { once: true });
558
+ const timeoutSignal = timeoutAc.signal;
559
+ timeoutSignal.addEventListener("abort", onAbort, { once: true });
560
+ let agentResult;
561
+ try {
562
+ agentResult = await agent.run({
563
+ task: fullTask,
564
+ workDir: wtInfo.path,
565
+ model: threadConfig.agent.model || this.config.default_model,
566
+ files: threadConfig.files,
567
+ signal: combinedAc.signal,
568
+ onOutput: this.onThreadOutput ? (chunk) => this.onThreadOutput(threadId, chunk) : undefined,
569
+ });
570
+ }
571
+ finally {
572
+ signal.removeEventListener("abort", onAbort);
573
+ timeoutSignal.removeEventListener("abort", onAbort);
574
+ clearTimeout(timeoutTimer);
575
+ }
576
+ if (signal.aborted) {
577
+ this.budget.releaseReservation(threadId);
578
+ state.status = "cancelled";
579
+ state.phase = "cancelled";
580
+ state.completedAt = Date.now();
581
+ await this.destroyWorktree(threadId);
582
+ return this.failResult(state, "Thread cancelled during execution");
583
+ }
584
+ // Capture diff
585
+ state.phase = "capturing_diff";
586
+ this.onThreadProgress?.(threadId, "capturing_diff");
587
+ const diff = await this.worktreeManager.getDiff(threadId);
588
+ const diffStats = await this.worktreeManager.getDiffStats(threadId);
589
+ const filesChanged = await this.worktreeManager.getChangedFiles(threadId);
590
+ try {
591
+ await this.worktreeManager.assertWriteScope(threadId, state.taskBrief.baseSha, state.taskBrief.writeScope, state.taskBrief.writeScopeDeny);
592
+ }
593
+ catch (error) {
594
+ if (error instanceof Error && error.name === "ScopeViolationError")
595
+ state.scopeViolation = true;
596
+ throw error;
597
+ }
598
+ if (filesChanged.length > 0) {
599
+ await this.worktreeManager.commit(threadId, `swarm: ${threadConfig.task.slice(0, 72)}`, state.taskBrief.writeScope, state.taskBrief.writeScopeDeny);
600
+ }
601
+ const candidateHeadSha = await this.worktreeManager.getHeadSha(threadId);
602
+ const candidateDiff = await this.worktreeManager.getCandidateDiff(threadId, state.taskBrief.baseSha);
603
+ const diffSha256 = createHash("sha256").update(candidateDiff).digest("hex");
604
+ const repoFingerprint = this.worktreeManager.getRepositoryFingerprint();
605
+ const writeScope = state.taskBrief?.writeScope || [];
606
+ const writeScopeDeny = state.taskBrief?.writeScopeDeny || [];
607
+ const scopeSha256 = createHash("sha256")
608
+ .update(JSON.stringify({
609
+ writeScope: [...writeScope].sort(),
610
+ writeScopeDeny: [...writeScopeDeny].sort(),
611
+ }))
612
+ .digest("hex");
613
+ const candidateSubject = {
614
+ repositoryFingerprint: repoFingerprint,
615
+ baseSha: state.taskBrief.baseSha,
616
+ headSha: candidateHeadSha,
617
+ diffSha256,
618
+ scopeSha256,
619
+ };
620
+ // Automated test verification (if taskBrief specifies testCommand or verificationSpec)
621
+ let verificationVerdict = "NOT_CONFIGURED";
622
+ let verificationOutput = "";
623
+ if (state.taskBrief?.testCommand || state.taskBrief?.verificationSpec) {
624
+ const verifyTarget = state.taskBrief.verificationSpec || state.taskBrief.testCommand;
625
+ const displayTarget = typeof verifyTarget === "string" ? verifyTarget : `${verifyTarget.executable} ${verifyTarget.args.join(" ")}`;
626
+ state.phase = "verifying";
627
+ this.onThreadProgress?.(threadId, "verifying", displayTarget);
628
+ try {
629
+ const verifyRes = await runWorktreeVerification(wtInfo.path, verifyTarget, {
630
+ expectedHeadSha: candidateSubject.headSha,
631
+ sandboxMode: state.taskBrief?.sandboxMode,
632
+ });
633
+ verificationOutput = verifyRes.output;
634
+ verificationVerdict = verifyRes.verdict;
635
+ state.verificationAttestation = {
636
+ schemaVersion: 1,
637
+ status: verificationVerdict,
638
+ subject: { ...candidateSubject },
639
+ evidence: {
640
+ exitCode: verifyRes.exitCode,
641
+ signal: null,
642
+ outputSha256: createHash("sha256").update(verificationOutput).digest("hex"),
643
+ redactedPreview: verificationOutput.substring(0, 100),
644
+ },
645
+ runnerMode: verifyRes.runnerMode || "host-unsafe",
646
+ };
647
+ state.verification = verificationVerdict;
648
+ state.verificationStatus = verificationVerdict;
649
+ if (verifyRes.verdict !== "PASS") {
650
+ state.error = `Verification failed: ${verifyRes.output}`;
651
+ }
652
+ }
653
+ catch (verifyErr) {
654
+ verificationVerdict = "ERROR";
655
+ state.verificationAttestation = {
656
+ schemaVersion: 1,
657
+ status: "ERROR",
658
+ subject: { ...candidateSubject },
659
+ evidence: {
660
+ exitCode: null,
661
+ signal: null,
662
+ outputSha256: createHash("sha256").update(String(verifyErr)).digest("hex"),
663
+ redactedPreview: String(verifyErr).substring(0, 100),
664
+ },
665
+ runnerMode: "host-unsafe",
666
+ };
667
+ state.verification = "ERROR";
668
+ state.verificationStatus = "ERROR";
669
+ state.error = `Verification error: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`;
670
+ }
671
+ }
672
+ else {
673
+ verificationVerdict = "NOT_CONFIGURED";
674
+ state.verificationAttestation = {
675
+ schemaVersion: 1,
676
+ status: "NOT_CONFIGURED",
677
+ subject: { ...candidateSubject },
678
+ runnerMode: "host-unsafe",
679
+ };
680
+ state.verification = "NOT_CONFIGURED";
681
+ state.verificationStatus = "NOT_CONFIGURED";
682
+ }
683
+ // Independent Reviewer Gate (Dual Verdict)
684
+ state.phase = "reviewing";
685
+ this.onThreadProgress?.(threadId, "reviewing");
686
+ const reviewVerdict = await evaluateReviewerGate({
687
+ taskBrief: state.taskBrief,
688
+ diff,
689
+ filesChanged,
690
+ verificationVerdict,
691
+ testOutput: verificationOutput,
692
+ implementerAgent: threadConfig.agent.backend || this.config.default_agent,
693
+ implementerModel: threadConfig.agent.model || this.config.default_model,
694
+ workDir: wtInfo.path,
695
+ repoRoot: this.repoRoot,
696
+ });
697
+ state.review = reviewVerdict;
698
+ let reviewStatus = reviewVerdict.specCompliance === "PASS" && reviewVerdict.codeQuality === "PASS" ? "PASS" : "FAIL";
699
+ if (reviewVerdict.specCompliance === "NOT_CONFIGURED" && reviewVerdict.codeQuality === "NOT_CONFIGURED") {
700
+ reviewStatus = "NOT_CONFIGURED";
701
+ }
702
+ else if (reviewVerdict.specCompliance === "ERROR" || reviewVerdict.codeQuality === "ERROR") {
703
+ reviewStatus = "ERROR";
704
+ }
705
+ else if (reviewVerdict.specCompliance === "UNAVAILABLE" || reviewVerdict.codeQuality === "UNAVAILABLE") {
706
+ reviewStatus = "UNAVAILABLE";
707
+ }
708
+ else if (reviewVerdict.specCompliance === "MALFORMED" || reviewVerdict.codeQuality === "MALFORMED") {
709
+ reviewStatus = "MALFORMED";
710
+ }
711
+ else if (reviewVerdict.specCompliance === "TIMEOUT" || reviewVerdict.codeQuality === "TIMEOUT") {
712
+ reviewStatus = "TIMEOUT";
713
+ }
714
+ state.reviewAttestation = {
715
+ schemaVersion: 1,
716
+ status: reviewStatus,
717
+ llmVerdict: {
718
+ specCompliance: reviewVerdict.specCompliance,
719
+ codeQuality: reviewVerdict.codeQuality,
720
+ summary: reviewVerdict.summary,
721
+ },
722
+ subject: { ...candidateSubject },
723
+ implementerExecutionId: threadId,
724
+ reviewerExecutionId: reviewVerdict.reviewerId,
725
+ provider: threadConfig.agent.backend || this.config.default_agent || "contextos",
726
+ model: threadConfig.agent.model || this.config.default_model || "unknown",
727
+ independenceLevel: "separate_process",
728
+ rawOutputDigest: createHash("sha256").update(JSON.stringify(reviewVerdict)).digest("hex"),
729
+ retries: 0,
730
+ };
731
+ state.reviewStatus = reviewStatus;
732
+ const reviewFailed = reviewVerdict.specCompliance !== "PASS" || reviewVerdict.codeQuality !== "PASS";
733
+ if (reviewFailed) {
734
+ const reason = `Review failed [spec=${reviewVerdict.specCompliance}, quality=${reviewVerdict.codeQuality}]: ${reviewVerdict.summary}`;
735
+ state.error = reason;
736
+ if (state.attempt < state.maxAttempts) {
737
+ throw new Error(reason);
738
+ }
739
+ }
740
+ // Compress
741
+ state.phase = "compressing";
742
+ this.onThreadProgress?.(threadId, "compressing");
743
+ const compressed = await compressResult({
744
+ agentOutput: agentResult.output,
745
+ diff,
746
+ diffStats,
747
+ filesChanged,
748
+ success: agentResult.success,
749
+ durationMs: agentResult.durationMs,
750
+ error: agentResult.error,
751
+ }, this.config.compression_strategy, this.config.compression_max_tokens);
752
+ // Record cost — uses real usage when available, falls back to estimate
753
+ const model = threadConfig.agent.model || this.config.default_model;
754
+ const { cost, isEstimate } = this.budget.recordCost(threadId, model, agentResult.usage);
755
+ state.estimatedCostUsd = cost;
756
+ const costLabel = isEstimate ? `~$${cost.toFixed(4)}` : `$${cost.toFixed(4)}`;
757
+ const usageLabel = agentResult.usage
758
+ ? ` (${agentResult.usage.inputTokens}+${agentResult.usage.outputTokens} tokens)`
759
+ : "";
760
+ const verificationOk = verificationVerdict === "PASS" ||
761
+ verificationVerdict === "NOT_CONFIGURED" ||
762
+ verificationVerdict === "NOT_APPLICABLE";
763
+ const overallSuccess = agentResult.success && !reviewFailed && verificationOk;
764
+ const result = {
765
+ success: overallSuccess,
766
+ summary: compressed,
767
+ filesChanged,
768
+ diffStats,
769
+ durationMs: Date.now() - state.startedAt,
770
+ estimatedCostUsd: cost,
771
+ usage: agentResult.usage,
772
+ costIsEstimate: isEstimate,
773
+ };
774
+ state.executionStatus = agentResult.success ? "SUCCEEDED" : "FAILED";
775
+ state.verificationStatus = verificationVerdict;
776
+ state.reviewStatus = reviewStatus;
777
+ state.mergeStatus = verificationVerdict === "PASS" && reviewStatus === "PASS" ? "READY" : "NOT_READY";
778
+ state.status = "completed";
779
+ state.phase = "completed";
780
+ state.result = result;
781
+ state.completedAt = Date.now();
782
+ this.onThreadProgress?.(threadId, state.phase, `${filesChanged.length} files, ${costLabel}${usageLabel}`);
783
+ // Cache successful results for subthread reuse
784
+ if (result.success) {
785
+ const cfg = state.config;
786
+ const commitSha = await this.getCurrentCommitSha();
787
+ this.threadCache.set(cfg.task, cfg.taskBrief?.writeScope ? [...cfg.taskBrief.writeScope] : cfg.writeScope?.length ? [...cfg.writeScope] : [], cfg.agent.backend || this.config.default_agent, cfg.agent.model || this.config.default_model, result, this.repoRoot, commitSha);
788
+ // Record episode in episodic memory (fire-and-forget)
789
+ // Only records if auto-routing is NOT active (swarm.ts records richer episodes with slot/complexity)
790
+ if (this.episodicMemory && !this.config.auto_model_selection) {
791
+ this.episodicMemory
792
+ .record({
793
+ task: cfg.task,
794
+ agent: cfg.agent.backend || this.config.default_agent,
795
+ model: cfg.agent.model || this.config.default_model,
796
+ slot: "",
797
+ complexity: "",
798
+ success: true,
799
+ durationMs: result.durationMs,
800
+ estimatedCostUsd: cost,
801
+ filesChanged: filesChanged,
802
+ summary: compressed,
803
+ })
804
+ .catch(() => { }); // Non-fatal
805
+ }
806
+ }
807
+ return result;
808
+ }
809
+ catch (err) {
810
+ const errorMsg = err instanceof Error ? err.message : String(err);
811
+ state.status = "failed";
812
+ state.phase = "failed";
813
+ state.error = errorMsg;
814
+ state.completedAt = Date.now();
815
+ this.onThreadProgress?.(threadId, "failed", errorMsg.slice(0, 100));
816
+ // Record estimated cost for failed threads (agent may have consumed tokens before failure)
817
+ const errModel = threadConfig.agent.model || this.config.default_model;
818
+ const { cost } = this.budget.recordCost(threadId, errModel);
819
+ state.estimatedCostUsd = cost;
820
+ // Cleanup worktree on failure
821
+ await this.destroyWorktree(threadId);
822
+ return this.failResult(state, errorMsg);
823
+ }
824
+ finally {
825
+ this.semaphore.release();
826
+ }
827
+ }
828
+ /** Cancel a specific running thread. */
829
+ cancelThread(threadId) {
830
+ const ac = this.threadAbortControllers.get(threadId);
831
+ if (!ac)
832
+ return false;
833
+ ac.abort();
834
+ const state = this.threads.get(threadId);
835
+ if (state) {
836
+ state.status = "cancelled";
837
+ state.phase = "cancelled";
838
+ }
839
+ return true;
840
+ }
841
+ /** Cancel all running threads. */
842
+ cancelAll() {
843
+ for (const [id, ac] of this.threadAbortControllers) {
844
+ ac.abort();
845
+ const state = this.threads.get(id);
846
+ if (state && (state.status === "running" || state.status === "pending")) {
847
+ state.status = "cancelled";
848
+ state.phase = "cancelled";
849
+ }
850
+ }
851
+ }
852
+ /** Get all thread states. */
853
+ getThreads() {
854
+ return [...this.threads.values()];
855
+ }
856
+ /** Get a specific thread's state. */
857
+ getThread(threadId) {
858
+ return this.threads.get(threadId);
859
+ }
860
+ /** Restore a completed/failed thread from persisted state (server restart recovery). */
861
+ restoreThread(state) {
862
+ if (!this.threads.has(state.id)) {
863
+ this.threads.set(state.id, state);
864
+ }
865
+ }
866
+ /** Get the worktree manager for merge operations. */
867
+ getWorktreeManager() {
868
+ return this.worktreeManager;
869
+ }
870
+ /** Get current budget state. */
871
+ getBudgetState() {
872
+ return this.budget.getState();
873
+ }
874
+ /** Get subthread cache stats. */
875
+ getCacheStats() {
876
+ return this.threadCache.getStats();
877
+ }
878
+ /** Get concurrency stats. */
879
+ getConcurrencyStats() {
880
+ return {
881
+ active: this.semaphore.activeCount,
882
+ waiting: this.semaphore.waitingCount,
883
+ total: this.totalSpawned,
884
+ max: this.config.max_threads,
885
+ };
886
+ }
887
+ /** Cleanup all worktrees. */
888
+ async cleanup() {
889
+ this.cancelAll();
890
+ if (this.config.auto_cleanup_worktrees) {
891
+ await this.worktreeManager.destroyAll();
892
+ }
893
+ }
894
+ /** Destroy a specific thread's worktree and branch. */
895
+ async destroyWorktree(threadId) {
896
+ try {
897
+ await this.worktreeManager.destroy(threadId, true);
898
+ }
899
+ catch {
900
+ // Non-fatal
901
+ }
902
+ }
903
+ failResult(state, message) {
904
+ return {
905
+ success: false,
906
+ summary: `Thread failed (attempt ${state.attempt}/${state.maxAttempts}): ${message}`,
907
+ filesChanged: [],
908
+ diffStats: "",
909
+ durationMs: Date.now() - (state.startedAt || Date.now()),
910
+ estimatedCostUsd: state.estimatedCostUsd,
911
+ };
912
+ }
913
+ }
914
+ //# sourceMappingURL=manager.js.map