@welluable/orch 1.1.0 → 1.3.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/README.md +224 -6
- package/agents/boundaries.js +26 -0
- package/agents/code-writer.js +5 -0
- package/agents/decomposer.js +62 -0
- package/agents/index.js +3 -0
- package/agents/integrator.js +41 -0
- package/agents/quick-fix.js +4 -0
- package/agents/test-writer.js +4 -0
- package/lib/agent.js +82 -6
- package/lib/commit.js +70 -0
- package/lib/config.js +115 -0
- package/lib/continue.js +132 -0
- package/lib/fanout.js +355 -0
- package/lib/file-tracker.js +89 -0
- package/lib/integrate.js +51 -0
- package/lib/job-lifecycle.js +53 -0
- package/lib/jobs.js +361 -0
- package/lib/parse-decomposition.js +33 -0
- package/lib/run-context.js +13 -1
- package/lib/stage-summary.js +55 -6
- package/lib/worktree.js +4 -2
- package/main.js +2739 -214
- package/package.json +1 -1
package/main.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command, Option } from 'commander';
|
|
3
|
-
import { execFileSync } from 'child_process';
|
|
3
|
+
import { execFileSync, spawn } from 'child_process';
|
|
4
4
|
import fs from 'fs';
|
|
5
5
|
import path from 'path';
|
|
6
|
+
import readline from 'node:readline/promises';
|
|
7
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
6
8
|
|
|
7
9
|
import { fileURLToPath } from 'url';
|
|
8
10
|
import { AgentCursor } from './lib/agent-cursor.js';
|
|
@@ -13,7 +15,32 @@ import { parseVerdict } from './lib/parse-verdict.js';
|
|
|
13
15
|
import { splitStageSummary, printStageSummary } from './lib/stage-summary.js';
|
|
14
16
|
import { createRunContext } from './lib/run-context.js';
|
|
15
17
|
import { createWorktree } from './lib/worktree.js';
|
|
16
|
-
import { commitWorktree } from './lib/commit.js';
|
|
18
|
+
import { commitWorktree, collectWorktreeChanges, printFilesChanged } from './lib/commit.js';
|
|
19
|
+
import { FileTracker } from './lib/file-tracker.js';
|
|
20
|
+
import { allocateJob } from './lib/job-lifecycle.js';
|
|
21
|
+
import { setJobSlug, exitCodeForSignal, formatElapsed } from './lib/agent.js';
|
|
22
|
+
import {
|
|
23
|
+
jobPaths,
|
|
24
|
+
readJob,
|
|
25
|
+
patchJob,
|
|
26
|
+
listJobs,
|
|
27
|
+
reconcileJob,
|
|
28
|
+
checkpointPause,
|
|
29
|
+
requestPause,
|
|
30
|
+
requestResume,
|
|
31
|
+
cascadePause,
|
|
32
|
+
cascadeResume,
|
|
33
|
+
stopJob,
|
|
34
|
+
cleanJobs,
|
|
35
|
+
isPidAlive,
|
|
36
|
+
reopenJob,
|
|
37
|
+
buildLastOutcome,
|
|
38
|
+
} from './lib/jobs.js';
|
|
39
|
+
import {
|
|
40
|
+
validateContinue,
|
|
41
|
+
snapshotPriorOutcome,
|
|
42
|
+
buildPriorOutcomeText,
|
|
43
|
+
} from './lib/continue.js';
|
|
17
44
|
import { askAgentArgs } from './agents/ask.js';
|
|
18
45
|
import { triageAgentArgs } from './agents/triage.js';
|
|
19
46
|
import { quickFixAgentArgs } from './agents/quick-fix.js';
|
|
@@ -23,6 +50,37 @@ import { testWriterAgentArgs } from './agents/test-writer.js';
|
|
|
23
50
|
import { testCriticAgentArgs } from './agents/test-critic.js';
|
|
24
51
|
import { codeWriterAgentArgs } from './agents/code-writer.js';
|
|
25
52
|
import { testRunnerAgentArgs } from './agents/test-runner.js';
|
|
53
|
+
import { integratorAgentArgs } from './agents/integrator.js';
|
|
54
|
+
import { boundariesAgentArgs } from './agents/boundaries.js';
|
|
55
|
+
import { decomposerAgentArgs } from './agents/decomposer.js';
|
|
56
|
+
import {
|
|
57
|
+
readFanout,
|
|
58
|
+
writeFanout,
|
|
59
|
+
patchWorker,
|
|
60
|
+
patchIntegration,
|
|
61
|
+
recordChangedFiles,
|
|
62
|
+
buildWorkerEnvelope,
|
|
63
|
+
buildIntegrationEnvelope,
|
|
64
|
+
validateDecomposition,
|
|
65
|
+
planLayers,
|
|
66
|
+
chooseConcurrency,
|
|
67
|
+
detectOverlaps,
|
|
68
|
+
ensureScaffoldSubtask,
|
|
69
|
+
} from './lib/fanout.js';
|
|
70
|
+
import { parseDecomposition } from './lib/parse-decomposition.js';
|
|
71
|
+
import {
|
|
72
|
+
mergeBranches,
|
|
73
|
+
abortMerge,
|
|
74
|
+
conflictedFiles,
|
|
75
|
+
hasConflictMarkers,
|
|
76
|
+
} from './lib/integrate.js';
|
|
77
|
+
import {
|
|
78
|
+
resolveAgent,
|
|
79
|
+
writeConfig,
|
|
80
|
+
printConfig,
|
|
81
|
+
globalConfigPath,
|
|
82
|
+
localConfigPath,
|
|
83
|
+
} from './lib/config.js';
|
|
26
84
|
|
|
27
85
|
const __filename = fileURLToPath(import.meta.url);
|
|
28
86
|
const __dirname = path.dirname(__filename);
|
|
@@ -71,6 +129,149 @@ function ensureBinaryOnPath(binary, agentName) {
|
|
|
71
129
|
}
|
|
72
130
|
}
|
|
73
131
|
|
|
132
|
+
const TERMINAL_JOB_STATES = ['done', 'failed', 'stopped', 'crashed'];
|
|
133
|
+
|
|
134
|
+
function formatRelativeTime(iso) {
|
|
135
|
+
if (!iso) return '-';
|
|
136
|
+
const sec = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
|
|
137
|
+
if (sec < 60) return `${sec}s ago`;
|
|
138
|
+
const min = Math.floor(sec / 60);
|
|
139
|
+
if (min < 60) return `${min}m ago`;
|
|
140
|
+
const hr = Math.floor(min / 60);
|
|
141
|
+
if (hr < 24) return `${hr}h ago`;
|
|
142
|
+
return `${Math.floor(hr / 24)}d ago`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function jobDuration(job) {
|
|
146
|
+
if (!job.startedAt) return '-';
|
|
147
|
+
const start = new Date(job.startedAt).getTime();
|
|
148
|
+
if (Number.isNaN(start)) return '-';
|
|
149
|
+
const end = job.finishedAt ? new Date(job.finishedAt).getTime() : Date.now();
|
|
150
|
+
if (Number.isNaN(end)) return '-';
|
|
151
|
+
return formatElapsed(Math.max(0, end - start));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function displayJobRole(role) {
|
|
155
|
+
if (role === 'integration') return 'integrate';
|
|
156
|
+
if (role == null) return '-';
|
|
157
|
+
return role;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Workers first (startedAt ascending), then the integration child last. */
|
|
161
|
+
function compareFanoutChildren(a, b) {
|
|
162
|
+
const aIntegrate = a.role === 'integration' ? 1 : 0;
|
|
163
|
+
const bIntegrate = b.role === 'integration' ? 1 : 0;
|
|
164
|
+
if (aIntegrate !== bIntegrate) return aIntegrate - bIntegrate;
|
|
165
|
+
return new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function formatJobsTable(jobs) {
|
|
169
|
+
const header = ['SLUG', 'ROLE', 'STATE', 'PHASE', 'AGENT', 'STARTED', 'DURATION', 'PID'];
|
|
170
|
+
const childrenByParent = new Map();
|
|
171
|
+
const topLevel = [];
|
|
172
|
+
for (const job of jobs) {
|
|
173
|
+
if (job.parent) {
|
|
174
|
+
if (!childrenByParent.has(job.parent)) childrenByParent.set(job.parent, []);
|
|
175
|
+
childrenByParent.get(job.parent).push(job);
|
|
176
|
+
} else {
|
|
177
|
+
topLevel.push(job);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
topLevel.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
|
181
|
+
|
|
182
|
+
const ordered = [];
|
|
183
|
+
for (const parent of topLevel) {
|
|
184
|
+
ordered.push({ job: parent, indent: '' });
|
|
185
|
+
const children = (childrenByParent.get(parent.slug) || []).slice().sort(compareFanoutChildren);
|
|
186
|
+
for (const child of children) ordered.push({ job: child, indent: ' ' });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const rows = ordered.map(({ job, indent }) => [
|
|
190
|
+
`${indent}${job.slug}`,
|
|
191
|
+
displayJobRole(job.role),
|
|
192
|
+
job.state,
|
|
193
|
+
job.phase ?? '-',
|
|
194
|
+
job.agent ?? '-',
|
|
195
|
+
formatRelativeTime(job.startedAt),
|
|
196
|
+
jobDuration(job),
|
|
197
|
+
TERMINAL_JOB_STATES.includes(job.state) ? '-' : (job.pid ?? '-'),
|
|
198
|
+
]);
|
|
199
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => String(row[i]).length)));
|
|
200
|
+
const formatRow = (cols) => cols.map((c, i) => String(c).padEnd(widths[i])).join(' ').trimEnd();
|
|
201
|
+
return [formatRow(header), ...rows.map(formatRow)].join('\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function lastNonEmptyLine(content) {
|
|
205
|
+
const lines = content.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
206
|
+
return lines[lines.length - 1];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function formatStatus(cwd, record) {
|
|
210
|
+
const lines = [
|
|
211
|
+
`slug: ${record.slug}`,
|
|
212
|
+
`state: ${record.state}`,
|
|
213
|
+
`phase: ${record.phase ?? '-'}`,
|
|
214
|
+
`stage: ${record.stage ?? '-'}`,
|
|
215
|
+
`agent: ${record.agent ?? '-'}`,
|
|
216
|
+
`started: ${record.startedAt} (${formatRelativeTime(record.startedAt)})`,
|
|
217
|
+
`finished: ${record.finishedAt ?? '-'}`,
|
|
218
|
+
`branch: ${record.branch ?? '-'}`,
|
|
219
|
+
`worktree: ${record.worktree ?? '-'}`,
|
|
220
|
+
`exitCode: ${record.exitCode ?? '-'}`,
|
|
221
|
+
`log: ${record.logPath ?? '-'}`,
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
if (record.parent) {
|
|
225
|
+
lines.splice(1, 0, `parent: ${record.parent}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (record.continuation > 1) {
|
|
229
|
+
const stateIdx = lines.findIndex((line) => line.startsWith('state:'));
|
|
230
|
+
lines.splice(stateIdx + 1, 0, `continuation: ${record.continuation}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (record.lastOutcome) {
|
|
234
|
+
const o = record.lastOutcome;
|
|
235
|
+
lines.push(`outcome: ${o.phase ?? '-'} / ${o.stage ?? '-'} (round ${o.round ?? '-'})`);
|
|
236
|
+
if (o.summary) lines.push(`summary: ${o.summary}`);
|
|
237
|
+
if (o.error) lines.push(`error: ${o.error}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const statusPath = path.join(jobPaths(cwd, record.slug).dir, 'status.md');
|
|
241
|
+
if (fs.existsSync(statusPath)) {
|
|
242
|
+
const last = lastNonEmptyLine(fs.readFileSync(statusPath, 'utf8'));
|
|
243
|
+
if (last) lines.push(`status: ${last}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Child view: parent line only — do not expand siblings.
|
|
247
|
+
// Read children from disk without reconcile so status reflects recorded
|
|
248
|
+
// state/phase/branch (listJobs would rewrite dead-pid live states to crashed).
|
|
249
|
+
if (!record.parent) {
|
|
250
|
+
const orchDir = path.join(path.resolve(cwd), '.orch');
|
|
251
|
+
const children = [];
|
|
252
|
+
if (fs.existsSync(orchDir)) {
|
|
253
|
+
for (const name of fs.readdirSync(orchDir)) {
|
|
254
|
+
const child = readJob(cwd, name);
|
|
255
|
+
if (child?.parent === record.slug) children.push(child);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
children.sort(compareFanoutChildren);
|
|
259
|
+
if (record.role === 'coordinator' || children.length > 0) {
|
|
260
|
+
for (const child of children) {
|
|
261
|
+
lines.push(` ${child.slug} ${child.state} ${child.phase ?? '-'} ${child.branch ?? '-'}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return lines.join('\n');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** True when pause/resume/stop should cascade to children. */
|
|
270
|
+
function isCascadeParent(cwd, record) {
|
|
271
|
+
if (record?.role === 'coordinator') return true;
|
|
272
|
+
return listJobs(cwd).some((job) => job.parent === record.slug);
|
|
273
|
+
}
|
|
274
|
+
|
|
74
275
|
function formatVerdictFeedback(verdict, rawResult) {
|
|
75
276
|
const lines = [];
|
|
76
277
|
if (verdict.summary) lines.push(verdict.summary);
|
|
@@ -96,6 +297,268 @@ function roundLabel(role, round, maxRounds) {
|
|
|
96
297
|
return `${role} ${round}/${maxRounds}`;
|
|
97
298
|
}
|
|
98
299
|
|
|
300
|
+
function defaultExecFile(command, args, options = {}) {
|
|
301
|
+
return execFileSync(command, args, { encoding: 'utf8', ...options });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Patch a job to a terminal state and write a matching `lastOutcome` in the same write. */
|
|
305
|
+
function patchTerminalJob(patchJobFn, jobCwd, jobSlug, { state, exitCode, summary = '', error = null, task }) {
|
|
306
|
+
if (!jobSlug) return;
|
|
307
|
+
const finishedAt = new Date().toISOString();
|
|
308
|
+
patchJobFn(jobCwd, jobSlug, (current) => ({
|
|
309
|
+
state,
|
|
310
|
+
exitCode,
|
|
311
|
+
finishedAt,
|
|
312
|
+
lastOutcome: buildLastOutcome({
|
|
313
|
+
state,
|
|
314
|
+
phase: current.phase,
|
|
315
|
+
stage: current.stage,
|
|
316
|
+
round: current.round,
|
|
317
|
+
exitCode,
|
|
318
|
+
finishedAt,
|
|
319
|
+
task: task ?? current.task,
|
|
320
|
+
summary,
|
|
321
|
+
error,
|
|
322
|
+
}),
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The test-writer ⇄ test-critic loop shared by `runPipeline` and `runWorkerPipeline`. */
|
|
327
|
+
async function runTestLoop({
|
|
328
|
+
prompt,
|
|
329
|
+
worktreePath,
|
|
330
|
+
branch,
|
|
331
|
+
taskPath,
|
|
332
|
+
statusPath,
|
|
333
|
+
maxRounds,
|
|
334
|
+
AgentClass,
|
|
335
|
+
verbose,
|
|
336
|
+
jobPatch,
|
|
337
|
+
jobCheckpoint,
|
|
338
|
+
}) {
|
|
339
|
+
let testAccepted = null;
|
|
340
|
+
let criticFeedback = null;
|
|
341
|
+
let testRound = 0;
|
|
342
|
+
let testSummary = '';
|
|
343
|
+
|
|
344
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
345
|
+
testRound = round;
|
|
346
|
+
|
|
347
|
+
jobPatch({ phase: 'test-loop', stage: 'test-writer', round });
|
|
348
|
+
const testWriterArgs = testWriterAgentArgs({
|
|
349
|
+
prompt,
|
|
350
|
+
cwd: worktreePath,
|
|
351
|
+
worktreePath,
|
|
352
|
+
branch,
|
|
353
|
+
taskPath,
|
|
354
|
+
statusPath,
|
|
355
|
+
criticFeedback,
|
|
356
|
+
});
|
|
357
|
+
const testWriterTracker = new FileTracker({ cwd: worktreePath });
|
|
358
|
+
const testWriter = new AgentClass(
|
|
359
|
+
roundLabel('test-writer', round, maxRounds),
|
|
360
|
+
testWriterArgs.instructions,
|
|
361
|
+
testWriterArgs.prompt,
|
|
362
|
+
{ ...testWriterArgs.options, fileTracker: testWriterTracker },
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const testOut = await testWriter.run({ verbose });
|
|
366
|
+
await jobCheckpoint();
|
|
367
|
+
const { content: testWriterContent, summary: testWriterSummary } = splitStageSummary(testOut.result);
|
|
368
|
+
printStageSummary(
|
|
369
|
+
roundLabel('test-writer', round, maxRounds),
|
|
370
|
+
testWriterSummary,
|
|
371
|
+
testWriterTracker.getFiles(),
|
|
372
|
+
);
|
|
373
|
+
if (!testOut.ok) {
|
|
374
|
+
appendLoopStatus(statusPath, 'Test loop', {
|
|
375
|
+
round: testRound,
|
|
376
|
+
maxRounds,
|
|
377
|
+
passed: false,
|
|
378
|
+
summary: 'test-writer failed',
|
|
379
|
+
});
|
|
380
|
+
throw new Error('test-writer failed; stopping before code-writer');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
jobPatch({ phase: 'test-loop', stage: 'test-critic', round });
|
|
384
|
+
const testCriticArgs = testCriticAgentArgs({
|
|
385
|
+
prompt,
|
|
386
|
+
cwd: worktreePath,
|
|
387
|
+
worktreePath,
|
|
388
|
+
branch,
|
|
389
|
+
taskPath,
|
|
390
|
+
statusPath,
|
|
391
|
+
testWriterOutput: testWriterContent,
|
|
392
|
+
});
|
|
393
|
+
const testCritic = new AgentClass(
|
|
394
|
+
roundLabel('test-critic', round, maxRounds),
|
|
395
|
+
testCriticArgs.instructions,
|
|
396
|
+
testCriticArgs.prompt,
|
|
397
|
+
testCriticArgs.options,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const criticOut = await testCritic.run({ verbose });
|
|
401
|
+
await jobCheckpoint();
|
|
402
|
+
const { content: testCriticContent, summary: testCriticSummary } = splitStageSummary(criticOut.result);
|
|
403
|
+
printStageSummary(roundLabel('test-critic', round, maxRounds), testCriticSummary);
|
|
404
|
+
if (!criticOut.ok) {
|
|
405
|
+
appendLoopStatus(statusPath, 'Test loop', {
|
|
406
|
+
round: testRound,
|
|
407
|
+
maxRounds,
|
|
408
|
+
passed: false,
|
|
409
|
+
summary: 'test-critic failed',
|
|
410
|
+
});
|
|
411
|
+
throw new Error('test-critic failed; stopping before code-writer');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const verdict = parseVerdict(testCriticContent);
|
|
415
|
+
testSummary = verdict.summary;
|
|
416
|
+
if (verdict.passed) {
|
|
417
|
+
testAccepted = { writerContent: testWriterContent, criticOut, verdict, round };
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
criticFeedback = formatVerdictFeedback(verdict, testCriticContent);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
appendLoopStatus(statusPath, 'Test loop', {
|
|
424
|
+
round: testAccepted?.round ?? testRound,
|
|
425
|
+
maxRounds,
|
|
426
|
+
passed: Boolean(testAccepted),
|
|
427
|
+
summary: testAccepted?.verdict.summary ?? testSummary,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
if (!testAccepted) {
|
|
431
|
+
throw new Error(`test loop exhausted after ${maxRounds} rounds`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return testAccepted;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The code-writer ⇄ test-runner loop shared by `runPipeline`, `runWorkerPipeline`, and
|
|
439
|
+
* `runIntegratePipeline`. `runnerFirst` (used by `--integrate`'s verify loop) skips
|
|
440
|
+
* `code-writer` on round 1 only; if that lone `test-runner` attempt fails, rounds 2+
|
|
441
|
+
* alternate `code-writer` → `test-runner` exactly like the default writer-first shape.
|
|
442
|
+
*/
|
|
443
|
+
async function runCodeLoop({
|
|
444
|
+
prompt,
|
|
445
|
+
worktreePath,
|
|
446
|
+
branch,
|
|
447
|
+
taskPath,
|
|
448
|
+
statusPath,
|
|
449
|
+
maxRounds,
|
|
450
|
+
AgentClass,
|
|
451
|
+
verbose,
|
|
452
|
+
jobPatch,
|
|
453
|
+
jobCheckpoint,
|
|
454
|
+
acceptedVerification,
|
|
455
|
+
runnerFirst = false,
|
|
456
|
+
loopTitle = 'Code loop',
|
|
457
|
+
}) {
|
|
458
|
+
let codeAccepted = null;
|
|
459
|
+
let runnerFeedback = null;
|
|
460
|
+
let codeRound = 0;
|
|
461
|
+
let codeSummary = '';
|
|
462
|
+
let codeWriterContent = null;
|
|
463
|
+
|
|
464
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
465
|
+
codeRound = round;
|
|
466
|
+
const skipWriter = runnerFirst && round === 1;
|
|
467
|
+
|
|
468
|
+
if (!skipWriter) {
|
|
469
|
+
jobPatch({ phase: 'code-loop', stage: 'code-writer', round });
|
|
470
|
+
const codeWriterArgs = codeWriterAgentArgs({
|
|
471
|
+
prompt,
|
|
472
|
+
cwd: worktreePath,
|
|
473
|
+
worktreePath,
|
|
474
|
+
branch,
|
|
475
|
+
taskPath,
|
|
476
|
+
statusPath,
|
|
477
|
+
round,
|
|
478
|
+
acceptedVerification,
|
|
479
|
+
runnerFeedback,
|
|
480
|
+
});
|
|
481
|
+
const codeWriterTracker = new FileTracker({ cwd: worktreePath });
|
|
482
|
+
const codeWriter = new AgentClass(
|
|
483
|
+
roundLabel('code-writer', round, maxRounds),
|
|
484
|
+
codeWriterArgs.instructions,
|
|
485
|
+
codeWriterArgs.prompt,
|
|
486
|
+
{ ...codeWriterArgs.options, fileTracker: codeWriterTracker },
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const codeOut = await codeWriter.run({ verbose });
|
|
490
|
+
await jobCheckpoint();
|
|
491
|
+
const { content, summary } = splitStageSummary(codeOut.result);
|
|
492
|
+
codeWriterContent = content;
|
|
493
|
+
printStageSummary(
|
|
494
|
+
roundLabel('code-writer', round, maxRounds),
|
|
495
|
+
summary,
|
|
496
|
+
codeWriterTracker.getFiles(),
|
|
497
|
+
);
|
|
498
|
+
if (!codeOut.ok) {
|
|
499
|
+
appendLoopStatus(statusPath, loopTitle, {
|
|
500
|
+
round: codeRound,
|
|
501
|
+
maxRounds,
|
|
502
|
+
passed: false,
|
|
503
|
+
summary: 'code-writer failed',
|
|
504
|
+
});
|
|
505
|
+
throw new Error('code-writer failed; stopping before commit');
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
jobPatch({ phase: 'code-loop', stage: 'test-runner', round });
|
|
510
|
+
const testRunnerArgs = testRunnerAgentArgs({
|
|
511
|
+
prompt,
|
|
512
|
+
cwd: worktreePath,
|
|
513
|
+
worktreePath,
|
|
514
|
+
branch,
|
|
515
|
+
statusPath,
|
|
516
|
+
codeWriterOutput: codeWriterContent,
|
|
517
|
+
});
|
|
518
|
+
const testRunner = new AgentClass(
|
|
519
|
+
roundLabel('test-runner', round, maxRounds),
|
|
520
|
+
testRunnerArgs.instructions,
|
|
521
|
+
testRunnerArgs.prompt,
|
|
522
|
+
testRunnerArgs.options,
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
const runnerOut = await testRunner.run({ verbose });
|
|
526
|
+
await jobCheckpoint();
|
|
527
|
+
const { content: testRunnerContent, summary: testRunnerSummary } = splitStageSummary(runnerOut.result);
|
|
528
|
+
printStageSummary(roundLabel('test-runner', round, maxRounds), testRunnerSummary);
|
|
529
|
+
if (!runnerOut.ok) {
|
|
530
|
+
appendLoopStatus(statusPath, loopTitle, {
|
|
531
|
+
round: codeRound,
|
|
532
|
+
maxRounds,
|
|
533
|
+
passed: false,
|
|
534
|
+
summary: 'test-runner failed',
|
|
535
|
+
});
|
|
536
|
+
throw new Error('test-runner failed; stopping before commit');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const verdict = parseVerdict(testRunnerContent);
|
|
540
|
+
codeSummary = verdict.summary;
|
|
541
|
+
if (verdict.passed) {
|
|
542
|
+
codeAccepted = { writerContent: codeWriterContent, verdict, round };
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
runnerFeedback = formatVerdictFeedback(verdict, testRunnerContent);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
appendLoopStatus(statusPath, loopTitle, {
|
|
549
|
+
round: codeAccepted?.round ?? codeRound,
|
|
550
|
+
maxRounds,
|
|
551
|
+
passed: Boolean(codeAccepted),
|
|
552
|
+
summary: codeAccepted?.verdict.summary ?? codeSummary,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
if (!codeAccepted) {
|
|
556
|
+
throw new Error(`code loop exhausted after ${maxRounds} rounds`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return codeAccepted;
|
|
560
|
+
}
|
|
561
|
+
|
|
99
562
|
export async function runPipeline(prompt, options) {
|
|
100
563
|
const verbose = Boolean(options.verbose);
|
|
101
564
|
const maxRounds = options.maxRounds ?? 5;
|
|
@@ -108,8 +571,24 @@ export async function runPipeline(prompt, options) {
|
|
|
108
571
|
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
109
572
|
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
110
573
|
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
574
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
111
575
|
const invocationCwd = process.cwd();
|
|
112
576
|
|
|
577
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
578
|
+
const jobCwd = options.jobCwd ?? invocationCwd;
|
|
579
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
580
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
581
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
582
|
+
|
|
583
|
+
const jobPatch = (fields) => {
|
|
584
|
+
if (!jobSlug) return;
|
|
585
|
+
patchJobFn(jobCwd, jobSlug, fields);
|
|
586
|
+
};
|
|
587
|
+
const jobCheckpoint = async () => {
|
|
588
|
+
if (!jobSlug) return;
|
|
589
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
590
|
+
};
|
|
591
|
+
|
|
113
592
|
console.log(`cwd: ${invocationCwd}`);
|
|
114
593
|
console.log(`agent: ${options.agent}`);
|
|
115
594
|
|
|
@@ -129,6 +608,7 @@ export async function runPipeline(prompt, options) {
|
|
|
129
608
|
console.log();
|
|
130
609
|
|
|
131
610
|
if (options.ask) {
|
|
611
|
+
jobPatch({ phase: 'ask' });
|
|
132
612
|
const ask = askAgentArgs({ prompt, cwd: invocationCwd });
|
|
133
613
|
const askAgent = new AgentClass(ask.name, ask.instructions, ask.prompt, ask.options);
|
|
134
614
|
|
|
@@ -136,37 +616,47 @@ export async function runPipeline(prompt, options) {
|
|
|
136
616
|
const askResult = await askAgent.run({ verbose });
|
|
137
617
|
if (!askResult.ok) {
|
|
138
618
|
console.error(`Error: ask agent failed`);
|
|
619
|
+
jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
|
|
139
620
|
process.exit(1);
|
|
140
621
|
return;
|
|
141
622
|
}
|
|
142
623
|
const { content, summary } = splitStageSummary(askResult.result);
|
|
143
624
|
printStageSummary('ask', summary);
|
|
144
625
|
console.log(content);
|
|
626
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
145
627
|
} catch (err) {
|
|
146
628
|
console.error(`Error: ${err.message}`);
|
|
629
|
+
jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
|
|
147
630
|
process.exit(1);
|
|
148
631
|
}
|
|
149
632
|
return;
|
|
150
633
|
}
|
|
151
634
|
|
|
152
635
|
if (options.quick) {
|
|
636
|
+
jobPatch({ phase: 'quick-fix' });
|
|
153
637
|
const quickFix = quickFixAgentArgs({ prompt, cwd: invocationCwd });
|
|
638
|
+
const quickFixTracker = new FileTracker({ cwd: invocationCwd });
|
|
154
639
|
const quickFixAgent = new AgentClass(
|
|
155
640
|
quickFix.name,
|
|
156
641
|
quickFix.instructions,
|
|
157
642
|
quickFix.prompt,
|
|
158
|
-
quickFix.options,
|
|
643
|
+
{ ...quickFix.options, fileTracker: quickFixTracker },
|
|
159
644
|
);
|
|
160
645
|
|
|
161
646
|
try {
|
|
162
647
|
const quickFixResult = await quickFixAgent.run({ verbose });
|
|
163
648
|
if (!quickFixResult.ok) {
|
|
164
649
|
console.error(`Error: quick-fix agent failed`);
|
|
650
|
+
jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
|
|
165
651
|
process.exit(1);
|
|
166
652
|
return;
|
|
167
653
|
}
|
|
654
|
+
const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
655
|
+
printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
|
|
656
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
168
657
|
} catch (err) {
|
|
169
658
|
console.error(`Error: ${err.message}`);
|
|
659
|
+
jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
|
|
170
660
|
process.exit(1);
|
|
171
661
|
}
|
|
172
662
|
return;
|
|
@@ -181,33 +671,43 @@ export async function runPipeline(prompt, options) {
|
|
|
181
671
|
);
|
|
182
672
|
|
|
183
673
|
try {
|
|
674
|
+
jobPatch({ phase: 'triage', stage: 'triage', round: null });
|
|
675
|
+
await jobCheckpoint();
|
|
184
676
|
const triageResult = await triageAgent.run({ verbose });
|
|
677
|
+
await jobCheckpoint();
|
|
185
678
|
const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
|
|
186
679
|
printStageSummary('triage', triageSummary);
|
|
187
680
|
const parsed = parseTriageJson(triageContent);
|
|
188
681
|
|
|
189
682
|
if (parsed?.simple === true) {
|
|
683
|
+
jobPatch({ phase: 'quick-fix', stage: 'quick-fix', round: null });
|
|
190
684
|
const quickFix = quickFixAgentArgs({
|
|
191
685
|
prompt,
|
|
192
686
|
cwd: invocationCwd,
|
|
193
687
|
fix_plan: parsed.fix_plan,
|
|
194
688
|
});
|
|
689
|
+
const quickFixTracker = new FileTracker({ cwd: invocationCwd });
|
|
195
690
|
const quickFixAgent = new AgentClass(
|
|
196
691
|
quickFix.name,
|
|
197
692
|
quickFix.instructions,
|
|
198
693
|
quickFix.prompt,
|
|
199
|
-
quickFix.options,
|
|
694
|
+
{ ...quickFix.options, fileTracker: quickFixTracker },
|
|
200
695
|
);
|
|
201
696
|
|
|
202
697
|
const quickFixResult = await quickFixAgent.run({ verbose });
|
|
698
|
+
await jobCheckpoint();
|
|
203
699
|
const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
204
|
-
printStageSummary('quick-fix', quickFixSummary);
|
|
700
|
+
printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
|
|
701
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
205
702
|
return;
|
|
206
703
|
}
|
|
207
704
|
|
|
208
|
-
const runContext = createRunContextFn(
|
|
705
|
+
const runContext = createRunContextFn(
|
|
706
|
+
jobSlug ? { cwd: invocationCwd, slug: jobSlug } : { cwd: invocationCwd },
|
|
707
|
+
);
|
|
209
708
|
console.log(`task ${runContext.slug} is started`);
|
|
210
709
|
|
|
710
|
+
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
211
711
|
const research = researchAgentArgs({
|
|
212
712
|
prompt,
|
|
213
713
|
cwd: invocationCwd,
|
|
@@ -221,9 +721,11 @@ export async function runPipeline(prompt, options) {
|
|
|
221
721
|
);
|
|
222
722
|
|
|
223
723
|
const result = await researchAgent.run({ verbose });
|
|
724
|
+
await jobCheckpoint();
|
|
224
725
|
const { content: researchContent, summary: researchSummary } = splitStageSummary(result.result);
|
|
225
726
|
printStageSummary('research', researchSummary);
|
|
226
727
|
|
|
728
|
+
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
227
729
|
const planner = plannerAgentArgs({
|
|
228
730
|
prompt,
|
|
229
731
|
cwd: invocationCwd,
|
|
@@ -239,10 +741,13 @@ export async function runPipeline(prompt, options) {
|
|
|
239
741
|
);
|
|
240
742
|
|
|
241
743
|
const plannerResult = await plannerAgent.run({ verbose });
|
|
744
|
+
await jobCheckpoint();
|
|
242
745
|
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
243
746
|
printStageSummary('planner', plannerSummary);
|
|
244
747
|
|
|
748
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
245
749
|
const worktree = createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
|
|
750
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
246
751
|
|
|
247
752
|
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
248
753
|
fs.writeFileSync(
|
|
@@ -251,98 +756,20 @@ export async function runPipeline(prompt, options) {
|
|
|
251
756
|
);
|
|
252
757
|
|
|
253
758
|
// --- test loop: test-writer ⇄ test-critic ---
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
testRound = round;
|
|
261
|
-
|
|
262
|
-
const testWriterArgs = testWriterAgentArgs({
|
|
263
|
-
prompt,
|
|
264
|
-
cwd: worktree.worktreePath,
|
|
265
|
-
worktreePath: worktree.worktreePath,
|
|
266
|
-
branch: worktree.branch,
|
|
267
|
-
taskPath: runContext.taskPath,
|
|
268
|
-
statusPath: runContext.statusPath,
|
|
269
|
-
criticFeedback,
|
|
270
|
-
});
|
|
271
|
-
const testWriter = new AgentClass(
|
|
272
|
-
roundLabel('test-writer', round, maxRounds),
|
|
273
|
-
testWriterArgs.instructions,
|
|
274
|
-
testWriterArgs.prompt,
|
|
275
|
-
testWriterArgs.options,
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
const testOut = await testWriter.run({ verbose });
|
|
279
|
-
const { content: testWriterContent, summary: testWriterSummary } = splitStageSummary(testOut.result);
|
|
280
|
-
printStageSummary(roundLabel('test-writer', round, maxRounds), testWriterSummary);
|
|
281
|
-
if (!testOut.ok) {
|
|
282
|
-
appendLoopStatus(runContext.statusPath, 'Test loop', {
|
|
283
|
-
round: testRound,
|
|
284
|
-
maxRounds,
|
|
285
|
-
passed: false,
|
|
286
|
-
summary: 'test-writer failed',
|
|
287
|
-
});
|
|
288
|
-
throw new Error('test-writer failed; stopping before code-writer');
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const testCriticArgs = testCriticAgentArgs({
|
|
292
|
-
prompt,
|
|
293
|
-
cwd: worktree.worktreePath,
|
|
294
|
-
worktreePath: worktree.worktreePath,
|
|
295
|
-
branch: worktree.branch,
|
|
296
|
-
taskPath: runContext.taskPath,
|
|
297
|
-
statusPath: runContext.statusPath,
|
|
298
|
-
testWriterOutput: testWriterContent,
|
|
299
|
-
});
|
|
300
|
-
const testCritic = new AgentClass(
|
|
301
|
-
roundLabel('test-critic', round, maxRounds),
|
|
302
|
-
testCriticArgs.instructions,
|
|
303
|
-
testCriticArgs.prompt,
|
|
304
|
-
testCriticArgs.options,
|
|
305
|
-
);
|
|
306
|
-
|
|
307
|
-
const criticOut = await testCritic.run({ verbose });
|
|
308
|
-
const { content: testCriticContent, summary: testCriticSummary } = splitStageSummary(criticOut.result);
|
|
309
|
-
printStageSummary(roundLabel('test-critic', round, maxRounds), testCriticSummary);
|
|
310
|
-
if (!criticOut.ok) {
|
|
311
|
-
appendLoopStatus(runContext.statusPath, 'Test loop', {
|
|
312
|
-
round: testRound,
|
|
313
|
-
maxRounds,
|
|
314
|
-
passed: false,
|
|
315
|
-
summary: 'test-critic failed',
|
|
316
|
-
});
|
|
317
|
-
throw new Error('test-critic failed; stopping before code-writer');
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const verdict = parseVerdict(testCriticContent);
|
|
321
|
-
testSummary = verdict.summary;
|
|
322
|
-
if (verdict.passed) {
|
|
323
|
-
testAccepted = { writerContent: testWriterContent, criticOut, verdict, round };
|
|
324
|
-
break;
|
|
325
|
-
}
|
|
326
|
-
criticFeedback = formatVerdictFeedback(verdict, testCriticContent);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
appendLoopStatus(runContext.statusPath, 'Test loop', {
|
|
330
|
-
round: testAccepted?.round ?? testRound,
|
|
759
|
+
const testAccepted = await runTestLoop({
|
|
760
|
+
prompt,
|
|
761
|
+
worktreePath: worktree.worktreePath,
|
|
762
|
+
branch: worktree.branch,
|
|
763
|
+
taskPath: runContext.taskPath,
|
|
764
|
+
statusPath: runContext.statusPath,
|
|
331
765
|
maxRounds,
|
|
332
|
-
|
|
333
|
-
|
|
766
|
+
AgentClass,
|
|
767
|
+
verbose,
|
|
768
|
+
jobPatch,
|
|
769
|
+
jobCheckpoint,
|
|
334
770
|
});
|
|
335
771
|
|
|
336
|
-
if (!testAccepted) {
|
|
337
|
-
throw new Error(`test loop exhausted after ${maxRounds} rounds`);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
772
|
// --- code loop: code-writer ⇄ test-runner ---
|
|
341
|
-
let codeAccepted = null;
|
|
342
|
-
let runnerFeedback = null;
|
|
343
|
-
let codeRound = 0;
|
|
344
|
-
let codeSummary = '';
|
|
345
|
-
|
|
346
773
|
const acceptedVerification = [
|
|
347
774
|
testAccepted.verdict.summary,
|
|
348
775
|
testAccepted.writerContent,
|
|
@@ -350,89 +777,26 @@ export async function runPipeline(prompt, options) {
|
|
|
350
777
|
.filter(Boolean)
|
|
351
778
|
.join('\n');
|
|
352
779
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
worktreePath: worktree.worktreePath,
|
|
360
|
-
branch: worktree.branch,
|
|
361
|
-
taskPath: runContext.taskPath,
|
|
362
|
-
statusPath: runContext.statusPath,
|
|
363
|
-
round,
|
|
364
|
-
acceptedVerification,
|
|
365
|
-
runnerFeedback,
|
|
366
|
-
});
|
|
367
|
-
const codeWriter = new AgentClass(
|
|
368
|
-
roundLabel('code-writer', round, maxRounds),
|
|
369
|
-
codeWriterArgs.instructions,
|
|
370
|
-
codeWriterArgs.prompt,
|
|
371
|
-
codeWriterArgs.options,
|
|
372
|
-
);
|
|
373
|
-
|
|
374
|
-
const codeOut = await codeWriter.run({ verbose });
|
|
375
|
-
const { content: codeWriterContent, summary: codeWriterSummary } = splitStageSummary(codeOut.result);
|
|
376
|
-
printStageSummary(roundLabel('code-writer', round, maxRounds), codeWriterSummary);
|
|
377
|
-
if (!codeOut.ok) {
|
|
378
|
-
appendLoopStatus(runContext.statusPath, 'Code loop', {
|
|
379
|
-
round: codeRound,
|
|
380
|
-
maxRounds,
|
|
381
|
-
passed: false,
|
|
382
|
-
summary: 'code-writer failed',
|
|
383
|
-
});
|
|
384
|
-
throw new Error('code-writer failed; stopping before commit');
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const testRunnerArgs = testRunnerAgentArgs({
|
|
388
|
-
prompt,
|
|
389
|
-
cwd: worktree.worktreePath,
|
|
390
|
-
worktreePath: worktree.worktreePath,
|
|
391
|
-
branch: worktree.branch,
|
|
392
|
-
statusPath: runContext.statusPath,
|
|
393
|
-
codeWriterOutput: codeWriterContent,
|
|
394
|
-
});
|
|
395
|
-
const testRunner = new AgentClass(
|
|
396
|
-
roundLabel('test-runner', round, maxRounds),
|
|
397
|
-
testRunnerArgs.instructions,
|
|
398
|
-
testRunnerArgs.prompt,
|
|
399
|
-
testRunnerArgs.options,
|
|
400
|
-
);
|
|
401
|
-
|
|
402
|
-
const runnerOut = await testRunner.run({ verbose });
|
|
403
|
-
const { content: testRunnerContent, summary: testRunnerSummary } = splitStageSummary(runnerOut.result);
|
|
404
|
-
printStageSummary(roundLabel('test-runner', round, maxRounds), testRunnerSummary);
|
|
405
|
-
if (!runnerOut.ok) {
|
|
406
|
-
appendLoopStatus(runContext.statusPath, 'Code loop', {
|
|
407
|
-
round: codeRound,
|
|
408
|
-
maxRounds,
|
|
409
|
-
passed: false,
|
|
410
|
-
summary: 'test-runner failed',
|
|
411
|
-
});
|
|
412
|
-
throw new Error('test-runner failed; stopping before commit');
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const verdict = parseVerdict(testRunnerContent);
|
|
416
|
-
codeSummary = verdict.summary;
|
|
417
|
-
if (verdict.passed) {
|
|
418
|
-
codeAccepted = { writerContent: codeWriterContent, verdict, round };
|
|
419
|
-
break;
|
|
420
|
-
}
|
|
421
|
-
runnerFeedback = formatVerdictFeedback(verdict, testRunnerContent);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
appendLoopStatus(runContext.statusPath, 'Code loop', {
|
|
425
|
-
round: codeAccepted?.round ?? codeRound,
|
|
780
|
+
const codeAccepted = await runCodeLoop({
|
|
781
|
+
prompt,
|
|
782
|
+
worktreePath: worktree.worktreePath,
|
|
783
|
+
branch: worktree.branch,
|
|
784
|
+
taskPath: runContext.taskPath,
|
|
785
|
+
statusPath: runContext.statusPath,
|
|
426
786
|
maxRounds,
|
|
427
|
-
|
|
428
|
-
|
|
787
|
+
AgentClass,
|
|
788
|
+
verbose,
|
|
789
|
+
jobPatch,
|
|
790
|
+
jobCheckpoint,
|
|
791
|
+
acceptedVerification,
|
|
429
792
|
});
|
|
430
793
|
|
|
431
|
-
|
|
432
|
-
throw new Error(`code loop exhausted after ${maxRounds} rounds`);
|
|
433
|
-
}
|
|
434
|
-
|
|
794
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
435
795
|
const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
|
|
796
|
+
const worktreeChanges = collectWorktreeChangesFn({
|
|
797
|
+
worktreePath: worktree.worktreePath,
|
|
798
|
+
});
|
|
799
|
+
printFilesChanged(worktreeChanges);
|
|
436
800
|
const commitResult = commitWorktreeFn({
|
|
437
801
|
worktreePath: worktree.worktreePath,
|
|
438
802
|
branch: worktree.branch,
|
|
@@ -446,6 +810,7 @@ export async function runPipeline(prompt, options) {
|
|
|
446
810
|
);
|
|
447
811
|
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
448
812
|
console.log(`merge: git merge ${commitResult.branch}`);
|
|
813
|
+
console.log(`next: orch continue ${runContext.slug} "…"`);
|
|
449
814
|
} else {
|
|
450
815
|
fs.appendFileSync(
|
|
451
816
|
runContext.statusPath,
|
|
@@ -453,54 +818,2214 @@ export async function runPipeline(prompt, options) {
|
|
|
453
818
|
);
|
|
454
819
|
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
455
820
|
}
|
|
821
|
+
|
|
822
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
823
|
+
state: 'done',
|
|
824
|
+
exitCode: 0,
|
|
825
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
826
|
+
error: null,
|
|
827
|
+
task: prompt,
|
|
828
|
+
});
|
|
456
829
|
} catch (err) {
|
|
457
830
|
console.error(`Error: ${err.message}`);
|
|
831
|
+
if (jobSlug) {
|
|
832
|
+
try {
|
|
833
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
834
|
+
state: 'failed',
|
|
835
|
+
exitCode: 1,
|
|
836
|
+
summary: '',
|
|
837
|
+
error: err.message,
|
|
838
|
+
task: prompt,
|
|
839
|
+
});
|
|
840
|
+
} catch {
|
|
841
|
+
// Best-effort: don't let a job-state write failure mask the real error.
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
console.error(`next: orch continue ${jobSlug ?? '<slug>'} "fix the failure and finish"`);
|
|
458
845
|
process.exit(1);
|
|
459
846
|
}
|
|
460
847
|
}
|
|
461
848
|
|
|
462
|
-
|
|
849
|
+
/**
|
|
850
|
+
* The detach-PARENT path: allocates a run directory, writes an initial
|
|
851
|
+
* `run.json`, spawns a `--detach`-stripped re-invocation of this CLI with
|
|
852
|
+
* `ORCH_JOB_SLUG`/`ORCH_DETACHED` set, patches in the child's pid, and
|
|
853
|
+
* returns immediately. `runPipeline` (the child/pipeline-running path) never
|
|
854
|
+
* runs in this process.
|
|
855
|
+
*/
|
|
856
|
+
export async function runDetached(prompt, options = {}) {
|
|
857
|
+
const {
|
|
858
|
+
agent,
|
|
859
|
+
maxRounds = 5,
|
|
860
|
+
verbose,
|
|
861
|
+
cwd = process.cwd(),
|
|
862
|
+
createRunContext: createRunContextFn = createRunContext,
|
|
863
|
+
spawn: spawnFn = spawn,
|
|
864
|
+
exit = (code) => process.exit(code),
|
|
865
|
+
} = options;
|
|
463
866
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
.
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
.
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
)
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
867
|
+
const backend = AGENT_BACKENDS[agent];
|
|
868
|
+
if (!backend) {
|
|
869
|
+
throw new Error(`Unknown agent backend: ${agent}`);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (!isBinaryOnPath(backend.binary)) {
|
|
873
|
+
console.error(binaryMissingHint(agent));
|
|
874
|
+
exit(1);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const { slug } = allocateJob({
|
|
879
|
+
cwd,
|
|
880
|
+
prompt,
|
|
881
|
+
agent,
|
|
882
|
+
maxRounds,
|
|
883
|
+
state: 'starting',
|
|
884
|
+
createRunContext: createRunContextFn,
|
|
885
|
+
});
|
|
886
|
+
const { logPath } = jobPaths(cwd, slug);
|
|
887
|
+
|
|
888
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
889
|
+
|
|
890
|
+
const childArgs = [__filename, prompt, '--agent', agent, '--max-rounds', String(maxRounds)];
|
|
891
|
+
if (verbose) childArgs.push('--verbose');
|
|
892
|
+
|
|
893
|
+
const child = spawnFn(process.execPath, childArgs, {
|
|
894
|
+
cwd,
|
|
895
|
+
env: { ...process.env, ORCH_JOB_SLUG: slug, ORCH_DETACHED: '1' },
|
|
896
|
+
detached: true,
|
|
897
|
+
stdio: ['ignore', logFd, logFd],
|
|
898
|
+
});
|
|
899
|
+
child.unref();
|
|
900
|
+
|
|
901
|
+
patchJob(cwd, slug, { pid: child.pid, state: 'running' });
|
|
902
|
+
|
|
903
|
+
console.log(`started ${slug} (pid ${child.pid})`);
|
|
904
|
+
exit(0);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Continue pipeline: full complex stages on an existing worktree/branch.
|
|
909
|
+
* Skips triage and `createWorktree`. Injects prior-outcome text into
|
|
910
|
+
* research/planner only. See `.spec/continue.md`.
|
|
911
|
+
*/
|
|
912
|
+
export async function runContinuePipeline(prompt, options = {}) {
|
|
913
|
+
const verbose = Boolean(options.verbose);
|
|
914
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
915
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
916
|
+
if (!backend) {
|
|
917
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
918
|
+
}
|
|
919
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
920
|
+
const cwd = options.cwd ?? process.cwd();
|
|
921
|
+
const {
|
|
922
|
+
slug,
|
|
923
|
+
worktreePath,
|
|
924
|
+
branch,
|
|
925
|
+
role,
|
|
926
|
+
parentSlug,
|
|
927
|
+
workerId,
|
|
928
|
+
priorOutcome,
|
|
929
|
+
continuation,
|
|
930
|
+
} = options;
|
|
931
|
+
|
|
932
|
+
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
933
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
934
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
935
|
+
const patchWorkerFn = options.patchWorker ?? patchWorker;
|
|
936
|
+
const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
|
|
937
|
+
const execFileFn = options.execFile;
|
|
938
|
+
|
|
939
|
+
const jobSlug = options.jobSlug ?? slug ?? process.env.ORCH_JOB_SLUG;
|
|
940
|
+
const jobCwd = options.jobCwd ?? cwd;
|
|
941
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
942
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
943
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
944
|
+
|
|
945
|
+
const jobPatch = (fields) => {
|
|
946
|
+
if (!jobSlug) return;
|
|
947
|
+
patchJobFn(jobCwd, jobSlug, fields);
|
|
948
|
+
};
|
|
949
|
+
const jobCheckpoint = async () => {
|
|
950
|
+
if (!jobSlug) return;
|
|
951
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
if (!options.AgentClass) {
|
|
955
|
+
ensureBinaryOnPath(backend.binary, options.agent);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const priorBlock = buildPriorOutcomeText(priorOutcome, {
|
|
959
|
+
slug,
|
|
960
|
+
continuation,
|
|
961
|
+
worktreePath,
|
|
962
|
+
branch,
|
|
963
|
+
parentSlug,
|
|
964
|
+
workerId,
|
|
965
|
+
});
|
|
966
|
+
const researchPlannerPrompt = `${priorBlock}\n\nUser follow-up:\n${prompt}`;
|
|
967
|
+
|
|
968
|
+
try {
|
|
969
|
+
const runContext = createRunContextFn({ cwd, slug });
|
|
970
|
+
|
|
971
|
+
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
972
|
+
const prior = priorOutcome ?? {};
|
|
973
|
+
const startedIso = new Date().toISOString();
|
|
974
|
+
let continueSection = `\n## Continue ${continuation}\n\n`;
|
|
975
|
+
continueSection += `- Task: ${prompt.split('\n')[0]}\n`;
|
|
976
|
+
continueSection += `- Started: ${startedIso}\n`;
|
|
977
|
+
continueSection += `- Branch: \`${branch}\`\n`;
|
|
978
|
+
continueSection += `- Worktree: \`${worktreePath}\`\n`;
|
|
979
|
+
if (parentSlug) continueSection += `- Fan-out parent: \`${parentSlug}\`\n`;
|
|
980
|
+
if (workerId) continueSection += `- Worker id: \`${workerId}\`\n`;
|
|
981
|
+
continueSection += `\n### Prior outcome\n\n`;
|
|
982
|
+
continueSection += `- State: ${prior.state ?? '(none recorded)'}\n`;
|
|
983
|
+
continueSection += `- Phase: ${prior.phase ?? '(none recorded)'}\n`;
|
|
984
|
+
continueSection += `- Stage: ${prior.stage ?? '(none recorded)'}\n`;
|
|
985
|
+
continueSection += `- Round: ${prior.round ?? 'null'}\n`;
|
|
986
|
+
continueSection += `- Summary: ${prior.summary || '(none recorded)'}\n`;
|
|
987
|
+
if (prior.error) continueSection += `- Error: ${prior.error}\n`;
|
|
988
|
+
fs.appendFileSync(runContext.statusPath, continueSection);
|
|
989
|
+
|
|
990
|
+
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
991
|
+
const research = researchAgentArgs({
|
|
992
|
+
prompt: researchPlannerPrompt,
|
|
993
|
+
cwd: worktreePath,
|
|
994
|
+
researchPath: runContext.researchPath,
|
|
995
|
+
});
|
|
996
|
+
const researchAgent = new AgentClass(
|
|
997
|
+
research.name,
|
|
998
|
+
research.instructions,
|
|
999
|
+
research.prompt,
|
|
1000
|
+
research.options,
|
|
1001
|
+
);
|
|
1002
|
+
const researchResult = await researchAgent.run({ verbose });
|
|
1003
|
+
await jobCheckpoint();
|
|
1004
|
+
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
1005
|
+
printStageSummary('research', researchSummary);
|
|
1006
|
+
|
|
1007
|
+
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
1008
|
+
const planner = plannerAgentArgs({
|
|
1009
|
+
prompt: researchPlannerPrompt,
|
|
1010
|
+
cwd: worktreePath,
|
|
1011
|
+
researchPath: runContext.researchPath,
|
|
1012
|
+
taskPath: runContext.taskPath,
|
|
1013
|
+
researchOutput: researchContent,
|
|
1014
|
+
});
|
|
1015
|
+
const plannerAgent = new AgentClass(
|
|
1016
|
+
planner.name,
|
|
1017
|
+
planner.instructions,
|
|
1018
|
+
planner.prompt,
|
|
1019
|
+
planner.options,
|
|
1020
|
+
);
|
|
1021
|
+
const plannerResult = await plannerAgent.run({ verbose });
|
|
1022
|
+
await jobCheckpoint();
|
|
1023
|
+
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
1024
|
+
printStageSummary('planner', plannerSummary);
|
|
1025
|
+
|
|
1026
|
+
const testAccepted = await runTestLoop({
|
|
1027
|
+
prompt,
|
|
1028
|
+
worktreePath,
|
|
1029
|
+
branch,
|
|
1030
|
+
taskPath: runContext.taskPath,
|
|
1031
|
+
statusPath: runContext.statusPath,
|
|
1032
|
+
maxRounds,
|
|
1033
|
+
AgentClass,
|
|
1034
|
+
verbose,
|
|
1035
|
+
jobPatch,
|
|
1036
|
+
jobCheckpoint,
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
|
|
1040
|
+
.filter(Boolean)
|
|
1041
|
+
.join('\n');
|
|
1042
|
+
|
|
1043
|
+
const codeAccepted = await runCodeLoop({
|
|
1044
|
+
prompt,
|
|
1045
|
+
worktreePath,
|
|
1046
|
+
branch,
|
|
1047
|
+
taskPath: runContext.taskPath,
|
|
1048
|
+
statusPath: runContext.statusPath,
|
|
1049
|
+
maxRounds,
|
|
1050
|
+
AgentClass,
|
|
1051
|
+
verbose,
|
|
1052
|
+
jobPatch,
|
|
1053
|
+
jobCheckpoint,
|
|
1054
|
+
acceptedVerification,
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
1058
|
+
const message = `orch: ${slug} (continue ${continuation}): ${prompt.split('\n')[0]}`;
|
|
1059
|
+
const worktreeChanges = collectWorktreeChangesFn({ worktreePath });
|
|
1060
|
+
printFilesChanged(worktreeChanges);
|
|
1061
|
+
const commitResult = commitWorktreeFn({
|
|
1062
|
+
worktreePath,
|
|
1063
|
+
branch,
|
|
1064
|
+
message,
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
if (commitResult.committed) {
|
|
1068
|
+
fs.appendFileSync(
|
|
1069
|
+
runContext.statusPath,
|
|
1070
|
+
`\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
|
|
1071
|
+
);
|
|
1072
|
+
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
1073
|
+
console.log(`merge: git merge ${commitResult.branch}`);
|
|
1074
|
+
} else {
|
|
1075
|
+
fs.appendFileSync(
|
|
1076
|
+
runContext.statusPath,
|
|
1077
|
+
`\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
|
|
1078
|
+
);
|
|
1079
|
+
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (role === 'worker' && parentSlug && workerId) {
|
|
1083
|
+
let changedFiles = [];
|
|
1084
|
+
try {
|
|
1085
|
+
changedFiles = recordChangedFilesFn({
|
|
1086
|
+
repoRoot: cwd,
|
|
1087
|
+
base: undefined,
|
|
1088
|
+
branch,
|
|
1089
|
+
execFile: execFileFn,
|
|
1090
|
+
});
|
|
1091
|
+
} catch {
|
|
1092
|
+
// Best-effort.
|
|
1093
|
+
}
|
|
1094
|
+
if (commitResult.committed) {
|
|
1095
|
+
patchWorkerFn(cwd, parentSlug, workerId, {
|
|
1096
|
+
state: 'done',
|
|
1097
|
+
sha: commitResult.sha,
|
|
1098
|
+
changedFiles,
|
|
1099
|
+
});
|
|
1100
|
+
console.log(`next: orch --integrate ${parentSlug}`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1105
|
+
state: 'done',
|
|
1106
|
+
exitCode: 0,
|
|
1107
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
1108
|
+
error: null,
|
|
1109
|
+
task: prompt,
|
|
1110
|
+
});
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
console.error(`Error: ${err.message}`);
|
|
1113
|
+
if (role === 'worker' && parentSlug && workerId) {
|
|
1114
|
+
try {
|
|
1115
|
+
patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
|
|
1116
|
+
} catch {
|
|
1117
|
+
// Best-effort.
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
if (jobSlug) {
|
|
1121
|
+
try {
|
|
1122
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1123
|
+
state: 'failed',
|
|
1124
|
+
exitCode: 1,
|
|
1125
|
+
summary: '',
|
|
1126
|
+
error: err.message,
|
|
1127
|
+
task: prompt,
|
|
1128
|
+
});
|
|
1129
|
+
} catch {
|
|
1130
|
+
// Best-effort.
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
process.exit(1);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Detach-parent path for `orch continue`: validate, PATH-check, spawn a
|
|
1139
|
+
* `--detach`-stripped re-invocation, reopen the existing slug with the child
|
|
1140
|
+
* pid, print `started`, exit. Never runs pipeline stages itself.
|
|
1141
|
+
*/
|
|
1142
|
+
export async function runContinueDetached(slug, prompt, options = {}) {
|
|
1143
|
+
const {
|
|
1144
|
+
agent,
|
|
1145
|
+
maxRounds = 5,
|
|
1146
|
+
verbose,
|
|
1147
|
+
cwd = process.cwd(),
|
|
1148
|
+
spawn: spawnFn = spawn,
|
|
1149
|
+
exit = (code) => process.exit(code),
|
|
1150
|
+
validateContinue: validateContinueFn = validateContinue,
|
|
1151
|
+
reopenJob: reopenJobFn = reopenJob,
|
|
1152
|
+
snapshotPriorOutcome: snapshotPriorOutcomeFn = snapshotPriorOutcome,
|
|
1153
|
+
} = options;
|
|
1154
|
+
|
|
1155
|
+
const backend = AGENT_BACKENDS[agent];
|
|
1156
|
+
if (!backend) {
|
|
1157
|
+
throw new Error(`Unknown agent backend: ${agent}`);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
let record;
|
|
1161
|
+
try {
|
|
1162
|
+
record = validateContinueFn(cwd, slug, { task: prompt });
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
console.error(`Error: ${err.message}`);
|
|
1165
|
+
exit(1);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (!isBinaryOnPath(backend.binary)) {
|
|
1170
|
+
console.error(binaryMissingHint(agent));
|
|
1171
|
+
exit(1);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const prior = snapshotPriorOutcomeFn(cwd, slug, record);
|
|
1176
|
+
const { logPath } = jobPaths(cwd, slug);
|
|
1177
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
1178
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
1179
|
+
|
|
1180
|
+
const childArgs = [
|
|
1181
|
+
__filename,
|
|
1182
|
+
'continue',
|
|
1183
|
+
slug,
|
|
1184
|
+
prompt,
|
|
1185
|
+
'--agent',
|
|
1186
|
+
agent,
|
|
1187
|
+
'--max-rounds',
|
|
1188
|
+
String(maxRounds),
|
|
1189
|
+
];
|
|
1190
|
+
if (verbose) childArgs.push('--verbose');
|
|
1191
|
+
|
|
1192
|
+
const child = spawnFn(process.execPath, childArgs, {
|
|
1193
|
+
cwd,
|
|
1194
|
+
env: { ...process.env, ORCH_JOB_SLUG: slug, ORCH_DETACHED: '1' },
|
|
1195
|
+
detached: true,
|
|
1196
|
+
stdio: ['ignore', logFd, logFd],
|
|
1197
|
+
});
|
|
1198
|
+
child.unref();
|
|
1199
|
+
|
|
1200
|
+
const updated = reopenJobFn(cwd, slug, {
|
|
1201
|
+
task: prompt,
|
|
1202
|
+
agent,
|
|
1203
|
+
maxRounds,
|
|
1204
|
+
pid: child.pid,
|
|
1205
|
+
prior,
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
console.log(`started ${slug} (pid ${child.pid}, continuation ${updated.continuation})`);
|
|
1209
|
+
exit(0);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* The `--worker <parent>:<workerId>` driver: skips triage and runs research → planner →
|
|
1214
|
+
* worktree (from the fan-out's recorded `base`) → test loop → code loop (writer-first) →
|
|
1215
|
+
* commit, exactly like `runPipeline` minus triage. `prompt` is the worker's subtask text
|
|
1216
|
+
* with the envelope already appended by the CLI wiring. On success, patches the parent's
|
|
1217
|
+
* `fanout.json.workers[]` entry to `state:'done'` with `sha`/`changedFiles`; on failure,
|
|
1218
|
+
* patches it (and this job's own `run.json`) to `state:'failed'` before exiting non-zero.
|
|
1219
|
+
*/
|
|
1220
|
+
export async function runWorkerPipeline(prompt, options = {}) {
|
|
1221
|
+
const verbose = Boolean(options.verbose);
|
|
1222
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
1223
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
1224
|
+
if (!backend) {
|
|
1225
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
1226
|
+
}
|
|
1227
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
1228
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1229
|
+
const { parentSlug, workerId, base } = options;
|
|
1230
|
+
|
|
1231
|
+
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
1232
|
+
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
1233
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
1234
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
1235
|
+
const patchWorkerFn = options.patchWorker ?? patchWorker;
|
|
1236
|
+
const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
|
|
1237
|
+
const execFileFn = options.execFile;
|
|
1238
|
+
|
|
1239
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
1240
|
+
const jobCwd = options.jobCwd ?? cwd;
|
|
1241
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
1242
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
1243
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
1244
|
+
|
|
1245
|
+
const jobPatch = (fields) => {
|
|
1246
|
+
if (!jobSlug) return;
|
|
1247
|
+
patchJobFn(jobCwd, jobSlug, fields);
|
|
1248
|
+
};
|
|
1249
|
+
const jobCheckpoint = async () => {
|
|
1250
|
+
if (!jobSlug) return;
|
|
1251
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
if (!options.AgentClass) {
|
|
1255
|
+
ensureBinaryOnPath(backend.binary, options.agent);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
try {
|
|
1259
|
+
const runContext = createRunContextFn(jobSlug ? { cwd, slug: jobSlug } : { cwd });
|
|
1260
|
+
|
|
1261
|
+
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
1262
|
+
const research = researchAgentArgs({ prompt, cwd, researchPath: runContext.researchPath });
|
|
1263
|
+
const researchAgent = new AgentClass(
|
|
1264
|
+
research.name,
|
|
1265
|
+
research.instructions,
|
|
1266
|
+
research.prompt,
|
|
1267
|
+
research.options,
|
|
1268
|
+
);
|
|
1269
|
+
const researchResult = await researchAgent.run({ verbose });
|
|
1270
|
+
await jobCheckpoint();
|
|
1271
|
+
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
1272
|
+
printStageSummary('research', researchSummary);
|
|
1273
|
+
|
|
1274
|
+
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
1275
|
+
const planner = plannerAgentArgs({
|
|
1276
|
+
prompt,
|
|
1277
|
+
cwd,
|
|
1278
|
+
researchPath: runContext.researchPath,
|
|
1279
|
+
taskPath: runContext.taskPath,
|
|
1280
|
+
researchOutput: researchContent,
|
|
1281
|
+
});
|
|
1282
|
+
const plannerAgent = new AgentClass(
|
|
1283
|
+
planner.name,
|
|
1284
|
+
planner.instructions,
|
|
1285
|
+
planner.prompt,
|
|
1286
|
+
planner.options,
|
|
1287
|
+
);
|
|
1288
|
+
const plannerResult = await plannerAgent.run({ verbose });
|
|
1289
|
+
await jobCheckpoint();
|
|
1290
|
+
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
1291
|
+
printStageSummary('planner', plannerSummary);
|
|
1292
|
+
|
|
1293
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
1294
|
+
const worktree = createWorktreeFn({ cwd, slug: runContext.slug, base });
|
|
1295
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
1296
|
+
|
|
1297
|
+
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
1298
|
+
fs.writeFileSync(
|
|
1299
|
+
runContext.statusPath,
|
|
1300
|
+
`# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n- Parent: \`${parentSlug}\`\n- Worker: \`${workerId}\`\n`,
|
|
1301
|
+
);
|
|
1302
|
+
|
|
1303
|
+
const testAccepted = await runTestLoop({
|
|
1304
|
+
prompt,
|
|
1305
|
+
worktreePath: worktree.worktreePath,
|
|
1306
|
+
branch: worktree.branch,
|
|
1307
|
+
taskPath: runContext.taskPath,
|
|
1308
|
+
statusPath: runContext.statusPath,
|
|
1309
|
+
maxRounds,
|
|
1310
|
+
AgentClass,
|
|
1311
|
+
verbose,
|
|
1312
|
+
jobPatch,
|
|
1313
|
+
jobCheckpoint,
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
|
|
1317
|
+
.filter(Boolean)
|
|
1318
|
+
.join('\n');
|
|
1319
|
+
|
|
1320
|
+
const codeAccepted = await runCodeLoop({
|
|
1321
|
+
prompt,
|
|
1322
|
+
worktreePath: worktree.worktreePath,
|
|
1323
|
+
branch: worktree.branch,
|
|
1324
|
+
taskPath: runContext.taskPath,
|
|
1325
|
+
statusPath: runContext.statusPath,
|
|
1326
|
+
maxRounds,
|
|
1327
|
+
AgentClass,
|
|
1328
|
+
verbose,
|
|
1329
|
+
jobPatch,
|
|
1330
|
+
jobCheckpoint,
|
|
1331
|
+
acceptedVerification,
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
1335
|
+
const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
|
|
1336
|
+
const worktreeChanges = collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
|
|
1337
|
+
printFilesChanged(worktreeChanges);
|
|
1338
|
+
const commitResult = commitWorktreeFn({
|
|
1339
|
+
worktreePath: worktree.worktreePath,
|
|
1340
|
+
branch: worktree.branch,
|
|
1341
|
+
message,
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
if (commitResult.committed) {
|
|
1345
|
+
fs.appendFileSync(
|
|
1346
|
+
runContext.statusPath,
|
|
1347
|
+
`\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
|
|
1348
|
+
);
|
|
1349
|
+
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
1350
|
+
} else {
|
|
1351
|
+
fs.appendFileSync(
|
|
1352
|
+
runContext.statusPath,
|
|
1353
|
+
`\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
|
|
1354
|
+
);
|
|
1355
|
+
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
let changedFiles = [];
|
|
1359
|
+
try {
|
|
1360
|
+
changedFiles = recordChangedFilesFn({
|
|
1361
|
+
repoRoot: worktree.repoRoot,
|
|
1362
|
+
base,
|
|
1363
|
+
branch: worktree.branch,
|
|
1364
|
+
execFile: execFileFn,
|
|
1365
|
+
});
|
|
1366
|
+
} catch {
|
|
1367
|
+
// Best-effort: changedFiles is informational only, never masks a successful commit.
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
patchWorkerFn(cwd, parentSlug, workerId, {
|
|
1371
|
+
state: 'done',
|
|
1372
|
+
sha: commitResult.sha,
|
|
1373
|
+
changedFiles,
|
|
1374
|
+
});
|
|
1375
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1376
|
+
state: 'done',
|
|
1377
|
+
exitCode: 0,
|
|
1378
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
1379
|
+
error: null,
|
|
1380
|
+
task: prompt,
|
|
1381
|
+
});
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
console.error(`Error: ${err.message}`);
|
|
1384
|
+
try {
|
|
1385
|
+
patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
|
|
1386
|
+
} catch {
|
|
1387
|
+
// Best-effort: don't let a fanout-state write failure mask the real error.
|
|
1388
|
+
}
|
|
1389
|
+
if (jobSlug) {
|
|
1390
|
+
try {
|
|
1391
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1392
|
+
state: 'failed',
|
|
1393
|
+
exitCode: 1,
|
|
1394
|
+
summary: '',
|
|
1395
|
+
error: err.message,
|
|
1396
|
+
task: prompt,
|
|
1397
|
+
});
|
|
1398
|
+
} catch {
|
|
1399
|
+
// Best-effort: don't let a job-state write failure mask the real error.
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
console.error(`next: orch continue ${jobSlug ?? '<slug>'} "fix the failure and finish"`);
|
|
1403
|
+
if (parentSlug) console.error(` orch --integrate ${parentSlug}`);
|
|
1404
|
+
process.exit(1);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* The `--integrate <parent>` driver: reuses (or creates) the integration worktree keyed
|
|
1410
|
+
* by the parent slug, merges `fanout.integration.candidates` in order (repairing conflicts
|
|
1411
|
+
* via the `integrator` agent, one conflict at a time), then runs a runner-first verify
|
|
1412
|
+
* loop and commits on green. Never invokes triage/research/planner/test-writer/test-critic.
|
|
1413
|
+
* Appends every step to `.orch/<job-slug>/integration.md` as it happens.
|
|
1414
|
+
*/
|
|
1415
|
+
export async function runIntegratePipeline(options = {}) {
|
|
1416
|
+
const verbose = Boolean(options.verbose);
|
|
1417
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
1418
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
1419
|
+
if (!backend) {
|
|
1420
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
1421
|
+
}
|
|
1422
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
1423
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1424
|
+
const { parentSlug } = options;
|
|
1425
|
+
|
|
1426
|
+
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
1427
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
1428
|
+
const readFanoutFn = options.readFanout ?? readFanout;
|
|
1429
|
+
const patchIntegrationFn = options.patchIntegration ?? patchIntegration;
|
|
1430
|
+
const mergeBranchesFn = options.mergeBranches ?? mergeBranches;
|
|
1431
|
+
const abortMergeFn = options.abortMerge ?? abortMerge;
|
|
1432
|
+
const conflictedFilesFn = options.conflictedFiles ?? conflictedFiles;
|
|
1433
|
+
const hasConflictMarkersFn = options.hasConflictMarkers ?? hasConflictMarkers;
|
|
1434
|
+
const execFileFn = options.execFile ?? defaultExecFile;
|
|
1435
|
+
|
|
1436
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
1437
|
+
const jobCwd = options.jobCwd ?? cwd;
|
|
1438
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
1439
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
1440
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
1441
|
+
|
|
1442
|
+
const jobPatch = (fields) => {
|
|
1443
|
+
if (!jobSlug) return;
|
|
1444
|
+
patchJobFn(jobCwd, jobSlug, fields);
|
|
1445
|
+
};
|
|
1446
|
+
const jobCheckpoint = async () => {
|
|
1447
|
+
if (!jobSlug) return;
|
|
1448
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
if (!options.AgentClass) {
|
|
1452
|
+
ensureBinaryOnPath(backend.binary, options.agent);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const fanout = readFanoutFn(cwd, parentSlug);
|
|
1456
|
+
if (!fanout) {
|
|
1457
|
+
console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
|
|
1458
|
+
process.exit(1);
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
const integrationSlug = jobSlug ?? parentSlug;
|
|
1463
|
+
const integrationDir = path.join(jobCwd, '.orch', integrationSlug);
|
|
1464
|
+
const integrationMdPath = path.join(integrationDir, 'integration.md');
|
|
1465
|
+
const logIntegration = (line) => {
|
|
1466
|
+
fs.mkdirSync(integrationDir, { recursive: true });
|
|
1467
|
+
fs.appendFileSync(integrationMdPath, `${line}\n`);
|
|
1468
|
+
};
|
|
1469
|
+
|
|
1470
|
+
let merged = [...fanout.integration.merged];
|
|
1471
|
+
let skipped = [...(fanout.integration.skipped ?? [])];
|
|
1472
|
+
|
|
1473
|
+
try {
|
|
1474
|
+
fs.mkdirSync(integrationDir, { recursive: true });
|
|
1475
|
+
fs.appendFileSync(integrationMdPath, `# Integration: ${parentSlug}\n\n`);
|
|
1476
|
+
|
|
1477
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
1478
|
+
|
|
1479
|
+
const reuseWorktreePath = `${cwd}-${parentSlug}`;
|
|
1480
|
+
const expectedBranch = `orch/${parentSlug}`;
|
|
1481
|
+
let worktree = null;
|
|
1482
|
+
|
|
1483
|
+
if (fs.existsSync(reuseWorktreePath)) {
|
|
1484
|
+
const currentBranch = execFileFn('git', ['-C', reuseWorktreePath, 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
1485
|
+
if (currentBranch === expectedBranch) {
|
|
1486
|
+
worktree = { repoRoot: cwd, worktreePath: reuseWorktreePath, branch: expectedBranch };
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const reused = Boolean(worktree);
|
|
1491
|
+
if (!worktree) {
|
|
1492
|
+
worktree = createWorktreeFn({ cwd, slug: parentSlug, base: fanout.base });
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
logIntegration(
|
|
1496
|
+
reused
|
|
1497
|
+
? `- Reused existing worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`
|
|
1498
|
+
: `- Created worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`,
|
|
1499
|
+
);
|
|
1500
|
+
|
|
1501
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
1502
|
+
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
1503
|
+
worktree: current.worktree ?? worktree.worktreePath,
|
|
1504
|
+
branch: current.branch ?? worktree.branch,
|
|
1505
|
+
}));
|
|
1506
|
+
|
|
1507
|
+
let remaining = fanout.integration.candidates.filter(
|
|
1508
|
+
(branch) => !merged.includes(branch) && !skipped.includes(branch),
|
|
1509
|
+
);
|
|
1510
|
+
|
|
1511
|
+
while (remaining.length > 0) {
|
|
1512
|
+
const results = mergeBranchesFn({
|
|
1513
|
+
cwd: worktree.worktreePath,
|
|
1514
|
+
candidates: remaining,
|
|
1515
|
+
merged,
|
|
1516
|
+
overlappingFiles: fanout.integration.overlappingFiles,
|
|
1517
|
+
execFile: execFileFn,
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
for (const result of results) {
|
|
1521
|
+
if (result.status === 'skipped') continue;
|
|
1522
|
+
|
|
1523
|
+
if (result.status === 'merged') {
|
|
1524
|
+
merged.push(result.branch);
|
|
1525
|
+
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
1526
|
+
merged: [...current.merged, result.branch],
|
|
1527
|
+
}));
|
|
1528
|
+
logIntegration(`- Merged \`${result.branch}\` cleanly.`);
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// status === 'conflict'
|
|
1533
|
+
logIntegration(`- Conflict merging \`${result.branch}\`; entering repair.`);
|
|
1534
|
+
patchIntegrationFn(cwd, parentSlug, { state: 'repairing' });
|
|
1535
|
+
|
|
1536
|
+
const conflicted = conflictedFilesFn({ cwd: worktree.worktreePath, execFile: execFileFn });
|
|
1537
|
+
const involvedWorkers = fanout.workers
|
|
1538
|
+
.filter((worker) => worker.branch === result.branch)
|
|
1539
|
+
.map(({ id, title, subtask, area }) => ({ id, title, subtask, area }));
|
|
1540
|
+
|
|
1541
|
+
jobPatch({ phase: 'integrate', stage: 'integrator', round: null });
|
|
1542
|
+
const integratorArgs = integratorAgentArgs({
|
|
1543
|
+
prompt: `Resolve the merge conflict from combining \`${result.branch}\` into the integration branch for "${fanout.task}".`,
|
|
1544
|
+
cwd: worktree.worktreePath,
|
|
1545
|
+
conflictedFiles: conflicted,
|
|
1546
|
+
mergeOutput: result.output,
|
|
1547
|
+
involvedWorkers,
|
|
1548
|
+
});
|
|
1549
|
+
const integratorAgent = new AgentClass(
|
|
1550
|
+
'integrator',
|
|
1551
|
+
integratorArgs.instructions,
|
|
1552
|
+
integratorArgs.prompt,
|
|
1553
|
+
integratorArgs.options,
|
|
1554
|
+
);
|
|
1555
|
+
|
|
1556
|
+
let integratorOk = false;
|
|
1557
|
+
try {
|
|
1558
|
+
const integratorOut = await integratorAgent.run({ verbose });
|
|
1559
|
+
await jobCheckpoint();
|
|
1560
|
+
const { summary: integratorSummary } = splitStageSummary(integratorOut.result);
|
|
1561
|
+
printStageSummary('integrator', integratorSummary);
|
|
1562
|
+
integratorOk = Boolean(integratorOut.ok);
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
logIntegration(`- Integrator agent errored: ${err.message}`);
|
|
1565
|
+
integratorOk = false;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const stillConflicted = integratorOk
|
|
1569
|
+
? hasConflictMarkersFn({ cwd: worktree.worktreePath, execFile: execFileFn })
|
|
1570
|
+
: true;
|
|
1571
|
+
|
|
1572
|
+
if (!stillConflicted) {
|
|
1573
|
+
execFileFn('git', ['-C', worktree.worktreePath, 'commit']);
|
|
1574
|
+
merged.push(result.branch);
|
|
1575
|
+
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
1576
|
+
merged: [...current.merged, result.branch],
|
|
1577
|
+
}));
|
|
1578
|
+
logIntegration(`- Integrator resolved conflicts in \`${result.branch}\`; merge completed.`);
|
|
1579
|
+
} else {
|
|
1580
|
+
abortMergeFn({ cwd: worktree.worktreePath, execFile: execFileFn });
|
|
1581
|
+
skipped.push(result.branch);
|
|
1582
|
+
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
1583
|
+
skipped: [...(current.skipped ?? []), result.branch],
|
|
1584
|
+
}));
|
|
1585
|
+
logIntegration(`- Conflicts in \`${result.branch}\` remained unresolved; aborted merge and skipped.`);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
remaining = remaining.slice(results.length);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// --- runner-first verify loop: test-runner first, code-writer only on failure ---
|
|
1593
|
+
const codeAccepted = await runCodeLoop({
|
|
1594
|
+
prompt: fanout.task,
|
|
1595
|
+
worktreePath: worktree.worktreePath,
|
|
1596
|
+
branch: worktree.branch,
|
|
1597
|
+
taskPath: path.join(integrationDir, 'task.md'),
|
|
1598
|
+
statusPath: integrationMdPath,
|
|
1599
|
+
maxRounds,
|
|
1600
|
+
AgentClass,
|
|
1601
|
+
verbose,
|
|
1602
|
+
jobPatch,
|
|
1603
|
+
jobCheckpoint,
|
|
1604
|
+
acceptedVerification: '',
|
|
1605
|
+
runnerFirst: true,
|
|
1606
|
+
loopTitle: 'Verify loop',
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
1610
|
+
const message = `orch: ${parentSlug} ${fanout.task.split('\n')[0]}`;
|
|
1611
|
+
const commitResult = commitWorktreeFn({
|
|
1612
|
+
worktreePath: worktree.worktreePath,
|
|
1613
|
+
branch: `orch/${parentSlug}`,
|
|
1614
|
+
message,
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
logIntegration(
|
|
1618
|
+
commitResult.committed
|
|
1619
|
+
? `- Committed \`${commitResult.sha}\` on \`${commitResult.branch}\`.`
|
|
1620
|
+
: `- No changes to commit on \`${commitResult.branch}\`.`,
|
|
1621
|
+
);
|
|
1622
|
+
|
|
1623
|
+
patchIntegrationFn(cwd, parentSlug, {
|
|
1624
|
+
state: 'done',
|
|
1625
|
+
sha: commitResult.sha,
|
|
1626
|
+
merged,
|
|
1627
|
+
skipped,
|
|
1628
|
+
});
|
|
1629
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1630
|
+
state: 'done',
|
|
1631
|
+
exitCode: 0,
|
|
1632
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
1633
|
+
error: null,
|
|
1634
|
+
task: fanout.task,
|
|
1635
|
+
});
|
|
1636
|
+
} catch (err) {
|
|
1637
|
+
console.error(`Error: ${err.message}`);
|
|
1638
|
+
try {
|
|
1639
|
+
logIntegration(`- Error: ${err.message}`);
|
|
1640
|
+
} catch {
|
|
1641
|
+
// Best-effort: don't let a log write failure mask the real error.
|
|
1642
|
+
}
|
|
1643
|
+
try {
|
|
1644
|
+
patchIntegrationFn(cwd, parentSlug, { state: 'failed', merged, skipped });
|
|
1645
|
+
} catch {
|
|
1646
|
+
// Best-effort: don't let a fanout-state write failure mask the real error.
|
|
1647
|
+
}
|
|
1648
|
+
if (jobSlug) {
|
|
1649
|
+
try {
|
|
1650
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1651
|
+
state: 'failed',
|
|
1652
|
+
exitCode: 1,
|
|
1653
|
+
summary: '',
|
|
1654
|
+
error: err.message,
|
|
1655
|
+
task: fanout?.task,
|
|
1656
|
+
});
|
|
1657
|
+
} catch {
|
|
1658
|
+
// Best-effort: don't let a job-state write failure mask the real error.
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
process.exit(1);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function sleep(ms) {
|
|
1666
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/** Sentinel thrown by the coordinator's scheduling loop when a SIGINT/SIGTERM/SIGHUP
|
|
1670
|
+
* cascade fires mid-flight, so the pending awaits unwind without a second exit call. */
|
|
1671
|
+
class FanoutInterrupted extends Error {}
|
|
1672
|
+
|
|
1673
|
+
/**
|
|
1674
|
+
* SIGINT/SIGHUP/SIGTERM cascade: reads `fanout.json`, resolves every worker's and the
|
|
1675
|
+
* integration session's own pid via its `run.json`, filters through `isPidAlive`, and
|
|
1676
|
+
* SIGTERMs each live one. Never touches worktrees. Composes with (does not replace)
|
|
1677
|
+
* `lib/agent.js`'s existing `shutdown()` — the coordinator's own signal handling calls
|
|
1678
|
+
* both this and the normal in-process agent-CLI reaping.
|
|
1679
|
+
*/
|
|
1680
|
+
export function cascadeStopFanoutChildren(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
|
|
1681
|
+
const fanout = readFanout(cwd, parentSlug);
|
|
1682
|
+
if (!fanout) return;
|
|
1683
|
+
|
|
1684
|
+
const slugs = fanout.workers.filter((worker) => worker.slug).map((worker) => worker.slug);
|
|
1685
|
+
if (fanout.integration?.slug) slugs.push(fanout.integration.slug);
|
|
1686
|
+
|
|
1687
|
+
for (const slug of slugs) {
|
|
1688
|
+
const record = readJob(cwd, slug);
|
|
1689
|
+
if (!record) continue;
|
|
1690
|
+
if (isPidAliveFn(record.pid)) {
|
|
1691
|
+
kill(record.pid, 'SIGTERM');
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* CLI / management cascade stop: SIGTERM the parent pid if alive, then every
|
|
1698
|
+
* live child pid (via `cascadeStopFanoutChildren`). The coordinator signal
|
|
1699
|
+
* handler keeps calling the child-only helper so it does not re-signal itself.
|
|
1700
|
+
*/
|
|
1701
|
+
export function cascadeStop(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
|
|
1702
|
+
const parent = readJob(cwd, parentSlug);
|
|
1703
|
+
if (parent && isPidAliveFn(parent.pid)) {
|
|
1704
|
+
kill(parent.pid, 'SIGTERM');
|
|
1705
|
+
}
|
|
1706
|
+
cascadeStopFanoutChildren(cwd, parentSlug, { kill, isPidAlive: isPidAliveFn });
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* The `--fan-out` coordinator: triage → boundaries → decompose → schedule workers →
|
|
1711
|
+
* overlap detection → spawn integrate → report. Never creates its own worktree or runs
|
|
1712
|
+
* implementer stages itself — those only happen on the decline path (today's
|
|
1713
|
+
* single-worktree pipeline, reusing `runTestLoop`/`runCodeLoop`) or inside spawned
|
|
1714
|
+
* children. See `.spec/fanout-3-coordinator.md` and `.spec/fanout.md`.
|
|
1715
|
+
*/
|
|
1716
|
+
export async function runFanoutPipeline(prompt, options = {}) {
|
|
1717
|
+
const verbose = Boolean(options.verbose);
|
|
1718
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
1719
|
+
const maxWorkers = options.maxWorkers ?? 4;
|
|
1720
|
+
const maxConcurrency = options.maxConcurrency ?? null;
|
|
1721
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
1722
|
+
if (!backend) {
|
|
1723
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
1724
|
+
}
|
|
1725
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
1726
|
+
const invocationCwd = options.cwd ?? process.cwd();
|
|
1727
|
+
|
|
1728
|
+
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
1729
|
+
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
1730
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
1731
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
1732
|
+
const spawnFn = options.spawn ?? spawn;
|
|
1733
|
+
const execFileFn = options.execFile ?? defaultExecFile;
|
|
1734
|
+
const allocateJobFn = options.allocateJob ?? allocateJob;
|
|
1735
|
+
const reconcileJobFn = options.reconcileJob ?? reconcileJob;
|
|
1736
|
+
const readFanoutFn = options.readFanout ?? readFanout;
|
|
1737
|
+
const writeFanoutFn = options.writeFanout ?? writeFanout;
|
|
1738
|
+
const patchWorkerFn = options.patchWorker ?? patchWorker;
|
|
1739
|
+
const patchIntegrationFn = options.patchIntegration ?? patchIntegration;
|
|
1740
|
+
const exitFn = options.exit ?? ((code) => process.exit(code));
|
|
1741
|
+
const pollIntervalMs = options.pollIntervalMs ?? 500;
|
|
1742
|
+
|
|
1743
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
1744
|
+
const jobCwd = options.jobCwd ?? invocationCwd;
|
|
1745
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
1746
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
1747
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
1748
|
+
|
|
1749
|
+
const jobPatch = (fields) => {
|
|
1750
|
+
if (!jobSlug) return;
|
|
1751
|
+
patchJobFn(jobCwd, jobSlug, fields);
|
|
1752
|
+
};
|
|
1753
|
+
const jobCheckpoint = async () => {
|
|
1754
|
+
if (!jobSlug) return;
|
|
1755
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1758
|
+
if (!options.AgentClass) {
|
|
1759
|
+
ensureBinaryOnPath(backend.binary, options.agent);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
console.log(`cwd: ${invocationCwd}`);
|
|
1763
|
+
console.log(`agent: ${options.agent}`);
|
|
1764
|
+
console.log();
|
|
1765
|
+
|
|
1766
|
+
let interrupted = false;
|
|
1767
|
+
const onSignal = (signal) => {
|
|
1768
|
+
interrupted = true;
|
|
1769
|
+
try {
|
|
1770
|
+
cascadeStopFanoutChildren(invocationCwd, jobSlug);
|
|
1771
|
+
} catch {
|
|
1772
|
+
// Best-effort: never let the cascade itself block shutdown.
|
|
1773
|
+
}
|
|
1774
|
+
if (jobSlug) {
|
|
1775
|
+
try {
|
|
1776
|
+
const exitCode = exitCodeForSignal(signal);
|
|
1777
|
+
const finishedAt = new Date().toISOString();
|
|
1778
|
+
patchJobFn(jobCwd, jobSlug, (current) => ({
|
|
1779
|
+
state: 'stopped',
|
|
1780
|
+
exitCode,
|
|
1781
|
+
finishedAt,
|
|
1782
|
+
lastOutcome: buildLastOutcome({
|
|
1783
|
+
state: 'stopped',
|
|
1784
|
+
phase: current.phase,
|
|
1785
|
+
stage: current.stage,
|
|
1786
|
+
round: current.round,
|
|
1787
|
+
exitCode,
|
|
1788
|
+
finishedAt,
|
|
1789
|
+
task: prompt,
|
|
1790
|
+
summary: '',
|
|
1791
|
+
error: null,
|
|
1792
|
+
}),
|
|
1793
|
+
}));
|
|
1794
|
+
} catch {
|
|
1795
|
+
// Best-effort: don't let a job-state write failure block shutdown.
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
exitFn(exitCodeForSignal(signal));
|
|
1799
|
+
};
|
|
1800
|
+
process.on('SIGINT', () => onSignal('SIGINT'));
|
|
1801
|
+
process.on('SIGTERM', () => onSignal('SIGTERM'));
|
|
1802
|
+
process.on('SIGHUP', () => onSignal('SIGHUP'));
|
|
1803
|
+
|
|
1804
|
+
try {
|
|
1805
|
+
jobPatch({ phase: 'triage', stage: 'triage', round: null });
|
|
1806
|
+
const triage = triageAgentArgs({ prompt, cwd: invocationCwd });
|
|
1807
|
+
const triageAgent = new AgentClass(triage.name, triage.instructions, triage.prompt, triage.options);
|
|
1808
|
+
const triageResult = await triageAgent.run({ verbose });
|
|
1809
|
+
await jobCheckpoint();
|
|
1810
|
+
const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
|
|
1811
|
+
printStageSummary('triage', triageSummary);
|
|
1812
|
+
const parsed = parseTriageJson(triageContent);
|
|
1813
|
+
|
|
1814
|
+
if (parsed?.simple === true) {
|
|
1815
|
+
console.log('triage: simple — fan-out skipped (quick-fix)');
|
|
1816
|
+
jobPatch({ phase: 'quick-fix', stage: 'quick-fix', round: null });
|
|
1817
|
+
const quickFix = quickFixAgentArgs({ prompt, cwd: invocationCwd, fix_plan: parsed.fix_plan });
|
|
1818
|
+
const quickFixTracker = new FileTracker({ cwd: invocationCwd });
|
|
1819
|
+
const quickFixAgent = new AgentClass(
|
|
1820
|
+
quickFix.name,
|
|
1821
|
+
quickFix.instructions,
|
|
1822
|
+
quickFix.prompt,
|
|
1823
|
+
{ ...quickFix.options, fileTracker: quickFixTracker },
|
|
1824
|
+
);
|
|
1825
|
+
const quickFixResult = await quickFixAgent.run({ verbose });
|
|
1826
|
+
await jobCheckpoint();
|
|
1827
|
+
const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
1828
|
+
printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
|
|
1829
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
1830
|
+
exitFn(0);
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
console.log('triage: complex — fan-out requested');
|
|
1835
|
+
|
|
1836
|
+
// --- boundaries: partitionability research only, runs exactly once ---
|
|
1837
|
+
const boundariesPath = path.join(jobCwd, '.orch', jobSlug, 'boundaries.md');
|
|
1838
|
+
jobPatch({ phase: 'boundaries', stage: 'boundaries', round: null });
|
|
1839
|
+
const boundaries = boundariesAgentArgs({ prompt, cwd: invocationCwd, boundariesPath });
|
|
1840
|
+
const boundariesAgent = new AgentClass(boundaries.name, boundaries.instructions, boundaries.prompt, boundaries.options);
|
|
1841
|
+
const boundariesResult = await boundariesAgent.run({ verbose });
|
|
1842
|
+
await jobCheckpoint();
|
|
1843
|
+
const { content: boundariesOutput, summary: boundariesSummary } = splitStageSummary(boundariesResult.result);
|
|
1844
|
+
printStageSummary('boundaries', boundariesSummary);
|
|
1845
|
+
console.log(`boundaries: ${boundariesSummary || 'done'}`);
|
|
1846
|
+
|
|
1847
|
+
// --- decomposer: up to two repair round-trips on validation failure ---
|
|
1848
|
+
let feedback;
|
|
1849
|
+
let decision = null;
|
|
1850
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
1851
|
+
jobPatch({ phase: 'decompose', stage: 'decomposer', round: attempt });
|
|
1852
|
+
const decomposer = decomposerAgentArgs({
|
|
1853
|
+
prompt, cwd: invocationCwd, boundariesOutput, maxWorkers, feedback,
|
|
1854
|
+
});
|
|
1855
|
+
const decomposerAgent = new AgentClass(decomposer.name, decomposer.instructions, decomposer.prompt, decomposer.options);
|
|
1856
|
+
const decomposerResult = await decomposerAgent.run({ verbose });
|
|
1857
|
+
await jobCheckpoint();
|
|
1858
|
+
const { content: decomposerContent, summary: decomposerSummary } = splitStageSummary(decomposerResult.result);
|
|
1859
|
+
printStageSummary('decomposer', decomposerSummary);
|
|
1860
|
+
|
|
1861
|
+
const parsedDecomposition = parseDecomposition(decomposerContent);
|
|
1862
|
+
if (!parsedDecomposition) {
|
|
1863
|
+
feedback = ['decomposer output was not valid JSON'];
|
|
1864
|
+
if (attempt === 3) decision = { decline: true, why: 'decomposer did not return valid JSON after repair attempts' };
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
if (parsedDecomposition.decomposable === false) {
|
|
1868
|
+
decision = { decline: true, why: parsedDecomposition.why };
|
|
1869
|
+
break;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
const violations = validateDecomposition(parsedDecomposition, { maxWorkers });
|
|
1873
|
+
if (violations.length === 0) {
|
|
1874
|
+
decision = { decline: false, decomposition: parsedDecomposition };
|
|
1875
|
+
break;
|
|
1876
|
+
}
|
|
1877
|
+
feedback = violations;
|
|
1878
|
+
if (attempt === 3) {
|
|
1879
|
+
decision = { decline: true, why: `decomposition still invalid after repairs: ${violations.join('; ')}` };
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
if (decision.decline) {
|
|
1884
|
+
console.log(`decomposer: declined — ${decision.why}`);
|
|
1885
|
+
console.log('falling through to the single-worktree pipeline');
|
|
1886
|
+
|
|
1887
|
+
const runContext = createRunContextFn(jobSlug ? { cwd: invocationCwd, slug: jobSlug } : { cwd: invocationCwd });
|
|
1888
|
+
|
|
1889
|
+
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
1890
|
+
const research = researchAgentArgs({ prompt, cwd: invocationCwd, researchPath: runContext.researchPath });
|
|
1891
|
+
const researchAgent = new AgentClass(research.name, research.instructions, research.prompt, research.options);
|
|
1892
|
+
const researchResult = await researchAgent.run({ verbose });
|
|
1893
|
+
await jobCheckpoint();
|
|
1894
|
+
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
1895
|
+
printStageSummary('research', researchSummary);
|
|
1896
|
+
|
|
1897
|
+
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
1898
|
+
const planner = plannerAgentArgs({
|
|
1899
|
+
prompt, cwd: invocationCwd, researchPath: runContext.researchPath, taskPath: runContext.taskPath, researchOutput: researchContent,
|
|
1900
|
+
});
|
|
1901
|
+
const plannerAgent = new AgentClass(planner.name, planner.instructions, planner.prompt, planner.options);
|
|
1902
|
+
const plannerResult = await plannerAgent.run({ verbose });
|
|
1903
|
+
await jobCheckpoint();
|
|
1904
|
+
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
1905
|
+
printStageSummary('planner', plannerSummary);
|
|
1906
|
+
|
|
1907
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
1908
|
+
const worktree = createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
|
|
1909
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
1910
|
+
|
|
1911
|
+
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
1912
|
+
fs.writeFileSync(
|
|
1913
|
+
runContext.statusPath,
|
|
1914
|
+
`# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n`,
|
|
1915
|
+
);
|
|
1916
|
+
|
|
1917
|
+
const testAccepted = await runTestLoop({
|
|
1918
|
+
prompt,
|
|
1919
|
+
worktreePath: worktree.worktreePath,
|
|
1920
|
+
branch: worktree.branch,
|
|
1921
|
+
taskPath: runContext.taskPath,
|
|
1922
|
+
statusPath: runContext.statusPath,
|
|
1923
|
+
maxRounds,
|
|
1924
|
+
AgentClass,
|
|
1925
|
+
verbose,
|
|
1926
|
+
jobPatch,
|
|
1927
|
+
jobCheckpoint,
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent].filter(Boolean).join('\n');
|
|
1931
|
+
|
|
1932
|
+
await runCodeLoop({
|
|
1933
|
+
prompt,
|
|
1934
|
+
worktreePath: worktree.worktreePath,
|
|
1935
|
+
branch: worktree.branch,
|
|
1936
|
+
taskPath: runContext.taskPath,
|
|
1937
|
+
statusPath: runContext.statusPath,
|
|
1938
|
+
maxRounds,
|
|
1939
|
+
AgentClass,
|
|
1940
|
+
verbose,
|
|
1941
|
+
jobPatch,
|
|
1942
|
+
jobCheckpoint,
|
|
1943
|
+
acceptedVerification,
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
1947
|
+
const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
|
|
1948
|
+
const worktreeChanges = collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
|
|
1949
|
+
printFilesChanged(worktreeChanges);
|
|
1950
|
+
const commitResult = commitWorktreeFn({ worktreePath: worktree.worktreePath, branch: worktree.branch, message });
|
|
1951
|
+
|
|
1952
|
+
if (commitResult.committed) {
|
|
1953
|
+
fs.appendFileSync(
|
|
1954
|
+
runContext.statusPath,
|
|
1955
|
+
`\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
|
|
1956
|
+
);
|
|
1957
|
+
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
1958
|
+
console.log(`merge: git merge ${commitResult.branch}`);
|
|
1959
|
+
} else {
|
|
1960
|
+
fs.appendFileSync(
|
|
1961
|
+
runContext.statusPath,
|
|
1962
|
+
`\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
|
|
1963
|
+
);
|
|
1964
|
+
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
1968
|
+
exitFn(0);
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// --- validated: bootstrap fanout.json, never give the coordinator a worktree ---
|
|
1973
|
+
const { decomposition } = decision;
|
|
1974
|
+
const base = execFileFn('git', ['-C', invocationCwd, 'rev-parse', 'HEAD']).trim();
|
|
1975
|
+
|
|
1976
|
+
const workers = decomposition.workers.map((worker) => ({
|
|
1977
|
+
id: worker.id,
|
|
1978
|
+
title: worker.title,
|
|
1979
|
+
subtask: worker.scaffold ? ensureScaffoldSubtask(worker.subtask) : worker.subtask,
|
|
1980
|
+
area: worker.area,
|
|
1981
|
+
owns: worker.owns ?? [],
|
|
1982
|
+
dependsOn: worker.dependsOn ?? [],
|
|
1983
|
+
scaffold: Boolean(worker.scaffold),
|
|
1984
|
+
slug: null,
|
|
1985
|
+
branch: null,
|
|
1986
|
+
state: 'pending',
|
|
1987
|
+
sha: null,
|
|
1988
|
+
changedFiles: [],
|
|
1989
|
+
overlaps: [],
|
|
1990
|
+
}));
|
|
1991
|
+
|
|
1992
|
+
writeFanoutFn(invocationCwd, jobSlug, {
|
|
1993
|
+
parentSlug: jobSlug,
|
|
1994
|
+
task: prompt,
|
|
1995
|
+
base,
|
|
1996
|
+
maxWorkers,
|
|
1997
|
+
maxConcurrency,
|
|
1998
|
+
concurrency: workers.length,
|
|
1999
|
+
state: 'running',
|
|
2000
|
+
workers,
|
|
2001
|
+
integration: {
|
|
2002
|
+
slug: null,
|
|
2003
|
+
pid: null,
|
|
2004
|
+
branch: null,
|
|
2005
|
+
worktree: null,
|
|
2006
|
+
candidates: [],
|
|
2007
|
+
merged: [],
|
|
2008
|
+
skipped: [],
|
|
2009
|
+
overlappingFiles: [],
|
|
2010
|
+
state: 'pending',
|
|
2011
|
+
sha: null,
|
|
2012
|
+
},
|
|
2013
|
+
startedAt: new Date().toISOString(),
|
|
2014
|
+
finishedAt: null,
|
|
2015
|
+
});
|
|
2016
|
+
|
|
2017
|
+
console.log(`decomposer: split into ${workers.length} worker${workers.length === 1 ? '' : 's'}`);
|
|
2018
|
+
|
|
2019
|
+
const spawnWorkerChild = (worker) => {
|
|
2020
|
+
const siblingTitles = workers.filter((w) => w.id !== worker.id).map((w) => w.title).filter(Boolean);
|
|
2021
|
+
const envelope = buildWorkerEnvelope({
|
|
2022
|
+
subtask: worker.subtask, area: worker.area, scaffold: worker.scaffold, siblingTitles,
|
|
2023
|
+
});
|
|
2024
|
+
const workerPrompt = `${worker.subtask}\n\n${envelope}`;
|
|
2025
|
+
|
|
2026
|
+
const { slug: workerSlug } = allocateJobFn({
|
|
2027
|
+
cwd: invocationCwd,
|
|
2028
|
+
prompt: workerPrompt,
|
|
2029
|
+
agent: options.agent,
|
|
2030
|
+
maxRounds,
|
|
2031
|
+
state: 'starting',
|
|
2032
|
+
parent: jobSlug,
|
|
2033
|
+
role: 'worker',
|
|
2034
|
+
workerId: worker.id,
|
|
2035
|
+
});
|
|
2036
|
+
patchWorkerFn(invocationCwd, jobSlug, worker.id, {
|
|
2037
|
+
slug: workerSlug,
|
|
2038
|
+
branch: `orch/${workerSlug}`,
|
|
2039
|
+
state: 'running',
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
const { logPath } = jobPaths(invocationCwd, workerSlug);
|
|
2043
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
2044
|
+
const childArgs = [
|
|
2045
|
+
__filename, workerPrompt,
|
|
2046
|
+
'--agent', options.agent,
|
|
2047
|
+
'--max-rounds', String(maxRounds),
|
|
2048
|
+
'--worker', `${jobSlug}:${worker.id}`,
|
|
2049
|
+
];
|
|
2050
|
+
const child = spawnFn(process.execPath, childArgs, {
|
|
2051
|
+
cwd: invocationCwd,
|
|
2052
|
+
env: { ...process.env, ORCH_JOB_SLUG: workerSlug, ORCH_DETACHED: '1', ORCH_FANOUT_DEPTH: '1' },
|
|
2053
|
+
detached: true,
|
|
2054
|
+
stdio: ['ignore', logFd, logFd],
|
|
2055
|
+
});
|
|
2056
|
+
child.unref();
|
|
2057
|
+
patchJobFn(invocationCwd, workerSlug, { pid: child.pid, state: 'running' });
|
|
2058
|
+
console.log(`[${worker.id} ${workerSlug}] running`);
|
|
2059
|
+
return workerSlug;
|
|
2060
|
+
};
|
|
2061
|
+
|
|
2062
|
+
// A worker child self-patches its fanout.json entry to 'done'/'failed' the moment it
|
|
2063
|
+
// finishes (see runWorkerPipeline) — that is the primary settlement signal. reconcileJob
|
|
2064
|
+
// (dead-pid detection) is only consulted once a worker has been in-flight past a grace
|
|
2065
|
+
// period, so a worker that simply hasn't self-reported yet is never mistaken for a crash.
|
|
2066
|
+
const CRASH_CHECK_GRACE_MS = Math.max(pollIntervalMs * 10, 200);
|
|
2067
|
+
|
|
2068
|
+
const settleWorker = (workerId, workerSlug, spawnedAt) => {
|
|
2069
|
+
const fanoutWorker = readFanoutFn(invocationCwd, jobSlug).workers.find((w) => w.id === workerId);
|
|
2070
|
+
if (fanoutWorker.state === 'done' || fanoutWorker.state === 'failed') {
|
|
2071
|
+
console.log(`[${workerId} ${workerSlug}] ${fanoutWorker.state}`);
|
|
2072
|
+
return true;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
if (Date.now() - spawnedAt < CRASH_CHECK_GRACE_MS) return false;
|
|
2076
|
+
|
|
2077
|
+
const record = reconcileJobFn(invocationCwd, workerSlug, readJob(invocationCwd, workerSlug));
|
|
2078
|
+
if (!TERMINAL_JOB_STATES.includes(record.state)) return false;
|
|
2079
|
+
|
|
2080
|
+
if (fanoutWorker.state !== 'done' && fanoutWorker.state !== 'failed') {
|
|
2081
|
+
patchWorkerFn(invocationCwd, jobSlug, workerId, { state: 'failed' });
|
|
2082
|
+
}
|
|
2083
|
+
console.log(`[${workerId} ${workerSlug}] failed`);
|
|
2084
|
+
return true;
|
|
2085
|
+
};
|
|
2086
|
+
|
|
2087
|
+
/** Spawns up to `concurrency` of `workerIds` at a time, polling to terminal state.
|
|
2088
|
+
* Honors `jobCheckpoint` before each spawn and on each poll tick; while paused does
|
|
2089
|
+
* not spawn or advance. Re-attaches to still-live children instead of re-spawning;
|
|
2090
|
+
* skips workers already `done`/`failed`/`skipped`. */
|
|
2091
|
+
const runWorkerGroup = async (workerIds, concurrency) => {
|
|
2092
|
+
const byId = new Map(workers.map((w) => [w.id, w]));
|
|
2093
|
+
const pending = [...workerIds];
|
|
2094
|
+
const active = new Map();
|
|
2095
|
+
|
|
2096
|
+
while (pending.length > 0 || active.size > 0) {
|
|
2097
|
+
while (active.size < concurrency && pending.length > 0) {
|
|
2098
|
+
await jobCheckpoint();
|
|
2099
|
+
if (interrupted) throw new FanoutInterrupted();
|
|
2100
|
+
|
|
2101
|
+
const id = pending.shift();
|
|
2102
|
+
const fanoutWorker = readFanoutFn(invocationCwd, jobSlug).workers.find((w) => w.id === id);
|
|
2103
|
+
|
|
2104
|
+
if (fanoutWorker && ['done', 'failed', 'skipped'].includes(fanoutWorker.state)) {
|
|
2105
|
+
continue;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
if (fanoutWorker?.slug) {
|
|
2109
|
+
const existing = readJob(invocationCwd, fanoutWorker.slug);
|
|
2110
|
+
if (existing && !TERMINAL_JOB_STATES.includes(existing.state)) {
|
|
2111
|
+
// Re-attach to a still-live child — do not spawn a duplicate.
|
|
2112
|
+
active.set(id, { workerSlug: fanoutWorker.slug, spawnedAt: Date.now() });
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
2115
|
+
if (existing && TERMINAL_JOB_STATES.includes(existing.state)) {
|
|
2116
|
+
if (fanoutWorker.state !== 'done' && fanoutWorker.state !== 'failed') {
|
|
2117
|
+
patchWorkerFn(invocationCwd, jobSlug, id, { state: 'failed' });
|
|
2118
|
+
}
|
|
2119
|
+
continue;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// Spawn only still-pending workers (no slug / not live or terminal).
|
|
2124
|
+
const workerSlug = spawnWorkerChild(byId.get(id));
|
|
2125
|
+
active.set(id, { workerSlug, spawnedAt: Date.now() });
|
|
2126
|
+
}
|
|
2127
|
+
if (active.size === 0) break;
|
|
2128
|
+
|
|
2129
|
+
await sleep(pollIntervalMs);
|
|
2130
|
+
if (interrupted) throw new FanoutInterrupted();
|
|
2131
|
+
await jobCheckpoint();
|
|
2132
|
+
|
|
2133
|
+
for (const [id, { workerSlug, spawnedAt }] of [...active]) {
|
|
2134
|
+
if (settleWorker(id, workerSlug, spawnedAt)) active.delete(id);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
|
|
2139
|
+
const scaffoldWorker = workers.find((w) => w.scaffold);
|
|
2140
|
+
let aborted = false;
|
|
2141
|
+
|
|
2142
|
+
if (scaffoldWorker) {
|
|
2143
|
+
await runWorkerGroup([scaffoldWorker.id], 1);
|
|
2144
|
+
const scaffoldEntry = readFanoutFn(invocationCwd, jobSlug).workers.find((w) => w.id === scaffoldWorker.id);
|
|
2145
|
+
if (scaffoldEntry.state === 'done') {
|
|
2146
|
+
const current = readFanoutFn(invocationCwd, jobSlug);
|
|
2147
|
+
writeFanoutFn(invocationCwd, jobSlug, { ...current, base: scaffoldEntry.sha });
|
|
2148
|
+
console.log(`[${scaffoldWorker.id}] done — base updated to ${scaffoldEntry.sha.slice(0, 7)}`);
|
|
2149
|
+
} else {
|
|
2150
|
+
aborted = true;
|
|
2151
|
+
for (const worker of workers) {
|
|
2152
|
+
if (worker.id === scaffoldWorker.id) continue;
|
|
2153
|
+
patchWorkerFn(invocationCwd, jobSlug, worker.id, { state: 'skipped' });
|
|
2154
|
+
}
|
|
2155
|
+
console.log('scaffold worker failed; aborting fan-out before any parallel worker spawns');
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
if (!aborted) {
|
|
2160
|
+
const remaining = workers.filter((w) => !scaffoldWorker || w.id !== scaffoldWorker.id);
|
|
2161
|
+
const forLayering = remaining.map((w) => ({
|
|
2162
|
+
...w,
|
|
2163
|
+
dependsOn: (w.dependsOn || []).filter((dep) => !scaffoldWorker || dep !== scaffoldWorker.id),
|
|
2164
|
+
}));
|
|
2165
|
+
const layers = planLayers(forLayering);
|
|
2166
|
+
|
|
2167
|
+
for (const layerIds of layers) {
|
|
2168
|
+
if (interrupted) throw new FanoutInterrupted();
|
|
2169
|
+
const currentDoc = readFanoutFn(invocationCwd, jobSlug);
|
|
2170
|
+
const byId = new Map(workers.map((w) => [w.id, w]));
|
|
2171
|
+
const toRun = [];
|
|
2172
|
+
for (const id of layerIds) {
|
|
2173
|
+
const worker = byId.get(id);
|
|
2174
|
+
const depFailed = (worker.dependsOn || []).some(
|
|
2175
|
+
(dep) => currentDoc.workers.find((w) => w.id === dep)?.state === 'failed',
|
|
2176
|
+
);
|
|
2177
|
+
if (depFailed) {
|
|
2178
|
+
patchWorkerFn(invocationCwd, jobSlug, id, { state: 'skipped' });
|
|
2179
|
+
console.log(`[${id}] skipped — dependency failed`);
|
|
2180
|
+
} else {
|
|
2181
|
+
toRun.push(id);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
if (toRun.length === 0) continue;
|
|
2185
|
+
|
|
2186
|
+
const concurrency = chooseConcurrency({ layerSize: toRun.length, maxConcurrency });
|
|
2187
|
+
const currentForConcurrency = readFanoutFn(invocationCwd, jobSlug);
|
|
2188
|
+
writeFanoutFn(invocationCwd, jobSlug, { ...currentForConcurrency, concurrency });
|
|
2189
|
+
console.log(`schedule: concurrency ${concurrency}`);
|
|
2190
|
+
|
|
2191
|
+
await runWorkerGroup(toRun, concurrency);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// --- overlap detection + integration candidates ---
|
|
2196
|
+
await jobCheckpoint();
|
|
2197
|
+
const settledDoc = readFanoutFn(invocationCwd, jobSlug);
|
|
2198
|
+
const overlapUnion = detectOverlaps(settledDoc.workers);
|
|
2199
|
+
for (const worker of settledDoc.workers) {
|
|
2200
|
+
if (worker.overlaps && worker.overlaps.length > 0) {
|
|
2201
|
+
patchWorkerFn(invocationCwd, jobSlug, worker.id, { overlaps: worker.overlaps });
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
console.log(`overlaps: ${overlapUnion.length > 0 ? overlapUnion.join(', ') : 'none'}`);
|
|
2205
|
+
|
|
2206
|
+
const doneWorkers = settledDoc.workers.filter((w) => w.state === 'done');
|
|
2207
|
+
const failedWorkers = settledDoc.workers.filter((w) => w.state === 'failed');
|
|
2208
|
+
patchIntegrationFn(invocationCwd, jobSlug, {
|
|
2209
|
+
candidates: doneWorkers.map((w) => w.branch),
|
|
2210
|
+
overlappingFiles: overlapUnion,
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
let integrationDone = false;
|
|
2214
|
+
await jobCheckpoint();
|
|
2215
|
+
const integrationGate = readFanoutFn(invocationCwd, jobSlug).integration;
|
|
2216
|
+
const integrationAlreadyDone = integrationGate?.state === 'done';
|
|
2217
|
+
let integrationSlug = integrationGate?.slug ?? null;
|
|
2218
|
+
let integrationLive = false;
|
|
2219
|
+
if (integrationSlug && !integrationAlreadyDone) {
|
|
2220
|
+
const existingIntegrate = readJob(invocationCwd, integrationSlug);
|
|
2221
|
+
integrationLive = Boolean(existingIntegrate && !TERMINAL_JOB_STATES.includes(existingIntegrate.state));
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
if (integrationAlreadyDone) {
|
|
2225
|
+
integrationDone = true;
|
|
2226
|
+
console.log(`[integrate ${integrationSlug ?? 'done'}] already done — skipping spawn`);
|
|
2227
|
+
} else if (integrationLive) {
|
|
2228
|
+
// Re-attach to an already-live integrate child; do not spawn a duplicate.
|
|
2229
|
+
console.log(`[integrate ${integrationSlug}] re-attached`);
|
|
2230
|
+
const integrateSpawnedAt = Date.now();
|
|
2231
|
+
for (;;) {
|
|
2232
|
+
await sleep(pollIntervalMs);
|
|
2233
|
+
if (interrupted) throw new FanoutInterrupted();
|
|
2234
|
+
await jobCheckpoint();
|
|
2235
|
+
|
|
2236
|
+
const integrationState = readFanoutFn(invocationCwd, jobSlug).integration.state;
|
|
2237
|
+
if (integrationState === 'done') { integrationDone = true; break; }
|
|
2238
|
+
if (integrationState === 'failed') break;
|
|
2239
|
+
|
|
2240
|
+
if (Date.now() - integrateSpawnedAt < CRASH_CHECK_GRACE_MS) continue;
|
|
2241
|
+
const record = reconcileJobFn(invocationCwd, integrationSlug, readJob(invocationCwd, integrationSlug));
|
|
2242
|
+
if (TERMINAL_JOB_STATES.includes(record.state)) {
|
|
2243
|
+
if (record.state === 'done') integrationDone = true;
|
|
2244
|
+
else patchIntegrationFn(invocationCwd, jobSlug, (current) => (current.state === 'done' ? {} : { state: 'failed' }));
|
|
2245
|
+
break;
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
const finalIntegration = readFanoutFn(invocationCwd, jobSlug).integration;
|
|
2250
|
+
if (finalIntegration.state === 'done' && finalIntegration.sha) {
|
|
2251
|
+
console.log(`[integrate ${integrationSlug}] merged ${doneWorkers.length} branch${doneWorkers.length === 1 ? '' : 'es'}`);
|
|
2252
|
+
console.log(`commit: ${finalIntegration.sha.slice(0, 7)} on ${finalIntegration.branch ?? `orch/${jobSlug}`}`);
|
|
2253
|
+
console.log(`merge: git merge ${finalIntegration.branch ?? `orch/${jobSlug}`}`);
|
|
2254
|
+
} else {
|
|
2255
|
+
console.log(`[integrate ${integrationSlug}] failed`);
|
|
2256
|
+
}
|
|
2257
|
+
} else if (doneWorkers.length > 0) {
|
|
2258
|
+
const envelope = buildIntegrationEnvelope({
|
|
2259
|
+
task: prompt,
|
|
2260
|
+
branches: doneWorkers.map((w) => w.branch),
|
|
2261
|
+
overlappingFiles: overlapUnion,
|
|
2262
|
+
});
|
|
2263
|
+
|
|
2264
|
+
const allocated = allocateJobFn({
|
|
2265
|
+
cwd: invocationCwd,
|
|
2266
|
+
prompt: envelope,
|
|
2267
|
+
agent: options.agent,
|
|
2268
|
+
maxRounds,
|
|
2269
|
+
state: 'starting',
|
|
2270
|
+
parent: jobSlug,
|
|
2271
|
+
role: 'integration',
|
|
2272
|
+
});
|
|
2273
|
+
integrationSlug = allocated.slug;
|
|
2274
|
+
patchIntegrationFn(invocationCwd, jobSlug, { slug: integrationSlug });
|
|
2275
|
+
|
|
2276
|
+
const { logPath } = jobPaths(invocationCwd, integrationSlug);
|
|
2277
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
2278
|
+
const childArgs = [
|
|
2279
|
+
__filename, envelope,
|
|
2280
|
+
'--agent', options.agent,
|
|
2281
|
+
'--max-rounds', String(maxRounds),
|
|
2282
|
+
'--integrate', jobSlug,
|
|
2283
|
+
];
|
|
2284
|
+
const child = spawnFn(process.execPath, childArgs, {
|
|
2285
|
+
cwd: invocationCwd,
|
|
2286
|
+
env: { ...process.env, ORCH_JOB_SLUG: integrationSlug, ORCH_DETACHED: '1', ORCH_FANOUT_DEPTH: '1' },
|
|
2287
|
+
detached: true,
|
|
2288
|
+
stdio: ['ignore', logFd, logFd],
|
|
2289
|
+
});
|
|
2290
|
+
child.unref();
|
|
2291
|
+
patchJobFn(invocationCwd, integrationSlug, { pid: child.pid, state: 'running' });
|
|
2292
|
+
patchIntegrationFn(invocationCwd, jobSlug, { pid: child.pid });
|
|
2293
|
+
console.log(`[integrate ${integrationSlug}] running`);
|
|
2294
|
+
|
|
2295
|
+
const integrateSpawnedAt = Date.now();
|
|
2296
|
+
for (;;) {
|
|
2297
|
+
await sleep(pollIntervalMs);
|
|
2298
|
+
if (interrupted) throw new FanoutInterrupted();
|
|
2299
|
+
await jobCheckpoint();
|
|
2300
|
+
|
|
2301
|
+
const integrationState = readFanoutFn(invocationCwd, jobSlug).integration.state;
|
|
2302
|
+
if (integrationState === 'done') { integrationDone = true; break; }
|
|
2303
|
+
if (integrationState === 'failed') break;
|
|
2304
|
+
|
|
2305
|
+
if (Date.now() - integrateSpawnedAt < CRASH_CHECK_GRACE_MS) continue;
|
|
2306
|
+
const record = reconcileJobFn(invocationCwd, integrationSlug, readJob(invocationCwd, integrationSlug));
|
|
2307
|
+
if (TERMINAL_JOB_STATES.includes(record.state)) {
|
|
2308
|
+
if (record.state === 'done') integrationDone = true;
|
|
2309
|
+
else patchIntegrationFn(invocationCwd, jobSlug, (current) => (current.state === 'done' ? {} : { state: 'failed' }));
|
|
2310
|
+
break;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
const finalIntegration = readFanoutFn(invocationCwd, jobSlug).integration;
|
|
2315
|
+
if (finalIntegration.state === 'done' && finalIntegration.sha) {
|
|
2316
|
+
console.log(`[integrate ${integrationSlug}] merged ${doneWorkers.length} branch${doneWorkers.length === 1 ? '' : 'es'}`);
|
|
2317
|
+
console.log(`commit: ${finalIntegration.sha.slice(0, 7)} on ${finalIntegration.branch ?? `orch/${jobSlug}`}`);
|
|
2318
|
+
console.log(`merge: git merge ${finalIntegration.branch ?? `orch/${jobSlug}`}`);
|
|
2319
|
+
} else {
|
|
2320
|
+
console.log(`[integrate ${integrationSlug}] failed`);
|
|
2321
|
+
}
|
|
2322
|
+
} else {
|
|
2323
|
+
console.log('no worker reached done; skipping integration');
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
const finalDoc = readFanoutFn(invocationCwd, jobSlug);
|
|
2327
|
+
const success = failedWorkers.length === 0 && integrationDone && Boolean(finalDoc.integration.sha);
|
|
2328
|
+
writeFanoutFn(invocationCwd, jobSlug, {
|
|
2329
|
+
...finalDoc,
|
|
2330
|
+
state: success ? 'done' : 'failed',
|
|
2331
|
+
finishedAt: new Date().toISOString(),
|
|
2332
|
+
});
|
|
2333
|
+
|
|
2334
|
+
if (failedWorkers.length > 0) {
|
|
2335
|
+
console.log(`${failedWorkers.length} worker${failedWorkers.length === 1 ? '' : 's'} failed: ${failedWorkers.map((w) => w.id).join(', ')}`);
|
|
2336
|
+
console.log(`retry integration after fixing it: orch --integrate ${jobSlug}`);
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
const finishedAt = new Date().toISOString();
|
|
2340
|
+
const summary = success
|
|
2341
|
+
? `fan-out complete: ${doneWorkers.length} worker${doneWorkers.length === 1 ? '' : 's'} integrated`
|
|
2342
|
+
: (failedWorkers.length > 0
|
|
2343
|
+
? `${failedWorkers.length} worker(s) failed`
|
|
2344
|
+
: 'fan-out failed');
|
|
2345
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
2346
|
+
state: success ? 'done' : 'failed',
|
|
2347
|
+
exitCode: success ? 0 : 1,
|
|
2348
|
+
summary,
|
|
2349
|
+
error: success ? null : summary,
|
|
2350
|
+
task: prompt,
|
|
2351
|
+
});
|
|
2352
|
+
exitFn(success ? 0 : 1);
|
|
2353
|
+
} catch (err) {
|
|
2354
|
+
if (err instanceof FanoutInterrupted) return;
|
|
2355
|
+
console.error(`Error: ${err.message}`);
|
|
2356
|
+
if (jobSlug) {
|
|
2357
|
+
try {
|
|
2358
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
2359
|
+
state: 'failed',
|
|
2360
|
+
exitCode: 1,
|
|
2361
|
+
summary: '',
|
|
2362
|
+
error: err.message,
|
|
2363
|
+
task: prompt,
|
|
2364
|
+
});
|
|
2365
|
+
} catch {
|
|
2366
|
+
// Best-effort: don't let a job-state write failure mask the real error.
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
exitFn(1);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/** Splits `--worker`'s `<parent-slug>:<worker-id>` value on the first colon. */
|
|
2374
|
+
function splitParentWorker(value) {
|
|
2375
|
+
const idx = value.indexOf(':');
|
|
2376
|
+
if (idx === -1) return { parentSlug: null, workerId: null };
|
|
2377
|
+
return { parentSlug: value.slice(0, idx), workerId: value.slice(idx + 1) };
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
/** CLI glue for `--worker`: resolves the parent fan-out/worker record, builds the worker
|
|
2381
|
+
* envelope, allocates (or reuses) the job record, then calls `runWorkerPipeline`. */
|
|
2382
|
+
async function runWorkerFromCli(options) {
|
|
2383
|
+
const cwd = process.cwd();
|
|
2384
|
+
const { parentSlug, workerId } = splitParentWorker(options.worker);
|
|
2385
|
+
if (!parentSlug || !workerId) {
|
|
2386
|
+
console.error(`Error: --worker must be in the form <parent-slug>:<worker-id>, got "${options.worker}"`);
|
|
2387
|
+
process.exit(1);
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// ORCH_FANOUT_DEPTH guards against a future --fan-out spawning nested fan-outs.
|
|
2392
|
+
process.env.ORCH_FANOUT_DEPTH = '1';
|
|
2393
|
+
|
|
2394
|
+
const fanout = readFanout(cwd, parentSlug);
|
|
2395
|
+
if (!fanout) {
|
|
2396
|
+
console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
|
|
2397
|
+
process.exit(1);
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
const worker = fanout.workers.find((w) => w.id === workerId);
|
|
2402
|
+
if (!worker) {
|
|
2403
|
+
console.error(`Error: unknown worker ${workerId} in ${parentSlug}`);
|
|
2404
|
+
process.exit(1);
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
const siblingTitles = fanout.workers
|
|
2409
|
+
.filter((w) => w.id !== workerId)
|
|
2410
|
+
.map((w) => w.title)
|
|
2411
|
+
.filter(Boolean);
|
|
2412
|
+
const envelope = buildWorkerEnvelope({
|
|
2413
|
+
subtask: worker.subtask,
|
|
2414
|
+
area: worker.area,
|
|
2415
|
+
scaffold: worker.scaffold,
|
|
2416
|
+
siblingTitles,
|
|
2417
|
+
});
|
|
2418
|
+
const workerPrompt = `${worker.subtask}\n\n${envelope}`;
|
|
2419
|
+
|
|
2420
|
+
let jobSlug = process.env.ORCH_JOB_SLUG;
|
|
2421
|
+
if (!jobSlug) {
|
|
2422
|
+
const alloc = allocateJob({
|
|
2423
|
+
cwd,
|
|
2424
|
+
prompt: workerPrompt,
|
|
2425
|
+
agent: options.agent,
|
|
2426
|
+
maxRounds: options.maxRounds,
|
|
2427
|
+
state: 'running',
|
|
2428
|
+
pid: process.pid,
|
|
2429
|
+
parent: parentSlug,
|
|
2430
|
+
role: 'worker',
|
|
2431
|
+
workerId,
|
|
2432
|
+
});
|
|
2433
|
+
jobSlug = alloc.slug;
|
|
2434
|
+
}
|
|
2435
|
+
setJobSlug(jobSlug);
|
|
2436
|
+
|
|
2437
|
+
await runWorkerPipeline(workerPrompt, {
|
|
2438
|
+
agent: options.agent,
|
|
2439
|
+
maxRounds: options.maxRounds,
|
|
2440
|
+
verbose: options.verbose,
|
|
2441
|
+
cwd,
|
|
2442
|
+
parentSlug,
|
|
2443
|
+
workerId,
|
|
2444
|
+
base: fanout.base,
|
|
2445
|
+
jobSlug,
|
|
2446
|
+
jobCwd: cwd,
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
/** CLI glue for `--integrate`: resolves the parent fan-out, allocates (or reuses) the
|
|
2451
|
+
* integration job record, then calls `runIntegratePipeline`. */
|
|
2452
|
+
async function runIntegrateFromCli(options) {
|
|
2453
|
+
const cwd = process.cwd();
|
|
2454
|
+
const parentSlug = options.integrate;
|
|
2455
|
+
|
|
2456
|
+
// ORCH_FANOUT_DEPTH guards against a future --fan-out spawning nested fan-outs.
|
|
2457
|
+
process.env.ORCH_FANOUT_DEPTH = '1';
|
|
2458
|
+
|
|
2459
|
+
const fanout = readFanout(cwd, parentSlug);
|
|
2460
|
+
if (!fanout) {
|
|
2461
|
+
console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
|
|
2462
|
+
process.exit(1);
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
let jobSlug = process.env.ORCH_JOB_SLUG;
|
|
2467
|
+
if (!jobSlug) {
|
|
2468
|
+
const alloc = allocateJob({
|
|
2469
|
+
cwd,
|
|
2470
|
+
prompt: fanout.task,
|
|
2471
|
+
agent: options.agent,
|
|
2472
|
+
maxRounds: options.maxRounds,
|
|
2473
|
+
state: 'running',
|
|
2474
|
+
pid: process.pid,
|
|
2475
|
+
parent: parentSlug,
|
|
2476
|
+
role: 'integration',
|
|
2477
|
+
});
|
|
2478
|
+
jobSlug = alloc.slug;
|
|
2479
|
+
}
|
|
2480
|
+
setJobSlug(jobSlug);
|
|
2481
|
+
|
|
2482
|
+
await runIntegratePipeline({
|
|
2483
|
+
agent: options.agent,
|
|
2484
|
+
maxRounds: options.maxRounds,
|
|
2485
|
+
verbose: options.verbose,
|
|
2486
|
+
cwd,
|
|
2487
|
+
parentSlug,
|
|
2488
|
+
jobSlug,
|
|
2489
|
+
jobCwd: cwd,
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
/** Commander option parser shared by `--max-rounds`, `--max-workers`, and `--max-concurrency`. */
|
|
2494
|
+
function positiveIntParser(flagName) {
|
|
2495
|
+
return (value) => {
|
|
2496
|
+
const n = Number.parseInt(value, 10);
|
|
2497
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
2498
|
+
throw new Error(`${flagName} must be a positive integer`);
|
|
2499
|
+
}
|
|
2500
|
+
return n;
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
/** Resolve the effective agent (CLI > local > global > cursor) or exit 1. */
|
|
2505
|
+
function resolveAgentOrExit(cliAgent, cwd = process.cwd()) {
|
|
2506
|
+
try {
|
|
2507
|
+
return resolveAgent({ cliAgent, cwd });
|
|
2508
|
+
} catch (err) {
|
|
2509
|
+
console.error(`Error: ${err.message}`);
|
|
2510
|
+
process.exit(1);
|
|
2511
|
+
return undefined;
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
const program = new Command();
|
|
2516
|
+
|
|
2517
|
+
program
|
|
2518
|
+
.name('orch')
|
|
2519
|
+
.version(version)
|
|
2520
|
+
.description('The Orchestrator: triage → research → plan → implement pipeline against a task')
|
|
2521
|
+
.argument('<task...>', 'Task description to use as the prompt (mention a file path and the agent will read it)')
|
|
2522
|
+
.option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
|
|
2523
|
+
.option('--dry-run', 'Check that the selected agent CLI is on PATH and exit; do not run the pipeline')
|
|
2524
|
+
.option('--ask', 'Ask a read-only question about the codebase; print the reply and exit (skips triage and all write pipelines)')
|
|
2525
|
+
.option('--quick', 'Skip triage, run quick-fix directly in the current working tree; create no artifacts, worktrees, or commits')
|
|
2526
|
+
.option('--detach', 'Run the pipeline in the background and return immediately; manage it with orch list/status/pause/resume/stop/logs. Cannot be combined with --ask, --quick, or --dry-run')
|
|
2527
|
+
.option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop (ignored with --ask and --quick)', positiveIntParser('--max-rounds'), 5)
|
|
2528
|
+
.option('--fan-out', 'Decompose the task into parallel workers coordinated by this process (see README Fan-out section). Cannot be combined with --ask, --quick, or --dry-run')
|
|
2529
|
+
.option('--max-workers <n>', 'Max number of parallel fan-out workers (only meaningful with --fan-out)', positiveIntParser('--max-workers'), 4)
|
|
2530
|
+
.option('--max-concurrency <n>', 'Optional hard ceiling on in-flight fan-out workers at once (only meaningful with --fan-out; default: coordinator chooses)', positiveIntParser('--max-concurrency'))
|
|
2531
|
+
.addOption(
|
|
2532
|
+
new Option('--agent <agent>', 'Agent backend to run the pipeline with: "cursor" (Cursor Agent CLI), "claude" (Claude Code CLI), or "agn" (agn CLI). Omitting uses local then global config, else cursor')
|
|
2533
|
+
.choices(['cursor', 'claude', 'agn']),
|
|
2534
|
+
)
|
|
2535
|
+
.addOption(new Option('--worker <value>', 'internal: run a single fan-out worker "<parent-slug>:<worker-id>"').hideHelp())
|
|
2536
|
+
.addOption(new Option('--integrate <value>', 'internal: (re)run fan-out integration for "<parent-slug>"').hideHelp())
|
|
2537
|
+
.addHelpText(
|
|
2538
|
+
'after',
|
|
2539
|
+
`
|
|
2540
|
+
Examples:
|
|
2541
|
+
$ orch "fix the typo in the README" --agent claude
|
|
2542
|
+
$ orch "fix the bug described in task.md" --agent cursor -v
|
|
2543
|
+
$ orch "implement the local spec" --agent agn -v
|
|
2544
|
+
$ orch --ask "where is the CLI entrypoint?" --agent claude
|
|
2545
|
+
$ orch --quick "fix the typo in the README" --agent claude
|
|
2546
|
+
$ orch "noop" --dry-run --agent cursor
|
|
2547
|
+
$ orch config # print effective agent
|
|
2548
|
+
$ orch config --agent claude # pin global default
|
|
2549
|
+
$ orch config --agent agn --local # pin project default
|
|
2550
|
+
|
|
2551
|
+
Headless runs:
|
|
2552
|
+
$ orch "long-running task" --detach --agent claude # start in the background, prints the run slug
|
|
2553
|
+
$ orch list # show all tracked runs
|
|
2554
|
+
$ orch status [slug] # show full status (defaults to most recent)
|
|
2555
|
+
$ orch pause <slug> # request a pause at the next stage boundary
|
|
2556
|
+
$ orch resume <slug> # unpause a paused/pausing run
|
|
2557
|
+
$ orch continue <slug> "new task" # new work on a finished run's worktree
|
|
2558
|
+
# (workers: same command; then re-integrate the parent)
|
|
2559
|
+
$ orch stop <slug> # send SIGTERM to a running job
|
|
2560
|
+
$ orch logs <slug> [-f] # print (or follow) a run's log file
|
|
2561
|
+
|
|
2562
|
+
Fan-out:
|
|
2563
|
+
$ orch "implement the billing module" --fan-out --agent claude # triage, decompose, run parallel workers, integrate
|
|
2564
|
+
$ orch "implement X" --fan-out --max-workers 6 --max-concurrency 3
|
|
2565
|
+
`,
|
|
2566
|
+
)
|
|
2567
|
+
.action(async (task, options) => {
|
|
2568
|
+
const prompt = task.join(' ').trim();
|
|
2569
|
+
if (!prompt) {
|
|
2570
|
+
console.error('Error: task cannot be empty');
|
|
2571
|
+
process.exit(1);
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
options.agent = resolveAgentOrExit(options.agent);
|
|
2576
|
+
|
|
2577
|
+
if (options.fanOut) {
|
|
2578
|
+
const conflicts = ['ask', 'quick', 'dryRun']
|
|
2579
|
+
.filter((key) => options[key])
|
|
2580
|
+
.map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
|
|
2581
|
+
if (conflicts.length > 0) {
|
|
2582
|
+
console.error(`Error: --fan-out cannot be combined with ${conflicts.join(', ')}`);
|
|
2583
|
+
process.exit(1);
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
if (process.env.ORCH_FANOUT_DEPTH) {
|
|
2587
|
+
console.error('Error: --fan-out cannot be used inside a fan-out child (ORCH_FANOUT_DEPTH is already set)');
|
|
2588
|
+
process.exit(1);
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
const cwd = process.cwd();
|
|
2593
|
+
const { slug } = allocateJob({
|
|
2594
|
+
cwd,
|
|
2595
|
+
prompt,
|
|
2596
|
+
agent: options.agent,
|
|
2597
|
+
maxRounds: options.maxRounds,
|
|
2598
|
+
state: 'running',
|
|
2599
|
+
pid: process.pid,
|
|
2600
|
+
role: 'coordinator',
|
|
2601
|
+
});
|
|
2602
|
+
setJobSlug(slug);
|
|
2603
|
+
|
|
2604
|
+
await runFanoutPipeline(prompt, {
|
|
2605
|
+
...options,
|
|
2606
|
+
cwd,
|
|
2607
|
+
jobSlug: slug,
|
|
2608
|
+
jobCwd: cwd,
|
|
2609
|
+
maxWorkers: options.maxWorkers,
|
|
2610
|
+
maxConcurrency: options.maxConcurrency ?? null,
|
|
2611
|
+
});
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
if (options.worker || options.integrate) {
|
|
2616
|
+
const flagName = options.worker ? '--worker' : '--integrate';
|
|
2617
|
+
const conflicts = ['ask', 'quick', 'detach', 'dryRun']
|
|
2618
|
+
.filter((key) => options[key])
|
|
2619
|
+
.map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
|
|
2620
|
+
if (conflicts.length > 0) {
|
|
2621
|
+
console.error(`Error: ${flagName} cannot be combined with ${conflicts.join(', ')}`);
|
|
2622
|
+
process.exit(1);
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
if (options.worker) {
|
|
2627
|
+
await runWorkerFromCli(options);
|
|
2628
|
+
} else {
|
|
2629
|
+
await runIntegrateFromCli(options);
|
|
2630
|
+
}
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
if (options.detach) {
|
|
2635
|
+
const conflicts = ['ask', 'quick', 'dryRun']
|
|
2636
|
+
.filter((key) => options[key])
|
|
2637
|
+
.map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
|
|
2638
|
+
if (conflicts.length > 0) {
|
|
2639
|
+
console.error(`Error: --detach cannot be combined with ${conflicts.join(', ')}`);
|
|
2640
|
+
process.exit(1);
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
await runDetached(prompt, options);
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
if (!options.dryRun) {
|
|
2648
|
+
const { slug } = allocateJob({
|
|
2649
|
+
cwd: process.cwd(),
|
|
2650
|
+
prompt,
|
|
2651
|
+
agent: options.agent,
|
|
2652
|
+
maxRounds: options.ask || options.quick ? null : options.maxRounds,
|
|
2653
|
+
state: 'running',
|
|
2654
|
+
pid: process.pid,
|
|
2655
|
+
});
|
|
2656
|
+
options.jobSlug = slug;
|
|
2657
|
+
setJobSlug(slug);
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
await runPipeline(prompt, options);
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
program
|
|
2664
|
+
.command('continue')
|
|
2665
|
+
.description(
|
|
2666
|
+
'Start new complex work on a finished run\'s existing worktree (not the same as resume, which only unpauses). Workers: continue the worker slug, then re-integrate the parent',
|
|
2667
|
+
)
|
|
2668
|
+
.argument('<slug>', 'Existing run slug under .orch/')
|
|
2669
|
+
.argument('<task...>', 'New task prompt for this continue iteration')
|
|
2670
|
+
.option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
|
|
2671
|
+
.option('--dry-run', 'Validate eligibility and agent PATH; do not reopen the job or run agents')
|
|
2672
|
+
.option('--ask', 'Rejected: continue does not support --ask')
|
|
2673
|
+
.option('--quick', 'Rejected: continue does not support --quick')
|
|
2674
|
+
.option('--detach', 'Run the continue in the background under the same slug')
|
|
2675
|
+
.option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop', positiveIntParser('--max-rounds'), 5)
|
|
2676
|
+
.addOption(
|
|
2677
|
+
new Option('--agent <agent>', 'Agent backend: "cursor", "claude", or "agn". Omitting uses local then global config, else cursor')
|
|
2678
|
+
.choices(['cursor', 'claude', 'agn']),
|
|
2679
|
+
)
|
|
2680
|
+
.action(async (slug, taskParts, options, command) => {
|
|
2681
|
+
// Parent program also defines --ask/--quick/--dry-run/--agent/--detach/
|
|
2682
|
+
// --max-rounds; when those flags appear after the continue arguments,
|
|
2683
|
+
// Commander attaches them to the parent. Merge via optsWithGlobals.
|
|
2684
|
+
const opts = typeof command.optsWithGlobals === 'function'
|
|
2685
|
+
? command.optsWithGlobals()
|
|
2686
|
+
: { ...program.opts(), ...options };
|
|
2687
|
+
const prompt = taskParts.join(' ').trim();
|
|
2688
|
+
const cwd = process.cwd();
|
|
2689
|
+
|
|
2690
|
+
opts.agent = resolveAgentOrExit(opts.agent, cwd);
|
|
2691
|
+
|
|
2692
|
+
let record;
|
|
2693
|
+
try {
|
|
2694
|
+
record = validateContinue(cwd, slug, {
|
|
2695
|
+
task: prompt,
|
|
2696
|
+
ask: opts.ask,
|
|
2697
|
+
quick: opts.quick,
|
|
2698
|
+
});
|
|
2699
|
+
} catch (err) {
|
|
2700
|
+
console.error(`Error: ${err.message}`);
|
|
2701
|
+
process.exit(1);
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
if (opts.detach) {
|
|
2706
|
+
await runContinueDetached(slug, prompt, {
|
|
2707
|
+
agent: opts.agent,
|
|
2708
|
+
maxRounds: opts.maxRounds,
|
|
2709
|
+
verbose: opts.verbose,
|
|
2710
|
+
cwd,
|
|
2711
|
+
});
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
if (opts.dryRun) {
|
|
2716
|
+
const backend = AGENT_BACKENDS[opts.agent];
|
|
2717
|
+
if (!isBinaryOnPath(backend.binary)) {
|
|
2718
|
+
console.error(binaryMissingHint(opts.agent));
|
|
2719
|
+
process.exit(1);
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
console.log(`dry-run: continue ${slug} ok`);
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
const alreadyReopened = Boolean(process.env.ORCH_JOB_SLUG);
|
|
2727
|
+
let priorOutcome;
|
|
2728
|
+
let continuation;
|
|
2729
|
+
let worktreePath = record.worktree;
|
|
2730
|
+
let branch = record.branch;
|
|
2731
|
+
let role = record.role;
|
|
2732
|
+
let parentSlug = record.parent;
|
|
2733
|
+
let workerId = record.workerId;
|
|
2734
|
+
|
|
2735
|
+
if (!alreadyReopened) {
|
|
2736
|
+
priorOutcome = snapshotPriorOutcome(cwd, slug, record);
|
|
2737
|
+
const updated = reopenJob(cwd, slug, {
|
|
2738
|
+
task: prompt,
|
|
2739
|
+
agent: opts.agent,
|
|
2740
|
+
maxRounds: opts.maxRounds,
|
|
2741
|
+
pid: process.pid,
|
|
2742
|
+
prior: priorOutcome,
|
|
2743
|
+
});
|
|
2744
|
+
continuation = updated.continuation;
|
|
2745
|
+
setJobSlug(slug);
|
|
2746
|
+
} else {
|
|
2747
|
+
const live = readJob(cwd, slug) ?? record;
|
|
2748
|
+
continuation = live.continuation ?? 2;
|
|
2749
|
+
const entries = Array.isArray(live.continuations) ? live.continuations : [];
|
|
2750
|
+
const last = entries[entries.length - 1];
|
|
2751
|
+
priorOutcome = last?.prior ?? snapshotPriorOutcome(cwd, slug, live);
|
|
2752
|
+
worktreePath = live.worktree ?? worktreePath;
|
|
2753
|
+
branch = live.branch ?? branch;
|
|
2754
|
+
role = live.role ?? role;
|
|
2755
|
+
parentSlug = live.parent ?? parentSlug;
|
|
2756
|
+
workerId = live.workerId ?? workerId;
|
|
2757
|
+
setJobSlug(slug);
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// PATH check after reopen (foreground) so empty-PATH tests can observe the bump.
|
|
2761
|
+
const backend = AGENT_BACKENDS[opts.agent];
|
|
2762
|
+
ensureBinaryOnPath(backend.binary, opts.agent);
|
|
2763
|
+
|
|
2764
|
+
await runContinuePipeline(prompt, {
|
|
2765
|
+
agent: opts.agent,
|
|
2766
|
+
maxRounds: opts.maxRounds,
|
|
2767
|
+
verbose: opts.verbose,
|
|
2768
|
+
cwd,
|
|
2769
|
+
slug,
|
|
2770
|
+
worktreePath,
|
|
2771
|
+
branch,
|
|
2772
|
+
role,
|
|
2773
|
+
parentSlug,
|
|
2774
|
+
workerId,
|
|
2775
|
+
priorOutcome,
|
|
2776
|
+
continuation,
|
|
2777
|
+
jobSlug: slug,
|
|
2778
|
+
jobCwd: cwd,
|
|
2779
|
+
});
|
|
2780
|
+
});
|
|
2781
|
+
|
|
2782
|
+
program
|
|
2783
|
+
.command('config')
|
|
2784
|
+
.description('Print or set the default agent (global ~/.orch/config or local .orch/config)')
|
|
2785
|
+
.addOption(
|
|
2786
|
+
new Option('--agent <agent>', 'Set the default agent backend')
|
|
2787
|
+
.choices(['cursor', 'claude', 'agn']),
|
|
2788
|
+
)
|
|
2789
|
+
.option('--global', 'Write the global config (~/.orch/config); default when --agent is set')
|
|
2790
|
+
.option('--local', 'Write the project-local config (.orch/config)')
|
|
2791
|
+
.action((options, command) => {
|
|
2792
|
+
// Parent also defines --agent; flags after `config` may land on the
|
|
2793
|
+
// parent. Merge so either placement works.
|
|
2794
|
+
const opts = typeof command.optsWithGlobals === 'function'
|
|
2795
|
+
? command.optsWithGlobals()
|
|
2796
|
+
: { ...program.opts(), ...options };
|
|
2797
|
+
const cwd = process.cwd();
|
|
2798
|
+
|
|
2799
|
+
if (opts.global && opts.local) {
|
|
2800
|
+
console.error('Error: --global and --local are mutually exclusive');
|
|
2801
|
+
process.exit(1);
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
if ((opts.global || opts.local) && !opts.agent) {
|
|
2806
|
+
console.error('Error: --global/--local require --agent (omit flags to print config)');
|
|
2807
|
+
process.exit(1);
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
if (opts.agent) {
|
|
2812
|
+
const targetPath = opts.local
|
|
2813
|
+
? localConfigPath(cwd)
|
|
2814
|
+
: globalConfigPath();
|
|
2815
|
+
try {
|
|
2816
|
+
writeConfig(targetPath, { agent: opts.agent });
|
|
2817
|
+
} catch (err) {
|
|
2818
|
+
console.error(`Error: ${err.message}`);
|
|
2819
|
+
process.exit(1);
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
console.log(`wrote ${targetPath}`);
|
|
2823
|
+
process.stdout.write(`${JSON.stringify({ agent: opts.agent }, null, 2)}\n`);
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
try {
|
|
2828
|
+
printConfig({ cwd });
|
|
2829
|
+
} catch (err) {
|
|
2830
|
+
console.error(`Error: ${err.message}`);
|
|
2831
|
+
process.exit(1);
|
|
2832
|
+
}
|
|
2833
|
+
});
|
|
2834
|
+
|
|
2835
|
+
program
|
|
2836
|
+
.command('list')
|
|
2837
|
+
.description('List all runs (active and finished) tracked under .orch/ in this directory')
|
|
2838
|
+
.action(() => {
|
|
2839
|
+
const jobs = listJobs(process.cwd());
|
|
2840
|
+
if (jobs.length === 0) {
|
|
2841
|
+
console.log('no runs');
|
|
2842
|
+
return;
|
|
2843
|
+
}
|
|
2844
|
+
console.log(formatJobsTable(jobs));
|
|
2845
|
+
});
|
|
2846
|
+
|
|
2847
|
+
program
|
|
2848
|
+
.command('status')
|
|
2849
|
+
.argument('[slug]', 'Run slug to show; defaults to the most recently started run in this directory')
|
|
2850
|
+
.description('Show full status for a run')
|
|
2851
|
+
.action((slug) => {
|
|
2852
|
+
const cwd = process.cwd();
|
|
2853
|
+
let record;
|
|
2854
|
+
if (slug) {
|
|
2855
|
+
record = readJob(cwd, slug);
|
|
2856
|
+
if (!record) {
|
|
2857
|
+
console.error(`Error: unknown run ${slug}`);
|
|
2858
|
+
process.exit(1);
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
record = reconcileJob(cwd, slug, record);
|
|
2862
|
+
} else {
|
|
2863
|
+
const jobs = listJobs(cwd);
|
|
2864
|
+
if (jobs.length === 0) {
|
|
2865
|
+
console.error('Error: no runs found in this directory');
|
|
2866
|
+
process.exit(1);
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2869
|
+
[record] = jobs;
|
|
2870
|
+
}
|
|
2871
|
+
console.log(formatStatus(cwd, record));
|
|
2872
|
+
});
|
|
2873
|
+
|
|
2874
|
+
program
|
|
2875
|
+
.command('pause')
|
|
2876
|
+
.argument('<slug>', 'Run slug to pause')
|
|
2877
|
+
.description('Request a running job to pause at its next stage-boundary checkpoint')
|
|
2878
|
+
.action((slug) => {
|
|
2879
|
+
const cwd = process.cwd();
|
|
2880
|
+
try {
|
|
2881
|
+
const record = readJob(cwd, slug);
|
|
2882
|
+
if (!record) throw new Error(`requestPause: unknown job ${slug}`);
|
|
2883
|
+
if (isCascadeParent(cwd, record)) {
|
|
2884
|
+
const { childrenSignaled } = cascadePause(cwd, slug);
|
|
2885
|
+
console.log(`pause requested for ${slug} (${childrenSignaled} children signaled)`);
|
|
2886
|
+
} else {
|
|
2887
|
+
requestPause(cwd, slug);
|
|
2888
|
+
console.log(`pause requested for ${slug}`);
|
|
2889
|
+
}
|
|
2890
|
+
} catch (err) {
|
|
2891
|
+
console.error(`Error: ${err.message}`);
|
|
2892
|
+
process.exit(1);
|
|
2893
|
+
}
|
|
2894
|
+
});
|
|
2895
|
+
|
|
2896
|
+
program
|
|
2897
|
+
.command('resume')
|
|
2898
|
+
.argument('<slug>', 'Run slug to resume')
|
|
2899
|
+
.description('Resume a paused (or pausing) job')
|
|
2900
|
+
.action((slug) => {
|
|
2901
|
+
const cwd = process.cwd();
|
|
2902
|
+
try {
|
|
2903
|
+
const record = readJob(cwd, slug);
|
|
2904
|
+
if (!record) throw new Error(`requestResume: unknown job ${slug}`);
|
|
2905
|
+
if (isCascadeParent(cwd, record)) {
|
|
2906
|
+
cascadeResume(cwd, slug);
|
|
2907
|
+
console.log(`resumed ${slug}`);
|
|
2908
|
+
} else {
|
|
2909
|
+
requestResume(cwd, slug);
|
|
2910
|
+
console.log(`resumed ${slug}`);
|
|
2911
|
+
}
|
|
2912
|
+
} catch (err) {
|
|
2913
|
+
console.error(`Error: ${err.message}`);
|
|
2914
|
+
process.exit(1);
|
|
2915
|
+
}
|
|
2916
|
+
});
|
|
2917
|
+
|
|
2918
|
+
program
|
|
2919
|
+
.command('stop')
|
|
2920
|
+
.argument('<slug>', 'Run slug to stop')
|
|
2921
|
+
.description('Send SIGTERM to a running job (or reconcile a dead one to crashed)')
|
|
2922
|
+
.action((slug) => {
|
|
2923
|
+
const cwd = process.cwd();
|
|
2924
|
+
try {
|
|
2925
|
+
const record = readJob(cwd, slug);
|
|
2926
|
+
if (!record) throw new Error(`stopJob: unknown job ${slug}`);
|
|
2927
|
+
if (isCascadeParent(cwd, record)) {
|
|
2928
|
+
cascadeStop(cwd, slug);
|
|
2929
|
+
console.log(`stop signal sent to ${slug} and its children`);
|
|
2930
|
+
} else {
|
|
2931
|
+
const result = stopJob(cwd, slug);
|
|
2932
|
+
if (result.action === 'signaled') {
|
|
2933
|
+
console.log(`stop signal sent to ${slug} (pid ${result.record.pid})`);
|
|
2934
|
+
} else if (result.action === 'crashed') {
|
|
2935
|
+
console.log(`${slug} process was already gone; marked crashed`);
|
|
2936
|
+
} else {
|
|
2937
|
+
console.log(`${slug} is already ${result.record.state}`);
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
} catch (err) {
|
|
2941
|
+
console.error(`Error: ${err.message}`);
|
|
2942
|
+
process.exit(1);
|
|
2943
|
+
}
|
|
2944
|
+
});
|
|
2945
|
+
|
|
2946
|
+
const jobsCmd = program
|
|
2947
|
+
.command('jobs')
|
|
2948
|
+
.description('Manage orch job artifacts under .orch/');
|
|
2949
|
+
|
|
2950
|
+
jobsCmd
|
|
2951
|
+
.command('clean')
|
|
2952
|
+
.description('Delete all orch jobs from the .orch folder')
|
|
2953
|
+
.action(async () => {
|
|
2954
|
+
const cwd = process.cwd();
|
|
2955
|
+
const orchDir = path.join(cwd, '.orch');
|
|
2956
|
+
if (!fs.existsSync(orchDir) || fs.readdirSync(orchDir).length === 0) {
|
|
2957
|
+
console.log('no jobs to clean');
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
const rl = readline.createInterface({ input, output });
|
|
2962
|
+
let answer;
|
|
2963
|
+
try {
|
|
2964
|
+
answer = await rl.question('Are you sure? [y/N] ');
|
|
2965
|
+
} finally {
|
|
2966
|
+
rl.close();
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
2970
|
+
console.log('aborted');
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
const removed = cleanJobs(cwd);
|
|
2975
|
+
console.log(`deleted ${removed.length} job${removed.length === 1 ? '' : 's'} from .orch/`);
|
|
2976
|
+
});
|
|
2977
|
+
|
|
2978
|
+
program
|
|
2979
|
+
.command('logs')
|
|
2980
|
+
.argument('<slug>', 'Run slug to show logs for')
|
|
2981
|
+
.option('-f, --follow', 'Follow the log file until the job reaches a terminal state (Ctrl+C to stop)')
|
|
2982
|
+
.description("Print a run's orch.log")
|
|
2983
|
+
.action((slug, options) => {
|
|
2984
|
+
const cwd = process.cwd();
|
|
2985
|
+
const record = readJob(cwd, slug);
|
|
2986
|
+
if (!record) {
|
|
2987
|
+
console.error(`Error: unknown run ${slug}`);
|
|
2988
|
+
process.exit(1);
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
const { logPath } = jobPaths(cwd, slug);
|
|
2992
|
+
if (!fs.existsSync(logPath)) {
|
|
2993
|
+
console.error(`Error: no log file for ${slug}`);
|
|
2994
|
+
process.exit(1);
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
if (!options.follow) {
|
|
2999
|
+
process.stdout.write(fs.readFileSync(logPath));
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
const fd = fs.openSync(logPath, 'r');
|
|
3004
|
+
let position = 0;
|
|
3005
|
+
const pump = () => {
|
|
3006
|
+
const { size } = fs.fstatSync(fd);
|
|
3007
|
+
if (size > position) {
|
|
3008
|
+
const buf = Buffer.alloc(size - position);
|
|
3009
|
+
fs.readSync(fd, buf, 0, buf.length, position);
|
|
3010
|
+
process.stdout.write(buf);
|
|
3011
|
+
position = size;
|
|
3012
|
+
}
|
|
3013
|
+
};
|
|
3014
|
+
pump();
|
|
3015
|
+
|
|
3016
|
+
const stop = () => {
|
|
3017
|
+
clearInterval(interval);
|
|
3018
|
+
fs.closeSync(fd);
|
|
3019
|
+
process.exit(0);
|
|
3020
|
+
};
|
|
3021
|
+
|
|
3022
|
+
const interval = setInterval(() => {
|
|
3023
|
+
pump();
|
|
3024
|
+
const current = reconcileJob(cwd, slug, readJob(cwd, slug));
|
|
3025
|
+
if (TERMINAL_JOB_STATES.includes(current.state)) stop();
|
|
3026
|
+
}, 500);
|
|
3027
|
+
|
|
3028
|
+
process.once('SIGINT', stop);
|
|
504
3029
|
});
|
|
505
3030
|
|
|
506
3031
|
const invokedPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
|