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.
- package/LICENSE +21 -0
- package/README.md +260 -0
- package/bin/mcp.mjs +61 -0
- package/bin/swarm.mjs +45 -0
- package/dist/agents/aider.d.ts +12 -0
- package/dist/agents/aider.js +192 -0
- package/dist/agents/aider.js.map +1 -0
- package/dist/agents/claude-code.d.ts +9 -0
- package/dist/agents/claude-code.js +236 -0
- package/dist/agents/claude-code.js.map +1 -0
- package/dist/agents/codex.d.ts +14 -0
- package/dist/agents/codex.js +203 -0
- package/dist/agents/codex.js.map +1 -0
- package/dist/agents/direct-llm.d.ts +22 -0
- package/dist/agents/direct-llm.js +254 -0
- package/dist/agents/direct-llm.js.map +1 -0
- package/dist/agents/mock.d.ts +10 -0
- package/dist/agents/mock.js +170 -0
- package/dist/agents/mock.js.map +1 -0
- package/dist/agents/opencode.d.ts +23 -0
- package/dist/agents/opencode.js +631 -0
- package/dist/agents/opencode.js.map +1 -0
- package/dist/agents/provider.d.ts +11 -0
- package/dist/agents/provider.js +31 -0
- package/dist/agents/provider.js.map +1 -0
- package/dist/compression/compressor.d.ts +28 -0
- package/dist/compression/compressor.js +268 -0
- package/dist/compression/compressor.js.map +1 -0
- package/dist/config.d.ts +43 -0
- package/dist/config.js +172 -0
- package/dist/config.js.map +1 -0
- package/dist/contextos/loader.d.ts +46 -0
- package/dist/contextos/loader.js +120 -0
- package/dist/contextos/loader.js.map +1 -0
- package/dist/contextos/selector.d.ts +46 -0
- package/dist/contextos/selector.js +80 -0
- package/dist/contextos/selector.js.map +1 -0
- package/dist/core/action-dispatcher.d.ts +15 -0
- package/dist/core/action-dispatcher.js +28 -0
- package/dist/core/action-dispatcher.js.map +1 -0
- package/dist/core/action-schema.d.ts +72 -0
- package/dist/core/action-schema.js +86 -0
- package/dist/core/action-schema.js.map +1 -0
- package/dist/core/repl-interface.d.ts +48 -0
- package/dist/core/repl-interface.js +5 -0
- package/dist/core/repl-interface.js.map +1 -0
- package/dist/core/repl.d.ts +59 -0
- package/dist/core/repl.js +353 -0
- package/dist/core/repl.js.map +1 -0
- package/dist/core/rlm.d.ts +65 -0
- package/dist/core/rlm.js +564 -0
- package/dist/core/rlm.js.map +1 -0
- package/dist/core/runtime.py +470 -0
- package/dist/core/types.d.ts +265 -0
- package/dist/core/types.js +19 -0
- package/dist/core/types.js.map +1 -0
- package/dist/env.d.ts +10 -0
- package/dist/env.js +75 -0
- package/dist/env.js.map +1 -0
- package/dist/hooks/runner.d.ts +43 -0
- package/dist/hooks/runner.js +173 -0
- package/dist/hooks/runner.js.map +1 -0
- package/dist/labs/cli.d.ts +15 -0
- package/dist/labs/cli.js +295 -0
- package/dist/labs/cli.js.map +1 -0
- package/dist/labs/episodic-memory.d.ts +132 -0
- package/dist/labs/episodic-memory.js +391 -0
- package/dist/labs/episodic-memory.js.map +1 -0
- package/dist/labs/interactive-swarm.d.ts +20 -0
- package/dist/labs/interactive-swarm.js +1173 -0
- package/dist/labs/interactive-swarm.js.map +1 -0
- package/dist/labs/interactive.d.ts +10 -0
- package/dist/labs/interactive.js +2099 -0
- package/dist/labs/interactive.js.map +1 -0
- package/dist/labs/ts-analyzer.d.ts +74 -0
- package/dist/labs/ts-analyzer.js +379 -0
- package/dist/labs/ts-analyzer.js.map +1 -0
- package/dist/labs/viewer.d.ts +12 -0
- package/dist/labs/viewer.js +1284 -0
- package/dist/labs/viewer.js.map +1 -0
- package/dist/main.d.ts +15 -0
- package/dist/main.js +247 -0
- package/dist/main.js.map +1 -0
- package/dist/mcp/server.d.ts +16 -0
- package/dist/mcp/server.js +192775 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/session.d.ts +117 -0
- package/dist/mcp/session.js +450 -0
- package/dist/mcp/session.js.map +1 -0
- package/dist/mcp/state.d.ts +42 -0
- package/dist/mcp/state.js +537 -0
- package/dist/mcp/state.js.map +1 -0
- package/dist/mcp/tools/contextos.d.ts +17 -0
- package/dist/mcp/tools/contextos.js +583 -0
- package/dist/mcp/tools/contextos.js.map +1 -0
- package/dist/mcp/tools.d.ts +15 -0
- package/dist/mcp/tools.js +393 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/orchestration/reviewer-gate.d.ts +43 -0
- package/dist/orchestration/reviewer-gate.js +280 -0
- package/dist/orchestration/reviewer-gate.js.map +1 -0
- package/dist/orchestration/reviewer-prompt.d.ts +18 -0
- package/dist/orchestration/reviewer-prompt.js +67 -0
- package/dist/orchestration/reviewer-prompt.js.map +1 -0
- package/dist/orchestration/verification-runner.d.ts +53 -0
- package/dist/orchestration/verification-runner.js +352 -0
- package/dist/orchestration/verification-runner.js.map +1 -0
- package/dist/plugins/adapter.d.ts +11 -0
- package/dist/plugins/adapter.js +11 -0
- package/dist/plugins/adapter.js.map +1 -0
- package/dist/plugins/core/archive.d.ts +9 -0
- package/dist/plugins/core/archive.js +128 -0
- package/dist/plugins/core/archive.js.map +1 -0
- package/dist/plugins/core/digest.d.ts +11 -0
- package/dist/plugins/core/digest.js +70 -0
- package/dist/plugins/core/digest.js.map +1 -0
- package/dist/plugins/core/grants.d.ts +22 -0
- package/dist/plugins/core/grants.js +90 -0
- package/dist/plugins/core/grants.js.map +1 -0
- package/dist/plugins/core/index.d.ts +10 -0
- package/dist/plugins/core/index.js +11 -0
- package/dist/plugins/core/index.js.map +1 -0
- package/dist/plugins/core/pinning.d.ts +15 -0
- package/dist/plugins/core/pinning.js +86 -0
- package/dist/plugins/core/pinning.js.map +1 -0
- package/dist/plugins/core/types.d.ts +105 -0
- package/dist/plugins/core/types.js +22 -0
- package/dist/plugins/core/types.js.map +1 -0
- package/dist/plugins/core/updater.d.ts +16 -0
- package/dist/plugins/core/updater.js +64 -0
- package/dist/plugins/core/updater.js.map +1 -0
- package/dist/plugins/loader.d.ts +65 -0
- package/dist/plugins/loader.js +110 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/prompts/orchestrator.d.ts +6 -0
- package/dist/prompts/orchestrator.js +114 -0
- package/dist/prompts/orchestrator.js.map +1 -0
- package/dist/routing/model-resolver.d.ts +27 -0
- package/dist/routing/model-resolver.js +265 -0
- package/dist/routing/model-resolver.js.map +1 -0
- package/dist/routing/model-router.d.ts +130 -0
- package/dist/routing/model-router.js +515 -0
- package/dist/routing/model-router.js.map +1 -0
- package/dist/runtime/attestations.cjs +260 -0
- package/dist/runtime/reviewer-pipeline.cjs +319 -0
- package/dist/runtime/sandbox.cjs +468 -0
- package/dist/runtime/state-machine.cjs +885 -0
- package/dist/runtime/thread-store.cjs +175 -0
- package/dist/runtime/transactional-git.cjs +315 -0
- package/dist/runtime/verification-pipeline.cjs +435 -0
- package/dist/sandbox/execution.d.ts +165 -0
- package/dist/sandbox/execution.js +497 -0
- package/dist/sandbox/execution.js.map +1 -0
- package/dist/security/file-policy.d.ts +22 -0
- package/dist/security/file-policy.js +53 -0
- package/dist/security/file-policy.js.map +1 -0
- package/dist/security/repository-boundary.d.ts +21 -0
- package/dist/security/repository-boundary.js +119 -0
- package/dist/security/repository-boundary.js.map +1 -0
- package/dist/security/secret-filter.d.ts +36 -0
- package/dist/security/secret-filter.js +149 -0
- package/dist/security/secret-filter.js.map +1 -0
- package/dist/swarm.d.ts +14 -0
- package/dist/swarm.js +624 -0
- package/dist/swarm.js.map +1 -0
- package/dist/threads/cache.d.ts +64 -0
- package/dist/threads/cache.js +241 -0
- package/dist/threads/cache.js.map +1 -0
- package/dist/threads/manager.d.ts +94 -0
- package/dist/threads/manager.js +914 -0
- package/dist/threads/manager.js.map +1 -0
- package/dist/ui/banner.d.ts +14 -0
- package/dist/ui/banner.js +42 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/dashboard.d.ts +33 -0
- package/dist/ui/dashboard.js +135 -0
- package/dist/ui/dashboard.js.map +1 -0
- package/dist/ui/index.d.ts +11 -0
- package/dist/ui/index.js +12 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/log.d.ts +39 -0
- package/dist/ui/log.js +126 -0
- package/dist/ui/log.js.map +1 -0
- package/dist/ui/onboarding.d.ts +14 -0
- package/dist/ui/onboarding.js +755 -0
- package/dist/ui/onboarding.js.map +1 -0
- package/dist/ui/run-log.d.ts +50 -0
- package/dist/ui/run-log.js +60 -0
- package/dist/ui/run-log.js.map +1 -0
- package/dist/ui/spinner.d.ts +41 -0
- package/dist/ui/spinner.js +172 -0
- package/dist/ui/spinner.js.map +1 -0
- package/dist/ui/streaming-feed.d.ts +72 -0
- package/dist/ui/streaming-feed.js +386 -0
- package/dist/ui/streaming-feed.js.map +1 -0
- package/dist/ui/summary.d.ts +18 -0
- package/dist/ui/summary.js +113 -0
- package/dist/ui/summary.js.map +1 -0
- package/dist/ui/text-input.d.ts +19 -0
- package/dist/ui/text-input.js +266 -0
- package/dist/ui/text-input.js.map +1 -0
- package/dist/ui/theme.d.ts +63 -0
- package/dist/ui/theme.js +97 -0
- package/dist/ui/theme.js.map +1 -0
- package/dist/utils/command-exists.d.ts +11 -0
- package/dist/utils/command-exists.js +41 -0
- package/dist/utils/command-exists.js.map +1 -0
- package/dist/utils/process-security.d.ts +19 -0
- package/dist/utils/process-security.js +90 -0
- package/dist/utils/process-security.js.map +1 -0
- package/dist/utils/rolling-buffer.d.ts +16 -0
- package/dist/utils/rolling-buffer.js +30 -0
- package/dist/utils/rolling-buffer.js.map +1 -0
- package/dist/utils/safe-spawn.d.ts +13 -0
- package/dist/utils/safe-spawn.js +49 -0
- package/dist/utils/safe-spawn.js.map +1 -0
- package/dist/worktree/manager.d.ts +69 -0
- package/dist/worktree/manager.js +593 -0
- package/dist/worktree/manager.js.map +1 -0
- package/dist/worktree/merge.d.ts +58 -0
- package/dist/worktree/merge.js +389 -0
- package/dist/worktree/merge.js.map +1 -0
- 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
|