@caseyharalson/orrery 0.7.1
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/.devcontainer.example/Dockerfile +149 -0
- package/.devcontainer.example/devcontainer.json +61 -0
- package/.devcontainer.example/init-firewall.sh +175 -0
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/agent/skills/discovery/SKILL.md +428 -0
- package/agent/skills/discovery/schemas/plan-schema.yaml +138 -0
- package/agent/skills/orrery-execute/SKILL.md +107 -0
- package/agent/skills/orrery-report/SKILL.md +119 -0
- package/agent/skills/orrery-review/SKILL.md +105 -0
- package/agent/skills/orrery-verify/SKILL.md +105 -0
- package/agent/skills/refine-plan/SKILL.md +291 -0
- package/agent/skills/simulate-plan/SKILL.md +244 -0
- package/bin/orrery.js +5 -0
- package/lib/cli/commands/help.js +21 -0
- package/lib/cli/commands/ingest-plan.js +56 -0
- package/lib/cli/commands/init.js +21 -0
- package/lib/cli/commands/install-devcontainer.js +97 -0
- package/lib/cli/commands/install-skills.js +182 -0
- package/lib/cli/commands/orchestrate.js +27 -0
- package/lib/cli/commands/resume.js +146 -0
- package/lib/cli/commands/status.js +137 -0
- package/lib/cli/commands/validate-plan.js +288 -0
- package/lib/cli/index.js +57 -0
- package/lib/orchestration/agent-invoker.js +595 -0
- package/lib/orchestration/condensed-plan.js +128 -0
- package/lib/orchestration/config.js +213 -0
- package/lib/orchestration/dependency-resolver.js +149 -0
- package/lib/orchestration/edit-invoker.js +115 -0
- package/lib/orchestration/index.js +1065 -0
- package/lib/orchestration/plan-loader.js +212 -0
- package/lib/orchestration/progress-tracker.js +208 -0
- package/lib/orchestration/report-format.js +80 -0
- package/lib/orchestration/review-invoker.js +305 -0
- package/lib/utils/agent-detector.js +47 -0
- package/lib/utils/git.js +297 -0
- package/lib/utils/paths.js +43 -0
- package/lib/utils/plan-detect.js +24 -0
- package/lib/utils/skill-copier.js +79 -0
- package/package.json +58 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawn } = require("child_process");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { validateAgentOutput } = require("./report-format");
|
|
7
|
+
const { getReportsDir } = require("../utils/paths");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Invoke an agent subprocess to execute plan steps
|
|
11
|
+
* @param {Object} agentConfig - Agent configuration (command, args)
|
|
12
|
+
* @param {string} planFile - Absolute path to the plan YAML file
|
|
13
|
+
* @param {string[]} stepIds - Array of step IDs to execute
|
|
14
|
+
* @param {string} repoRoot - Path to repository root
|
|
15
|
+
* @param {Object} [options] - Additional options
|
|
16
|
+
* @param {Function} [options.onStdout] - Callback for stdout data
|
|
17
|
+
* @param {Function} [options.onStderr] - Callback for stderr data
|
|
18
|
+
* @returns {Object} - Process handle with promise for completion
|
|
19
|
+
*/
|
|
20
|
+
function invokeAgent(agentConfig, planFile, stepIds, repoRoot, options = {}) {
|
|
21
|
+
const stepIdsStr = stepIds.join(",");
|
|
22
|
+
|
|
23
|
+
// Replace placeholders in command args
|
|
24
|
+
const args = agentConfig.args.map((arg) =>
|
|
25
|
+
arg.replace("{planFile}", planFile).replace("{stepIds}", stepIdsStr)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const proc = spawn(agentConfig.command, args, {
|
|
29
|
+
cwd: repoRoot,
|
|
30
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
31
|
+
detached: false,
|
|
32
|
+
shell: false
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Collect stdout for result parsing
|
|
36
|
+
let stdoutBuffer = "";
|
|
37
|
+
let stderrBuffer = "";
|
|
38
|
+
|
|
39
|
+
proc.stdout.on("data", (data) => {
|
|
40
|
+
const text = data.toString();
|
|
41
|
+
stdoutBuffer += text;
|
|
42
|
+
if (options.onStdout) {
|
|
43
|
+
options.onStdout(text, stepIds);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
proc.stderr.on("data", (data) => {
|
|
48
|
+
const text = data.toString();
|
|
49
|
+
stderrBuffer += text;
|
|
50
|
+
if (options.onStderr) {
|
|
51
|
+
options.onStderr(text, stepIds);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Create a promise that resolves when process exits
|
|
56
|
+
const completion = new Promise((resolve, reject) => {
|
|
57
|
+
proc.on("close", (code) => {
|
|
58
|
+
resolve({
|
|
59
|
+
exitCode: code,
|
|
60
|
+
stdout: stdoutBuffer,
|
|
61
|
+
stderr: stderrBuffer,
|
|
62
|
+
stepIds
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
proc.on("error", (err) => {
|
|
67
|
+
reject({
|
|
68
|
+
error: err,
|
|
69
|
+
stdout: stdoutBuffer,
|
|
70
|
+
stderr: stderrBuffer,
|
|
71
|
+
stepIds
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
process: proc,
|
|
78
|
+
completion,
|
|
79
|
+
stepIds,
|
|
80
|
+
kill: () => proc.kill()
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse agent result from stdout
|
|
86
|
+
* Expected format is JSON with:
|
|
87
|
+
* {
|
|
88
|
+
* stepId: string,
|
|
89
|
+
* status: "complete" | "blocked",
|
|
90
|
+
* summary: string,
|
|
91
|
+
* blockedReason?: string,
|
|
92
|
+
* artifacts?: string[],
|
|
93
|
+
* testResults?: { passed: number, failed: number }
|
|
94
|
+
* }
|
|
95
|
+
*
|
|
96
|
+
* Agents may output multiple JSON objects (one per step) or a single array.
|
|
97
|
+
* JSON may be wrapped in markdown code blocks (```json ... ```).
|
|
98
|
+
* @param {string} stdout - Raw stdout from agent
|
|
99
|
+
* @returns {Array<Object>} - Array of parsed results
|
|
100
|
+
*/
|
|
101
|
+
function parseAgentResults(stdout) {
|
|
102
|
+
const results = [];
|
|
103
|
+
|
|
104
|
+
// Helper to validate and add results
|
|
105
|
+
const addResult = (obj) => {
|
|
106
|
+
try {
|
|
107
|
+
const valid = validateAgentOutput(obj);
|
|
108
|
+
results.push(valid);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
// Invalid result structure, ignore
|
|
111
|
+
console.debug("Invalid result:", e.message);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// First, try to extract JSON from markdown code blocks
|
|
116
|
+
const codeBlockPattern = /```(?:json)?\s*([\s\S]*?)```/g;
|
|
117
|
+
let codeBlockMatch;
|
|
118
|
+
while ((codeBlockMatch = codeBlockPattern.exec(stdout)) !== null) {
|
|
119
|
+
const content = codeBlockMatch[1].trim();
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(content);
|
|
122
|
+
if (Array.isArray(parsed)) {
|
|
123
|
+
parsed.forEach(addResult);
|
|
124
|
+
} else {
|
|
125
|
+
addResult(parsed);
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// Not valid JSON in this code block, continue
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// If we found results in code blocks, return them
|
|
133
|
+
if (results.length > 0) {
|
|
134
|
+
return results;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Otherwise, try to find raw JSON objects with balanced braces
|
|
138
|
+
let i = 0;
|
|
139
|
+
while (i < stdout.length) {
|
|
140
|
+
if (stdout[i] === "{") {
|
|
141
|
+
const jsonObj = extractBalancedJson(stdout, i, "{", "}");
|
|
142
|
+
if (jsonObj) {
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(jsonObj);
|
|
145
|
+
addResult(parsed);
|
|
146
|
+
} catch {
|
|
147
|
+
// Not valid JSON
|
|
148
|
+
}
|
|
149
|
+
i += jsonObj.length;
|
|
150
|
+
} else {
|
|
151
|
+
i++;
|
|
152
|
+
}
|
|
153
|
+
} else if (stdout[i] === "[") {
|
|
154
|
+
const jsonArr = extractBalancedJson(stdout, i, "[", "]");
|
|
155
|
+
if (jsonArr) {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(jsonArr);
|
|
158
|
+
if (Array.isArray(parsed)) {
|
|
159
|
+
parsed.forEach(addResult);
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// Not valid JSON
|
|
163
|
+
}
|
|
164
|
+
i += jsonArr.length;
|
|
165
|
+
} else {
|
|
166
|
+
i++;
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
i++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return results;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Extract a balanced JSON structure from a string starting at a given position.
|
|
178
|
+
* Handles nested braces and respects string boundaries.
|
|
179
|
+
* @param {string} str - The string to extract from
|
|
180
|
+
* @param {number} start - Starting index (must be openChar)
|
|
181
|
+
* @param {string} openChar - Opening character ('{' or '[')
|
|
182
|
+
* @param {string} closeChar - Closing character ('}' or ']')
|
|
183
|
+
* @returns {string|null} - The extracted JSON string or null if unbalanced
|
|
184
|
+
*/
|
|
185
|
+
function extractBalancedJson(str, start, openChar = "{", closeChar = "}") {
|
|
186
|
+
if (str[start] !== openChar) return null;
|
|
187
|
+
|
|
188
|
+
let depth = 0;
|
|
189
|
+
let inString = false;
|
|
190
|
+
let escapeNext = false;
|
|
191
|
+
|
|
192
|
+
for (let i = start; i < str.length; i++) {
|
|
193
|
+
const char = str[i];
|
|
194
|
+
|
|
195
|
+
if (escapeNext) {
|
|
196
|
+
escapeNext = false;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (char === "\\" && inString) {
|
|
201
|
+
escapeNext = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (char === '"') {
|
|
206
|
+
inString = !inString;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!inString) {
|
|
211
|
+
if (char === openChar) {
|
|
212
|
+
depth++;
|
|
213
|
+
} else if (char === closeChar) {
|
|
214
|
+
depth--;
|
|
215
|
+
if (depth === 0) {
|
|
216
|
+
return str.slice(start, i + 1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return null; // Unbalanced
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Create a default result for a step when agent doesn't report properly
|
|
227
|
+
* @param {string} stepId - Step ID
|
|
228
|
+
* @param {number} exitCode - Process exit code
|
|
229
|
+
* @param {string} stderr - Error output
|
|
230
|
+
* @returns {Object} - Default result object
|
|
231
|
+
*/
|
|
232
|
+
function createDefaultResult(stepId, exitCode, stderr) {
|
|
233
|
+
if (exitCode === 0) {
|
|
234
|
+
return {
|
|
235
|
+
stepId,
|
|
236
|
+
status: "complete",
|
|
237
|
+
summary: "Step completed (no detailed report from agent)",
|
|
238
|
+
artifacts: [],
|
|
239
|
+
commitMessage: `feat: complete step ${stepId}`
|
|
240
|
+
};
|
|
241
|
+
} else {
|
|
242
|
+
return {
|
|
243
|
+
stepId,
|
|
244
|
+
status: "blocked",
|
|
245
|
+
summary: "Step failed",
|
|
246
|
+
blockedReason: stderr || `Agent exited with code ${exitCode}`,
|
|
247
|
+
artifacts: [],
|
|
248
|
+
commitMessage: `wip: attempt step ${stepId}`
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Check if an error condition should trigger failover to another agent
|
|
255
|
+
* @param {Object} result - Process result with exitCode, stdout, stderr
|
|
256
|
+
* @param {Error} spawnError - Error from spawn (if any)
|
|
257
|
+
* @param {boolean} timedOut - Whether the process timed out
|
|
258
|
+
* @param {Object} errorPatterns - Regex patterns for error detection
|
|
259
|
+
* @returns {{shouldFailover: boolean, reason: string}}
|
|
260
|
+
*/
|
|
261
|
+
function shouldTriggerFailover(result, spawnError, timedOut, errorPatterns) {
|
|
262
|
+
// 1. Spawn failures (command not found, ENOENT)
|
|
263
|
+
if (spawnError) {
|
|
264
|
+
if (spawnError.code === "ENOENT") {
|
|
265
|
+
return { shouldFailover: true, reason: "command_not_found" };
|
|
266
|
+
}
|
|
267
|
+
return { shouldFailover: true, reason: "spawn_error" };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 2. Timeout
|
|
271
|
+
if (timedOut) {
|
|
272
|
+
return { shouldFailover: true, reason: "timeout" };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 3. Non-zero exit with error patterns in stderr (but NOT legitimate blocked)
|
|
276
|
+
if (result && result.exitCode !== 0) {
|
|
277
|
+
const stderr = result.stderr || "";
|
|
278
|
+
|
|
279
|
+
// Check API error patterns
|
|
280
|
+
for (const pattern of errorPatterns.apiError || []) {
|
|
281
|
+
if (pattern.test(stderr)) {
|
|
282
|
+
return { shouldFailover: true, reason: "api_error" };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Check token limit patterns
|
|
287
|
+
for (const pattern of errorPatterns.tokenLimit || []) {
|
|
288
|
+
if (pattern.test(stderr)) {
|
|
289
|
+
return { shouldFailover: true, reason: "token_limit" };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { shouldFailover: false, reason: null };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Log a timeout event to the configured log file
|
|
299
|
+
* @param {Object} config - Orchestrator config
|
|
300
|
+
* @param {string} planFile - Path to plan file
|
|
301
|
+
* @param {string[]} stepIds - Step IDs that timed out
|
|
302
|
+
* @param {string} agentName - Name of the agent that timed out
|
|
303
|
+
*/
|
|
304
|
+
function logTimeout(config, planFile, stepIds, agentName) {
|
|
305
|
+
const logPath = path.join(getReportsDir(), "timeouts.log");
|
|
306
|
+
|
|
307
|
+
const entry = {
|
|
308
|
+
timestamp: new Date().toISOString(),
|
|
309
|
+
planFile: path.basename(planFile),
|
|
310
|
+
stepIds,
|
|
311
|
+
agent: agentName,
|
|
312
|
+
timeoutMs: config.failover?.timeoutMs
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const line = JSON.stringify(entry) + "\n";
|
|
316
|
+
fs.appendFileSync(logPath, line, "utf8");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Log a failure event to the configured log file
|
|
321
|
+
* Captures both stdout and stderr since agents may write errors to either stream
|
|
322
|
+
* @param {Object} config - Orchestrator config
|
|
323
|
+
* @param {string} planFile - Path to plan file
|
|
324
|
+
* @param {string[]} stepIds - Step IDs that failed
|
|
325
|
+
* @param {string} agentName - Name of the agent that failed
|
|
326
|
+
* @param {number} exitCode - Process exit code
|
|
327
|
+
* @param {string} stdout - Agent stdout output
|
|
328
|
+
* @param {string} stderr - Agent stderr output
|
|
329
|
+
*/
|
|
330
|
+
function logFailure(
|
|
331
|
+
config,
|
|
332
|
+
planFile,
|
|
333
|
+
stepIds,
|
|
334
|
+
agentName,
|
|
335
|
+
exitCode,
|
|
336
|
+
stdout,
|
|
337
|
+
stderr
|
|
338
|
+
) {
|
|
339
|
+
const logPath = path.join(getReportsDir(), "failures.log");
|
|
340
|
+
|
|
341
|
+
const entry = {
|
|
342
|
+
timestamp: new Date().toISOString(),
|
|
343
|
+
planFile: path.basename(planFile),
|
|
344
|
+
stepIds,
|
|
345
|
+
agent: agentName,
|
|
346
|
+
exitCode,
|
|
347
|
+
stdout: stdout || "",
|
|
348
|
+
stderr: stderr || ""
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const line = JSON.stringify(entry) + "\n";
|
|
352
|
+
fs.appendFileSync(logPath, line, "utf8");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Invoke an agent with a timeout
|
|
357
|
+
* @param {Object} agentConfig - Agent configuration
|
|
358
|
+
* @param {string} planFile - Path to plan file
|
|
359
|
+
* @param {string[]} stepIds - Step IDs to execute
|
|
360
|
+
* @param {string} repoRoot - Repository root path
|
|
361
|
+
* @param {number} timeoutMs - Timeout in milliseconds
|
|
362
|
+
* @param {Object} options - Options including callbacks
|
|
363
|
+
* @returns {Promise<Object>} - Result with timedOut flag
|
|
364
|
+
*/
|
|
365
|
+
async function invokeAgentWithTimeout(
|
|
366
|
+
agentConfig,
|
|
367
|
+
planFile,
|
|
368
|
+
stepIds,
|
|
369
|
+
repoRoot,
|
|
370
|
+
timeoutMs,
|
|
371
|
+
options
|
|
372
|
+
) {
|
|
373
|
+
const handle = invokeAgent(agentConfig, planFile, stepIds, repoRoot, options);
|
|
374
|
+
|
|
375
|
+
if (!timeoutMs || timeoutMs <= 0) {
|
|
376
|
+
const result = await handle.completion;
|
|
377
|
+
return { ...result, timedOut: false };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Race between completion and timeout
|
|
381
|
+
let timer;
|
|
382
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
383
|
+
timer = setTimeout(() => {
|
|
384
|
+
handle.kill();
|
|
385
|
+
resolve({
|
|
386
|
+
timedOut: true,
|
|
387
|
+
stepIds,
|
|
388
|
+
exitCode: null,
|
|
389
|
+
stdout: "",
|
|
390
|
+
stderr: "Process timed out"
|
|
391
|
+
});
|
|
392
|
+
}, timeoutMs);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const result = await Promise.race([
|
|
397
|
+
handle.completion.then((r) => ({ ...r, timedOut: false })),
|
|
398
|
+
timeoutPromise
|
|
399
|
+
]);
|
|
400
|
+
return result;
|
|
401
|
+
} finally {
|
|
402
|
+
clearTimeout(timer);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Invoke an agent with automatic failover to next agent on infrastructure failures
|
|
408
|
+
* @param {Object} config - Full orchestrator config
|
|
409
|
+
* @param {string} planFile - Path to plan file
|
|
410
|
+
* @param {string[]} stepIds - Step IDs to execute
|
|
411
|
+
* @param {string} repoRoot - Repository root path
|
|
412
|
+
* @param {Object} options - Options including callbacks
|
|
413
|
+
* @returns {Object} - Handle with completion promise
|
|
414
|
+
*/
|
|
415
|
+
function invokeAgentWithFailover(
|
|
416
|
+
config,
|
|
417
|
+
planFile,
|
|
418
|
+
stepIds,
|
|
419
|
+
repoRoot,
|
|
420
|
+
options = {}
|
|
421
|
+
) {
|
|
422
|
+
const failoverConfig = config.failover || { enabled: false };
|
|
423
|
+
|
|
424
|
+
// If failover is disabled, use default agent directly
|
|
425
|
+
if (!failoverConfig.enabled) {
|
|
426
|
+
const agentName = config.defaultAgent || Object.keys(config.agents)[0];
|
|
427
|
+
const agentConfig = config.agents[agentName];
|
|
428
|
+
const handle = invokeAgent(
|
|
429
|
+
agentConfig,
|
|
430
|
+
planFile,
|
|
431
|
+
stepIds,
|
|
432
|
+
repoRoot,
|
|
433
|
+
options
|
|
434
|
+
);
|
|
435
|
+
// Wrap completion to include agentName and log failures
|
|
436
|
+
return {
|
|
437
|
+
...handle,
|
|
438
|
+
completion: handle.completion.then((result) => {
|
|
439
|
+
if (result.exitCode !== 0 && result.exitCode !== null) {
|
|
440
|
+
logFailure(
|
|
441
|
+
config,
|
|
442
|
+
planFile,
|
|
443
|
+
stepIds,
|
|
444
|
+
agentName,
|
|
445
|
+
result.exitCode,
|
|
446
|
+
result.stdout,
|
|
447
|
+
result.stderr
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
return { ...result, agentName };
|
|
451
|
+
})
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const agentPriority = config.agentPriority || [config.defaultAgent];
|
|
456
|
+
|
|
457
|
+
// Filter to only configured agents
|
|
458
|
+
const availableAgents = agentPriority.filter((name) => config.agents[name]);
|
|
459
|
+
|
|
460
|
+
if (availableAgents.length === 0) {
|
|
461
|
+
throw new Error("No agents configured");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
let cancelled = false;
|
|
465
|
+
let currentHandle = null;
|
|
466
|
+
|
|
467
|
+
const completion = (async () => {
|
|
468
|
+
let lastResult = null;
|
|
469
|
+
let lastError = null;
|
|
470
|
+
|
|
471
|
+
for (let i = 0; i < availableAgents.length; i++) {
|
|
472
|
+
if (cancelled) break;
|
|
473
|
+
|
|
474
|
+
const agentName = availableAgents[i];
|
|
475
|
+
const agentConfig = config.agents[agentName];
|
|
476
|
+
|
|
477
|
+
console.log(
|
|
478
|
+
`[failover] Trying agent: ${agentName} (${i + 1}/${
|
|
479
|
+
availableAgents.length
|
|
480
|
+
})`
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
// Invoke with timeout wrapper
|
|
485
|
+
const result = await invokeAgentWithTimeout(
|
|
486
|
+
agentConfig,
|
|
487
|
+
planFile,
|
|
488
|
+
stepIds,
|
|
489
|
+
repoRoot,
|
|
490
|
+
failoverConfig.timeoutMs,
|
|
491
|
+
options
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
// Log failures (non-zero exit) for debugging
|
|
495
|
+
if (result.exitCode !== 0 && result.exitCode !== null) {
|
|
496
|
+
logFailure(
|
|
497
|
+
config,
|
|
498
|
+
planFile,
|
|
499
|
+
stepIds,
|
|
500
|
+
agentName,
|
|
501
|
+
result.exitCode,
|
|
502
|
+
result.stdout,
|
|
503
|
+
result.stderr
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Check if we should failover
|
|
508
|
+
const { shouldFailover, reason } = shouldTriggerFailover(
|
|
509
|
+
result,
|
|
510
|
+
null,
|
|
511
|
+
result.timedOut,
|
|
512
|
+
failoverConfig.errorPatterns || {}
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
if (shouldFailover && i < availableAgents.length - 1) {
|
|
516
|
+
console.log(
|
|
517
|
+
`[failover] Agent ${agentName} failed (${reason}), trying next agent`
|
|
518
|
+
);
|
|
519
|
+
lastResult = result;
|
|
520
|
+
|
|
521
|
+
if (reason === "timeout") {
|
|
522
|
+
logTimeout(config, planFile, stepIds, agentName);
|
|
523
|
+
}
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Either succeeded or no more agents to try
|
|
528
|
+
return { ...result, agentName };
|
|
529
|
+
} catch (spawnError) {
|
|
530
|
+
const { shouldFailover, reason } = shouldTriggerFailover(
|
|
531
|
+
null,
|
|
532
|
+
spawnError,
|
|
533
|
+
false,
|
|
534
|
+
failoverConfig.errorPatterns || {}
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
if (shouldFailover && i < availableAgents.length - 1) {
|
|
538
|
+
console.log(
|
|
539
|
+
`[failover] Agent ${agentName} spawn failed (${reason}), trying next agent`
|
|
540
|
+
);
|
|
541
|
+
lastError = spawnError;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// No more agents, rethrow
|
|
546
|
+
throw spawnError;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// All agents exhausted
|
|
551
|
+
if (lastResult) return lastResult;
|
|
552
|
+
if (lastError) throw lastError;
|
|
553
|
+
})();
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
process: null, // Not directly accessible with failover
|
|
557
|
+
completion,
|
|
558
|
+
stepIds,
|
|
559
|
+
kill: () => {
|
|
560
|
+
cancelled = true;
|
|
561
|
+
if (currentHandle) currentHandle.kill();
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Wait for multiple agent processes to complete
|
|
568
|
+
* @param {Array<Object>} agentHandles - Array of handles from invokeAgent
|
|
569
|
+
* @returns {Promise<Array>} - Array of completion results
|
|
570
|
+
*/
|
|
571
|
+
async function waitForAll(agentHandles) {
|
|
572
|
+
return Promise.all(agentHandles.map((h) => h.completion));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Wait for any one agent process to complete
|
|
577
|
+
* @param {Array<Object>} agentHandles - Array of handles from invokeAgent
|
|
578
|
+
* @returns {Promise<{result: Object, index: number}>} - First completed result with index
|
|
579
|
+
*/
|
|
580
|
+
async function waitForAny(agentHandles) {
|
|
581
|
+
return Promise.race(
|
|
582
|
+
agentHandles.map((handle, index) =>
|
|
583
|
+
handle.completion.then((result) => ({ result, index }))
|
|
584
|
+
)
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
module.exports = {
|
|
589
|
+
invokeAgent,
|
|
590
|
+
invokeAgentWithFailover,
|
|
591
|
+
parseAgentResults,
|
|
592
|
+
createDefaultResult,
|
|
593
|
+
waitForAll,
|
|
594
|
+
waitForAny
|
|
595
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Condensed Plan Generator
|
|
3
|
+
*
|
|
4
|
+
* Creates temporary condensed plans containing only the steps an agent needs,
|
|
5
|
+
* reducing context usage for large plans.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const YAML = require("yaml");
|
|
11
|
+
|
|
12
|
+
const { getTempDir } = require("../utils/paths");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get all completed dependencies for a set of step IDs (recursive)
|
|
16
|
+
* @param {Object} plan - The loaded plan object
|
|
17
|
+
* @param {string[]} stepIds - Step IDs to find dependencies for
|
|
18
|
+
* @returns {Object[]} - Array of completed dependency steps
|
|
19
|
+
*/
|
|
20
|
+
function getCompletedDependencies(plan, stepIds) {
|
|
21
|
+
const stepMap = new Map(plan.steps.map((s) => [s.id, s]));
|
|
22
|
+
const collected = new Set();
|
|
23
|
+
|
|
24
|
+
function collectDeps(stepId) {
|
|
25
|
+
const step = stepMap.get(stepId);
|
|
26
|
+
if (!step || !step.deps) return;
|
|
27
|
+
|
|
28
|
+
for (const depId of step.deps) {
|
|
29
|
+
if (collected.has(depId)) continue;
|
|
30
|
+
|
|
31
|
+
const depStep = stepMap.get(depId);
|
|
32
|
+
if (depStep && depStep.status === "complete") {
|
|
33
|
+
collected.add(depId);
|
|
34
|
+
collectDeps(depId);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const stepId of stepIds) {
|
|
40
|
+
collectDeps(stepId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return Array.from(collected)
|
|
44
|
+
.map((id) => stepMap.get(id))
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate a condensed plan containing only assigned steps and their completed dependencies
|
|
50
|
+
* @param {Object} plan - The loaded plan object
|
|
51
|
+
* @param {string[]} stepIds - Step IDs assigned to the agent
|
|
52
|
+
* @returns {Object} - Condensed plan data (plain object, not a plan object)
|
|
53
|
+
*/
|
|
54
|
+
function generateCondensedPlan(plan, stepIds) {
|
|
55
|
+
const stepSet = new Set(stepIds);
|
|
56
|
+
const assignedSteps = plan.steps.filter((s) => stepSet.has(s.id));
|
|
57
|
+
const completedDeps = getCompletedDependencies(plan, stepIds);
|
|
58
|
+
|
|
59
|
+
// Build ordered steps: completed deps + assigned steps, sorted by ascending ID
|
|
60
|
+
const depIds = new Set(completedDeps.map((s) => s.id));
|
|
61
|
+
const orderedSteps = [
|
|
62
|
+
...completedDeps,
|
|
63
|
+
...assignedSteps.filter((s) => !depIds.has(s.id))
|
|
64
|
+
].sort((a, b) =>
|
|
65
|
+
String(a.id).localeCompare(String(b.id), undefined, { numeric: true })
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Copy metadata and add condensed indicators
|
|
69
|
+
const condensedMetadata = {
|
|
70
|
+
...plan.metadata,
|
|
71
|
+
condensed: true,
|
|
72
|
+
source_plan: plan.filePath,
|
|
73
|
+
condensed_at: new Date().toISOString(),
|
|
74
|
+
assigned_steps: stepIds
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
metadata: condensedMetadata,
|
|
79
|
+
steps: orderedSteps
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Write a condensed plan to a temp file
|
|
85
|
+
* @param {Object} condensedPlan - The condensed plan data
|
|
86
|
+
* @param {string} originalPlanPath - Path to the original plan file
|
|
87
|
+
* @param {string[]} stepIds - Step IDs for filename generation
|
|
88
|
+
* @returns {string} - Path to the written temp file
|
|
89
|
+
*/
|
|
90
|
+
function writeCondensedPlan(condensedPlan, originalPlanPath, stepIds) {
|
|
91
|
+
const tempDir = getTempDir();
|
|
92
|
+
const baseName = path.basename(
|
|
93
|
+
originalPlanPath,
|
|
94
|
+
path.extname(originalPlanPath)
|
|
95
|
+
);
|
|
96
|
+
const stepSuffix = stepIds.join("-").replace(/[^a-zA-Z0-9.-]/g, "_");
|
|
97
|
+
const timestamp = Date.now();
|
|
98
|
+
const fileName = `${baseName}-${stepSuffix}-${timestamp}.yaml`;
|
|
99
|
+
const filePath = path.join(tempDir, fileName);
|
|
100
|
+
|
|
101
|
+
const content = YAML.stringify(condensedPlan);
|
|
102
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
103
|
+
|
|
104
|
+
return filePath;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Delete a condensed plan temp file
|
|
109
|
+
* @param {string} tempPlanPath - Path to the temp plan file
|
|
110
|
+
*/
|
|
111
|
+
function deleteCondensedPlan(tempPlanPath) {
|
|
112
|
+
try {
|
|
113
|
+
if (fs.existsSync(tempPlanPath)) {
|
|
114
|
+
fs.unlinkSync(tempPlanPath);
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.warn(
|
|
118
|
+
`Warning: Failed to delete temp plan ${tempPlanPath}: ${err.message}`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
generateCondensedPlan,
|
|
125
|
+
getCompletedDependencies,
|
|
126
|
+
writeCondensedPlan,
|
|
127
|
+
deleteCondensedPlan
|
|
128
|
+
};
|