@welluable/orch 1.1.0 → 1.2.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 +201 -3
- 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 +69 -6
- package/lib/commit.js +70 -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 +284 -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 +2142 -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,25 @@ 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
|
+
} from './lib/jobs.js';
|
|
17
37
|
import { askAgentArgs } from './agents/ask.js';
|
|
18
38
|
import { triageAgentArgs } from './agents/triage.js';
|
|
19
39
|
import { quickFixAgentArgs } from './agents/quick-fix.js';
|
|
@@ -23,6 +43,30 @@ import { testWriterAgentArgs } from './agents/test-writer.js';
|
|
|
23
43
|
import { testCriticAgentArgs } from './agents/test-critic.js';
|
|
24
44
|
import { codeWriterAgentArgs } from './agents/code-writer.js';
|
|
25
45
|
import { testRunnerAgentArgs } from './agents/test-runner.js';
|
|
46
|
+
import { integratorAgentArgs } from './agents/integrator.js';
|
|
47
|
+
import { boundariesAgentArgs } from './agents/boundaries.js';
|
|
48
|
+
import { decomposerAgentArgs } from './agents/decomposer.js';
|
|
49
|
+
import {
|
|
50
|
+
readFanout,
|
|
51
|
+
writeFanout,
|
|
52
|
+
patchWorker,
|
|
53
|
+
patchIntegration,
|
|
54
|
+
recordChangedFiles,
|
|
55
|
+
buildWorkerEnvelope,
|
|
56
|
+
buildIntegrationEnvelope,
|
|
57
|
+
validateDecomposition,
|
|
58
|
+
planLayers,
|
|
59
|
+
chooseConcurrency,
|
|
60
|
+
detectOverlaps,
|
|
61
|
+
ensureScaffoldSubtask,
|
|
62
|
+
} from './lib/fanout.js';
|
|
63
|
+
import { parseDecomposition } from './lib/parse-decomposition.js';
|
|
64
|
+
import {
|
|
65
|
+
mergeBranches,
|
|
66
|
+
abortMerge,
|
|
67
|
+
conflictedFiles,
|
|
68
|
+
hasConflictMarkers,
|
|
69
|
+
} from './lib/integrate.js';
|
|
26
70
|
|
|
27
71
|
const __filename = fileURLToPath(import.meta.url);
|
|
28
72
|
const __dirname = path.dirname(__filename);
|
|
@@ -71,6 +115,137 @@ function ensureBinaryOnPath(binary, agentName) {
|
|
|
71
115
|
}
|
|
72
116
|
}
|
|
73
117
|
|
|
118
|
+
const TERMINAL_JOB_STATES = ['done', 'failed', 'stopped', 'crashed'];
|
|
119
|
+
|
|
120
|
+
function formatRelativeTime(iso) {
|
|
121
|
+
if (!iso) return '-';
|
|
122
|
+
const sec = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
|
|
123
|
+
if (sec < 60) return `${sec}s ago`;
|
|
124
|
+
const min = Math.floor(sec / 60);
|
|
125
|
+
if (min < 60) return `${min}m ago`;
|
|
126
|
+
const hr = Math.floor(min / 60);
|
|
127
|
+
if (hr < 24) return `${hr}h ago`;
|
|
128
|
+
return `${Math.floor(hr / 24)}d ago`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function jobDuration(job) {
|
|
132
|
+
if (!job.startedAt) return '-';
|
|
133
|
+
const start = new Date(job.startedAt).getTime();
|
|
134
|
+
if (Number.isNaN(start)) return '-';
|
|
135
|
+
const end = job.finishedAt ? new Date(job.finishedAt).getTime() : Date.now();
|
|
136
|
+
if (Number.isNaN(end)) return '-';
|
|
137
|
+
return formatElapsed(Math.max(0, end - start));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function displayJobRole(role) {
|
|
141
|
+
if (role === 'integration') return 'integrate';
|
|
142
|
+
if (role == null) return '-';
|
|
143
|
+
return role;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Workers first (startedAt ascending), then the integration child last. */
|
|
147
|
+
function compareFanoutChildren(a, b) {
|
|
148
|
+
const aIntegrate = a.role === 'integration' ? 1 : 0;
|
|
149
|
+
const bIntegrate = b.role === 'integration' ? 1 : 0;
|
|
150
|
+
if (aIntegrate !== bIntegrate) return aIntegrate - bIntegrate;
|
|
151
|
+
return new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function formatJobsTable(jobs) {
|
|
155
|
+
const header = ['SLUG', 'ROLE', 'STATE', 'PHASE', 'AGENT', 'STARTED', 'DURATION', 'PID'];
|
|
156
|
+
const childrenByParent = new Map();
|
|
157
|
+
const topLevel = [];
|
|
158
|
+
for (const job of jobs) {
|
|
159
|
+
if (job.parent) {
|
|
160
|
+
if (!childrenByParent.has(job.parent)) childrenByParent.set(job.parent, []);
|
|
161
|
+
childrenByParent.get(job.parent).push(job);
|
|
162
|
+
} else {
|
|
163
|
+
topLevel.push(job);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
topLevel.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
|
167
|
+
|
|
168
|
+
const ordered = [];
|
|
169
|
+
for (const parent of topLevel) {
|
|
170
|
+
ordered.push({ job: parent, indent: '' });
|
|
171
|
+
const children = (childrenByParent.get(parent.slug) || []).slice().sort(compareFanoutChildren);
|
|
172
|
+
for (const child of children) ordered.push({ job: child, indent: ' ' });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const rows = ordered.map(({ job, indent }) => [
|
|
176
|
+
`${indent}${job.slug}`,
|
|
177
|
+
displayJobRole(job.role),
|
|
178
|
+
job.state,
|
|
179
|
+
job.phase ?? '-',
|
|
180
|
+
job.agent ?? '-',
|
|
181
|
+
formatRelativeTime(job.startedAt),
|
|
182
|
+
jobDuration(job),
|
|
183
|
+
TERMINAL_JOB_STATES.includes(job.state) ? '-' : (job.pid ?? '-'),
|
|
184
|
+
]);
|
|
185
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => String(row[i]).length)));
|
|
186
|
+
const formatRow = (cols) => cols.map((c, i) => String(c).padEnd(widths[i])).join(' ').trimEnd();
|
|
187
|
+
return [formatRow(header), ...rows.map(formatRow)].join('\n');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function lastNonEmptyLine(content) {
|
|
191
|
+
const lines = content.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
192
|
+
return lines[lines.length - 1];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function formatStatus(cwd, record) {
|
|
196
|
+
const lines = [
|
|
197
|
+
`slug: ${record.slug}`,
|
|
198
|
+
`state: ${record.state}`,
|
|
199
|
+
`phase: ${record.phase ?? '-'}`,
|
|
200
|
+
`stage: ${record.stage ?? '-'}`,
|
|
201
|
+
`agent: ${record.agent ?? '-'}`,
|
|
202
|
+
`started: ${record.startedAt} (${formatRelativeTime(record.startedAt)})`,
|
|
203
|
+
`finished: ${record.finishedAt ?? '-'}`,
|
|
204
|
+
`branch: ${record.branch ?? '-'}`,
|
|
205
|
+
`worktree: ${record.worktree ?? '-'}`,
|
|
206
|
+
`exitCode: ${record.exitCode ?? '-'}`,
|
|
207
|
+
`log: ${record.logPath ?? '-'}`,
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
if (record.parent) {
|
|
211
|
+
lines.splice(1, 0, `parent: ${record.parent}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const statusPath = path.join(jobPaths(cwd, record.slug).dir, 'status.md');
|
|
215
|
+
if (fs.existsSync(statusPath)) {
|
|
216
|
+
const last = lastNonEmptyLine(fs.readFileSync(statusPath, 'utf8'));
|
|
217
|
+
if (last) lines.push(`status: ${last}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Child view: parent line only — do not expand siblings.
|
|
221
|
+
// Read children from disk without reconcile so status reflects recorded
|
|
222
|
+
// state/phase/branch (listJobs would rewrite dead-pid live states to crashed).
|
|
223
|
+
if (!record.parent) {
|
|
224
|
+
const orchDir = path.join(path.resolve(cwd), '.orch');
|
|
225
|
+
const children = [];
|
|
226
|
+
if (fs.existsSync(orchDir)) {
|
|
227
|
+
for (const name of fs.readdirSync(orchDir)) {
|
|
228
|
+
const child = readJob(cwd, name);
|
|
229
|
+
if (child?.parent === record.slug) children.push(child);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
children.sort(compareFanoutChildren);
|
|
233
|
+
if (record.role === 'coordinator' || children.length > 0) {
|
|
234
|
+
for (const child of children) {
|
|
235
|
+
lines.push(` ${child.slug} ${child.state} ${child.phase ?? '-'} ${child.branch ?? '-'}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return lines.join('\n');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** True when pause/resume/stop should cascade to children. */
|
|
244
|
+
function isCascadeParent(cwd, record) {
|
|
245
|
+
if (record?.role === 'coordinator') return true;
|
|
246
|
+
return listJobs(cwd).some((job) => job.parent === record.slug);
|
|
247
|
+
}
|
|
248
|
+
|
|
74
249
|
function formatVerdictFeedback(verdict, rawResult) {
|
|
75
250
|
const lines = [];
|
|
76
251
|
if (verdict.summary) lines.push(verdict.summary);
|
|
@@ -96,6 +271,246 @@ function roundLabel(role, round, maxRounds) {
|
|
|
96
271
|
return `${role} ${round}/${maxRounds}`;
|
|
97
272
|
}
|
|
98
273
|
|
|
274
|
+
function defaultExecFile(command, args, options = {}) {
|
|
275
|
+
return execFileSync(command, args, { encoding: 'utf8', ...options });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The test-writer ⇄ test-critic loop shared by `runPipeline` and `runWorkerPipeline`. */
|
|
279
|
+
async function runTestLoop({
|
|
280
|
+
prompt,
|
|
281
|
+
worktreePath,
|
|
282
|
+
branch,
|
|
283
|
+
taskPath,
|
|
284
|
+
statusPath,
|
|
285
|
+
maxRounds,
|
|
286
|
+
AgentClass,
|
|
287
|
+
verbose,
|
|
288
|
+
jobPatch,
|
|
289
|
+
jobCheckpoint,
|
|
290
|
+
}) {
|
|
291
|
+
let testAccepted = null;
|
|
292
|
+
let criticFeedback = null;
|
|
293
|
+
let testRound = 0;
|
|
294
|
+
let testSummary = '';
|
|
295
|
+
|
|
296
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
297
|
+
testRound = round;
|
|
298
|
+
|
|
299
|
+
jobPatch({ phase: 'test-loop', stage: 'test-writer', round });
|
|
300
|
+
const testWriterArgs = testWriterAgentArgs({
|
|
301
|
+
prompt,
|
|
302
|
+
cwd: worktreePath,
|
|
303
|
+
worktreePath,
|
|
304
|
+
branch,
|
|
305
|
+
taskPath,
|
|
306
|
+
statusPath,
|
|
307
|
+
criticFeedback,
|
|
308
|
+
});
|
|
309
|
+
const testWriterTracker = new FileTracker({ cwd: worktreePath });
|
|
310
|
+
const testWriter = new AgentClass(
|
|
311
|
+
roundLabel('test-writer', round, maxRounds),
|
|
312
|
+
testWriterArgs.instructions,
|
|
313
|
+
testWriterArgs.prompt,
|
|
314
|
+
{ ...testWriterArgs.options, fileTracker: testWriterTracker },
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const testOut = await testWriter.run({ verbose });
|
|
318
|
+
await jobCheckpoint();
|
|
319
|
+
const { content: testWriterContent, summary: testWriterSummary } = splitStageSummary(testOut.result);
|
|
320
|
+
printStageSummary(
|
|
321
|
+
roundLabel('test-writer', round, maxRounds),
|
|
322
|
+
testWriterSummary,
|
|
323
|
+
testWriterTracker.getFiles(),
|
|
324
|
+
);
|
|
325
|
+
if (!testOut.ok) {
|
|
326
|
+
appendLoopStatus(statusPath, 'Test loop', {
|
|
327
|
+
round: testRound,
|
|
328
|
+
maxRounds,
|
|
329
|
+
passed: false,
|
|
330
|
+
summary: 'test-writer failed',
|
|
331
|
+
});
|
|
332
|
+
throw new Error('test-writer failed; stopping before code-writer');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
jobPatch({ phase: 'test-loop', stage: 'test-critic', round });
|
|
336
|
+
const testCriticArgs = testCriticAgentArgs({
|
|
337
|
+
prompt,
|
|
338
|
+
cwd: worktreePath,
|
|
339
|
+
worktreePath,
|
|
340
|
+
branch,
|
|
341
|
+
taskPath,
|
|
342
|
+
statusPath,
|
|
343
|
+
testWriterOutput: testWriterContent,
|
|
344
|
+
});
|
|
345
|
+
const testCritic = new AgentClass(
|
|
346
|
+
roundLabel('test-critic', round, maxRounds),
|
|
347
|
+
testCriticArgs.instructions,
|
|
348
|
+
testCriticArgs.prompt,
|
|
349
|
+
testCriticArgs.options,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const criticOut = await testCritic.run({ verbose });
|
|
353
|
+
await jobCheckpoint();
|
|
354
|
+
const { content: testCriticContent, summary: testCriticSummary } = splitStageSummary(criticOut.result);
|
|
355
|
+
printStageSummary(roundLabel('test-critic', round, maxRounds), testCriticSummary);
|
|
356
|
+
if (!criticOut.ok) {
|
|
357
|
+
appendLoopStatus(statusPath, 'Test loop', {
|
|
358
|
+
round: testRound,
|
|
359
|
+
maxRounds,
|
|
360
|
+
passed: false,
|
|
361
|
+
summary: 'test-critic failed',
|
|
362
|
+
});
|
|
363
|
+
throw new Error('test-critic failed; stopping before code-writer');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const verdict = parseVerdict(testCriticContent);
|
|
367
|
+
testSummary = verdict.summary;
|
|
368
|
+
if (verdict.passed) {
|
|
369
|
+
testAccepted = { writerContent: testWriterContent, criticOut, verdict, round };
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
criticFeedback = formatVerdictFeedback(verdict, testCriticContent);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
appendLoopStatus(statusPath, 'Test loop', {
|
|
376
|
+
round: testAccepted?.round ?? testRound,
|
|
377
|
+
maxRounds,
|
|
378
|
+
passed: Boolean(testAccepted),
|
|
379
|
+
summary: testAccepted?.verdict.summary ?? testSummary,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
if (!testAccepted) {
|
|
383
|
+
throw new Error(`test loop exhausted after ${maxRounds} rounds`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return testAccepted;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The code-writer ⇄ test-runner loop shared by `runPipeline`, `runWorkerPipeline`, and
|
|
391
|
+
* `runIntegratePipeline`. `runnerFirst` (used by `--integrate`'s verify loop) skips
|
|
392
|
+
* `code-writer` on round 1 only; if that lone `test-runner` attempt fails, rounds 2+
|
|
393
|
+
* alternate `code-writer` → `test-runner` exactly like the default writer-first shape.
|
|
394
|
+
*/
|
|
395
|
+
async function runCodeLoop({
|
|
396
|
+
prompt,
|
|
397
|
+
worktreePath,
|
|
398
|
+
branch,
|
|
399
|
+
taskPath,
|
|
400
|
+
statusPath,
|
|
401
|
+
maxRounds,
|
|
402
|
+
AgentClass,
|
|
403
|
+
verbose,
|
|
404
|
+
jobPatch,
|
|
405
|
+
jobCheckpoint,
|
|
406
|
+
acceptedVerification,
|
|
407
|
+
runnerFirst = false,
|
|
408
|
+
loopTitle = 'Code loop',
|
|
409
|
+
}) {
|
|
410
|
+
let codeAccepted = null;
|
|
411
|
+
let runnerFeedback = null;
|
|
412
|
+
let codeRound = 0;
|
|
413
|
+
let codeSummary = '';
|
|
414
|
+
let codeWriterContent = null;
|
|
415
|
+
|
|
416
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
417
|
+
codeRound = round;
|
|
418
|
+
const skipWriter = runnerFirst && round === 1;
|
|
419
|
+
|
|
420
|
+
if (!skipWriter) {
|
|
421
|
+
jobPatch({ phase: 'code-loop', stage: 'code-writer', round });
|
|
422
|
+
const codeWriterArgs = codeWriterAgentArgs({
|
|
423
|
+
prompt,
|
|
424
|
+
cwd: worktreePath,
|
|
425
|
+
worktreePath,
|
|
426
|
+
branch,
|
|
427
|
+
taskPath,
|
|
428
|
+
statusPath,
|
|
429
|
+
round,
|
|
430
|
+
acceptedVerification,
|
|
431
|
+
runnerFeedback,
|
|
432
|
+
});
|
|
433
|
+
const codeWriterTracker = new FileTracker({ cwd: worktreePath });
|
|
434
|
+
const codeWriter = new AgentClass(
|
|
435
|
+
roundLabel('code-writer', round, maxRounds),
|
|
436
|
+
codeWriterArgs.instructions,
|
|
437
|
+
codeWriterArgs.prompt,
|
|
438
|
+
{ ...codeWriterArgs.options, fileTracker: codeWriterTracker },
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
const codeOut = await codeWriter.run({ verbose });
|
|
442
|
+
await jobCheckpoint();
|
|
443
|
+
const { content, summary } = splitStageSummary(codeOut.result);
|
|
444
|
+
codeWriterContent = content;
|
|
445
|
+
printStageSummary(
|
|
446
|
+
roundLabel('code-writer', round, maxRounds),
|
|
447
|
+
summary,
|
|
448
|
+
codeWriterTracker.getFiles(),
|
|
449
|
+
);
|
|
450
|
+
if (!codeOut.ok) {
|
|
451
|
+
appendLoopStatus(statusPath, loopTitle, {
|
|
452
|
+
round: codeRound,
|
|
453
|
+
maxRounds,
|
|
454
|
+
passed: false,
|
|
455
|
+
summary: 'code-writer failed',
|
|
456
|
+
});
|
|
457
|
+
throw new Error('code-writer failed; stopping before commit');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
jobPatch({ phase: 'code-loop', stage: 'test-runner', round });
|
|
462
|
+
const testRunnerArgs = testRunnerAgentArgs({
|
|
463
|
+
prompt,
|
|
464
|
+
cwd: worktreePath,
|
|
465
|
+
worktreePath,
|
|
466
|
+
branch,
|
|
467
|
+
statusPath,
|
|
468
|
+
codeWriterOutput: codeWriterContent,
|
|
469
|
+
});
|
|
470
|
+
const testRunner = new AgentClass(
|
|
471
|
+
roundLabel('test-runner', round, maxRounds),
|
|
472
|
+
testRunnerArgs.instructions,
|
|
473
|
+
testRunnerArgs.prompt,
|
|
474
|
+
testRunnerArgs.options,
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
const runnerOut = await testRunner.run({ verbose });
|
|
478
|
+
await jobCheckpoint();
|
|
479
|
+
const { content: testRunnerContent, summary: testRunnerSummary } = splitStageSummary(runnerOut.result);
|
|
480
|
+
printStageSummary(roundLabel('test-runner', round, maxRounds), testRunnerSummary);
|
|
481
|
+
if (!runnerOut.ok) {
|
|
482
|
+
appendLoopStatus(statusPath, loopTitle, {
|
|
483
|
+
round: codeRound,
|
|
484
|
+
maxRounds,
|
|
485
|
+
passed: false,
|
|
486
|
+
summary: 'test-runner failed',
|
|
487
|
+
});
|
|
488
|
+
throw new Error('test-runner failed; stopping before commit');
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const verdict = parseVerdict(testRunnerContent);
|
|
492
|
+
codeSummary = verdict.summary;
|
|
493
|
+
if (verdict.passed) {
|
|
494
|
+
codeAccepted = { writerContent: codeWriterContent, verdict, round };
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
runnerFeedback = formatVerdictFeedback(verdict, testRunnerContent);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
appendLoopStatus(statusPath, loopTitle, {
|
|
501
|
+
round: codeAccepted?.round ?? codeRound,
|
|
502
|
+
maxRounds,
|
|
503
|
+
passed: Boolean(codeAccepted),
|
|
504
|
+
summary: codeAccepted?.verdict.summary ?? codeSummary,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
if (!codeAccepted) {
|
|
508
|
+
throw new Error(`code loop exhausted after ${maxRounds} rounds`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return codeAccepted;
|
|
512
|
+
}
|
|
513
|
+
|
|
99
514
|
export async function runPipeline(prompt, options) {
|
|
100
515
|
const verbose = Boolean(options.verbose);
|
|
101
516
|
const maxRounds = options.maxRounds ?? 5;
|
|
@@ -108,8 +523,24 @@ export async function runPipeline(prompt, options) {
|
|
|
108
523
|
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
109
524
|
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
110
525
|
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
526
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
111
527
|
const invocationCwd = process.cwd();
|
|
112
528
|
|
|
529
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
530
|
+
const jobCwd = options.jobCwd ?? invocationCwd;
|
|
531
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
532
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
533
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
534
|
+
|
|
535
|
+
const jobPatch = (fields) => {
|
|
536
|
+
if (!jobSlug) return;
|
|
537
|
+
patchJobFn(jobCwd, jobSlug, fields);
|
|
538
|
+
};
|
|
539
|
+
const jobCheckpoint = async () => {
|
|
540
|
+
if (!jobSlug) return;
|
|
541
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
542
|
+
};
|
|
543
|
+
|
|
113
544
|
console.log(`cwd: ${invocationCwd}`);
|
|
114
545
|
console.log(`agent: ${options.agent}`);
|
|
115
546
|
|
|
@@ -129,6 +560,7 @@ export async function runPipeline(prompt, options) {
|
|
|
129
560
|
console.log();
|
|
130
561
|
|
|
131
562
|
if (options.ask) {
|
|
563
|
+
jobPatch({ phase: 'ask' });
|
|
132
564
|
const ask = askAgentArgs({ prompt, cwd: invocationCwd });
|
|
133
565
|
const askAgent = new AgentClass(ask.name, ask.instructions, ask.prompt, ask.options);
|
|
134
566
|
|
|
@@ -136,37 +568,47 @@ export async function runPipeline(prompt, options) {
|
|
|
136
568
|
const askResult = await askAgent.run({ verbose });
|
|
137
569
|
if (!askResult.ok) {
|
|
138
570
|
console.error(`Error: ask agent failed`);
|
|
571
|
+
jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
|
|
139
572
|
process.exit(1);
|
|
140
573
|
return;
|
|
141
574
|
}
|
|
142
575
|
const { content, summary } = splitStageSummary(askResult.result);
|
|
143
576
|
printStageSummary('ask', summary);
|
|
144
577
|
console.log(content);
|
|
578
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
145
579
|
} catch (err) {
|
|
146
580
|
console.error(`Error: ${err.message}`);
|
|
581
|
+
jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
|
|
147
582
|
process.exit(1);
|
|
148
583
|
}
|
|
149
584
|
return;
|
|
150
585
|
}
|
|
151
586
|
|
|
152
587
|
if (options.quick) {
|
|
588
|
+
jobPatch({ phase: 'quick-fix' });
|
|
153
589
|
const quickFix = quickFixAgentArgs({ prompt, cwd: invocationCwd });
|
|
590
|
+
const quickFixTracker = new FileTracker({ cwd: invocationCwd });
|
|
154
591
|
const quickFixAgent = new AgentClass(
|
|
155
592
|
quickFix.name,
|
|
156
593
|
quickFix.instructions,
|
|
157
594
|
quickFix.prompt,
|
|
158
|
-
quickFix.options,
|
|
595
|
+
{ ...quickFix.options, fileTracker: quickFixTracker },
|
|
159
596
|
);
|
|
160
597
|
|
|
161
598
|
try {
|
|
162
599
|
const quickFixResult = await quickFixAgent.run({ verbose });
|
|
163
600
|
if (!quickFixResult.ok) {
|
|
164
601
|
console.error(`Error: quick-fix agent failed`);
|
|
602
|
+
jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
|
|
165
603
|
process.exit(1);
|
|
166
604
|
return;
|
|
167
605
|
}
|
|
606
|
+
const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
607
|
+
printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
|
|
608
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
168
609
|
} catch (err) {
|
|
169
610
|
console.error(`Error: ${err.message}`);
|
|
611
|
+
jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
|
|
170
612
|
process.exit(1);
|
|
171
613
|
}
|
|
172
614
|
return;
|
|
@@ -181,33 +623,43 @@ export async function runPipeline(prompt, options) {
|
|
|
181
623
|
);
|
|
182
624
|
|
|
183
625
|
try {
|
|
626
|
+
jobPatch({ phase: 'triage', stage: 'triage', round: null });
|
|
627
|
+
await jobCheckpoint();
|
|
184
628
|
const triageResult = await triageAgent.run({ verbose });
|
|
629
|
+
await jobCheckpoint();
|
|
185
630
|
const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
|
|
186
631
|
printStageSummary('triage', triageSummary);
|
|
187
632
|
const parsed = parseTriageJson(triageContent);
|
|
188
633
|
|
|
189
634
|
if (parsed?.simple === true) {
|
|
635
|
+
jobPatch({ phase: 'quick-fix', stage: 'quick-fix', round: null });
|
|
190
636
|
const quickFix = quickFixAgentArgs({
|
|
191
637
|
prompt,
|
|
192
638
|
cwd: invocationCwd,
|
|
193
639
|
fix_plan: parsed.fix_plan,
|
|
194
640
|
});
|
|
641
|
+
const quickFixTracker = new FileTracker({ cwd: invocationCwd });
|
|
195
642
|
const quickFixAgent = new AgentClass(
|
|
196
643
|
quickFix.name,
|
|
197
644
|
quickFix.instructions,
|
|
198
645
|
quickFix.prompt,
|
|
199
|
-
quickFix.options,
|
|
646
|
+
{ ...quickFix.options, fileTracker: quickFixTracker },
|
|
200
647
|
);
|
|
201
648
|
|
|
202
649
|
const quickFixResult = await quickFixAgent.run({ verbose });
|
|
650
|
+
await jobCheckpoint();
|
|
203
651
|
const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
204
|
-
printStageSummary('quick-fix', quickFixSummary);
|
|
652
|
+
printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
|
|
653
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
205
654
|
return;
|
|
206
655
|
}
|
|
207
656
|
|
|
208
|
-
const runContext = createRunContextFn(
|
|
657
|
+
const runContext = createRunContextFn(
|
|
658
|
+
jobSlug ? { cwd: invocationCwd, slug: jobSlug } : { cwd: invocationCwd },
|
|
659
|
+
);
|
|
209
660
|
console.log(`task ${runContext.slug} is started`);
|
|
210
661
|
|
|
662
|
+
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
211
663
|
const research = researchAgentArgs({
|
|
212
664
|
prompt,
|
|
213
665
|
cwd: invocationCwd,
|
|
@@ -221,9 +673,11 @@ export async function runPipeline(prompt, options) {
|
|
|
221
673
|
);
|
|
222
674
|
|
|
223
675
|
const result = await researchAgent.run({ verbose });
|
|
676
|
+
await jobCheckpoint();
|
|
224
677
|
const { content: researchContent, summary: researchSummary } = splitStageSummary(result.result);
|
|
225
678
|
printStageSummary('research', researchSummary);
|
|
226
679
|
|
|
680
|
+
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
227
681
|
const planner = plannerAgentArgs({
|
|
228
682
|
prompt,
|
|
229
683
|
cwd: invocationCwd,
|
|
@@ -239,10 +693,13 @@ export async function runPipeline(prompt, options) {
|
|
|
239
693
|
);
|
|
240
694
|
|
|
241
695
|
const plannerResult = await plannerAgent.run({ verbose });
|
|
696
|
+
await jobCheckpoint();
|
|
242
697
|
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
243
698
|
printStageSummary('planner', plannerSummary);
|
|
244
699
|
|
|
700
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
245
701
|
const worktree = createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
|
|
702
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
246
703
|
|
|
247
704
|
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
248
705
|
fs.writeFileSync(
|
|
@@ -251,98 +708,20 @@ export async function runPipeline(prompt, options) {
|
|
|
251
708
|
);
|
|
252
709
|
|
|
253
710
|
// --- 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,
|
|
711
|
+
const testAccepted = await runTestLoop({
|
|
712
|
+
prompt,
|
|
713
|
+
worktreePath: worktree.worktreePath,
|
|
714
|
+
branch: worktree.branch,
|
|
715
|
+
taskPath: runContext.taskPath,
|
|
716
|
+
statusPath: runContext.statusPath,
|
|
331
717
|
maxRounds,
|
|
332
|
-
|
|
333
|
-
|
|
718
|
+
AgentClass,
|
|
719
|
+
verbose,
|
|
720
|
+
jobPatch,
|
|
721
|
+
jobCheckpoint,
|
|
334
722
|
});
|
|
335
723
|
|
|
336
|
-
if (!testAccepted) {
|
|
337
|
-
throw new Error(`test loop exhausted after ${maxRounds} rounds`);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
724
|
// --- code loop: code-writer ⇄ test-runner ---
|
|
341
|
-
let codeAccepted = null;
|
|
342
|
-
let runnerFeedback = null;
|
|
343
|
-
let codeRound = 0;
|
|
344
|
-
let codeSummary = '';
|
|
345
|
-
|
|
346
725
|
const acceptedVerification = [
|
|
347
726
|
testAccepted.verdict.summary,
|
|
348
727
|
testAccepted.writerContent,
|
|
@@ -350,89 +729,26 @@ export async function runPipeline(prompt, options) {
|
|
|
350
729
|
.filter(Boolean)
|
|
351
730
|
.join('\n');
|
|
352
731
|
|
|
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,
|
|
732
|
+
await runCodeLoop({
|
|
733
|
+
prompt,
|
|
734
|
+
worktreePath: worktree.worktreePath,
|
|
735
|
+
branch: worktree.branch,
|
|
736
|
+
taskPath: runContext.taskPath,
|
|
737
|
+
statusPath: runContext.statusPath,
|
|
426
738
|
maxRounds,
|
|
427
|
-
|
|
428
|
-
|
|
739
|
+
AgentClass,
|
|
740
|
+
verbose,
|
|
741
|
+
jobPatch,
|
|
742
|
+
jobCheckpoint,
|
|
743
|
+
acceptedVerification,
|
|
429
744
|
});
|
|
430
745
|
|
|
431
|
-
|
|
432
|
-
throw new Error(`code loop exhausted after ${maxRounds} rounds`);
|
|
433
|
-
}
|
|
434
|
-
|
|
746
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
435
747
|
const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
|
|
748
|
+
const worktreeChanges = collectWorktreeChangesFn({
|
|
749
|
+
worktreePath: worktree.worktreePath,
|
|
750
|
+
});
|
|
751
|
+
printFilesChanged(worktreeChanges);
|
|
436
752
|
const commitResult = commitWorktreeFn({
|
|
437
753
|
worktreePath: worktree.worktreePath,
|
|
438
754
|
branch: worktree.branch,
|
|
@@ -453,54 +769,1666 @@ export async function runPipeline(prompt, options) {
|
|
|
453
769
|
);
|
|
454
770
|
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
455
771
|
}
|
|
772
|
+
|
|
773
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
456
774
|
} catch (err) {
|
|
457
775
|
console.error(`Error: ${err.message}`);
|
|
776
|
+
if (jobSlug) {
|
|
777
|
+
try {
|
|
778
|
+
patchJobFn(jobCwd, jobSlug, {
|
|
779
|
+
state: 'failed',
|
|
780
|
+
exitCode: 1,
|
|
781
|
+
finishedAt: new Date().toISOString(),
|
|
782
|
+
});
|
|
783
|
+
} catch {
|
|
784
|
+
// Best-effort: don't let a job-state write failure mask the real error.
|
|
785
|
+
}
|
|
786
|
+
}
|
|
458
787
|
process.exit(1);
|
|
459
788
|
}
|
|
460
789
|
}
|
|
461
790
|
|
|
462
|
-
|
|
791
|
+
/**
|
|
792
|
+
* The detach-PARENT path: allocates a run directory, writes an initial
|
|
793
|
+
* `run.json`, spawns a `--detach`-stripped re-invocation of this CLI with
|
|
794
|
+
* `ORCH_JOB_SLUG`/`ORCH_DETACHED` set, patches in the child's pid, and
|
|
795
|
+
* returns immediately. `runPipeline` (the child/pipeline-running path) never
|
|
796
|
+
* runs in this process.
|
|
797
|
+
*/
|
|
798
|
+
export async function runDetached(prompt, options = {}) {
|
|
799
|
+
const {
|
|
800
|
+
agent,
|
|
801
|
+
maxRounds = 5,
|
|
802
|
+
verbose,
|
|
803
|
+
cwd = process.cwd(),
|
|
804
|
+
createRunContext: createRunContextFn = createRunContext,
|
|
805
|
+
spawn: spawnFn = spawn,
|
|
806
|
+
exit = (code) => process.exit(code),
|
|
807
|
+
} = options;
|
|
463
808
|
|
|
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
|
-
|
|
809
|
+
const backend = AGENT_BACKENDS[agent];
|
|
810
|
+
if (!backend) {
|
|
811
|
+
throw new Error(`Unknown agent backend: ${agent}`);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (!isBinaryOnPath(backend.binary)) {
|
|
815
|
+
console.error(binaryMissingHint(agent));
|
|
816
|
+
exit(1);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const { slug } = allocateJob({
|
|
821
|
+
cwd,
|
|
822
|
+
prompt,
|
|
823
|
+
agent,
|
|
824
|
+
maxRounds,
|
|
825
|
+
state: 'starting',
|
|
826
|
+
createRunContext: createRunContextFn,
|
|
827
|
+
});
|
|
828
|
+
const { logPath } = jobPaths(cwd, slug);
|
|
829
|
+
|
|
830
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
831
|
+
|
|
832
|
+
const childArgs = [__filename, prompt, '--agent', agent, '--max-rounds', String(maxRounds)];
|
|
833
|
+
if (verbose) childArgs.push('--verbose');
|
|
834
|
+
|
|
835
|
+
const child = spawnFn(process.execPath, childArgs, {
|
|
836
|
+
cwd,
|
|
837
|
+
env: { ...process.env, ORCH_JOB_SLUG: slug, ORCH_DETACHED: '1' },
|
|
838
|
+
detached: true,
|
|
839
|
+
stdio: ['ignore', logFd, logFd],
|
|
840
|
+
});
|
|
841
|
+
child.unref();
|
|
842
|
+
|
|
843
|
+
patchJob(cwd, slug, { pid: child.pid, state: 'running' });
|
|
844
|
+
|
|
845
|
+
console.log(`started ${slug} (pid ${child.pid})`);
|
|
846
|
+
exit(0);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* The `--worker <parent>:<workerId>` driver: skips triage and runs research → planner →
|
|
851
|
+
* worktree (from the fan-out's recorded `base`) → test loop → code loop (writer-first) →
|
|
852
|
+
* commit, exactly like `runPipeline` minus triage. `prompt` is the worker's subtask text
|
|
853
|
+
* with the envelope already appended by the CLI wiring. On success, patches the parent's
|
|
854
|
+
* `fanout.json.workers[]` entry to `state:'done'` with `sha`/`changedFiles`; on failure,
|
|
855
|
+
* patches it (and this job's own `run.json`) to `state:'failed'` before exiting non-zero.
|
|
856
|
+
*/
|
|
857
|
+
export async function runWorkerPipeline(prompt, options = {}) {
|
|
858
|
+
const verbose = Boolean(options.verbose);
|
|
859
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
860
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
861
|
+
if (!backend) {
|
|
862
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
863
|
+
}
|
|
864
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
865
|
+
const cwd = options.cwd ?? process.cwd();
|
|
866
|
+
const { parentSlug, workerId, base } = options;
|
|
867
|
+
|
|
868
|
+
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
869
|
+
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
870
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
871
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
872
|
+
const patchWorkerFn = options.patchWorker ?? patchWorker;
|
|
873
|
+
const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
|
|
874
|
+
const execFileFn = options.execFile;
|
|
875
|
+
|
|
876
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
877
|
+
const jobCwd = options.jobCwd ?? cwd;
|
|
878
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
879
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
880
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
881
|
+
|
|
882
|
+
const jobPatch = (fields) => {
|
|
883
|
+
if (!jobSlug) return;
|
|
884
|
+
patchJobFn(jobCwd, jobSlug, fields);
|
|
885
|
+
};
|
|
886
|
+
const jobCheckpoint = async () => {
|
|
887
|
+
if (!jobSlug) return;
|
|
888
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
if (!options.AgentClass) {
|
|
892
|
+
ensureBinaryOnPath(backend.binary, options.agent);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
try {
|
|
896
|
+
const runContext = createRunContextFn(jobSlug ? { cwd, slug: jobSlug } : { cwd });
|
|
897
|
+
|
|
898
|
+
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
899
|
+
const research = researchAgentArgs({ prompt, cwd, researchPath: runContext.researchPath });
|
|
900
|
+
const researchAgent = new AgentClass(
|
|
901
|
+
research.name,
|
|
902
|
+
research.instructions,
|
|
903
|
+
research.prompt,
|
|
904
|
+
research.options,
|
|
905
|
+
);
|
|
906
|
+
const researchResult = await researchAgent.run({ verbose });
|
|
907
|
+
await jobCheckpoint();
|
|
908
|
+
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
909
|
+
printStageSummary('research', researchSummary);
|
|
910
|
+
|
|
911
|
+
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
912
|
+
const planner = plannerAgentArgs({
|
|
913
|
+
prompt,
|
|
914
|
+
cwd,
|
|
915
|
+
researchPath: runContext.researchPath,
|
|
916
|
+
taskPath: runContext.taskPath,
|
|
917
|
+
researchOutput: researchContent,
|
|
918
|
+
});
|
|
919
|
+
const plannerAgent = new AgentClass(
|
|
920
|
+
planner.name,
|
|
921
|
+
planner.instructions,
|
|
922
|
+
planner.prompt,
|
|
923
|
+
planner.options,
|
|
924
|
+
);
|
|
925
|
+
const plannerResult = await plannerAgent.run({ verbose });
|
|
926
|
+
await jobCheckpoint();
|
|
927
|
+
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
928
|
+
printStageSummary('planner', plannerSummary);
|
|
929
|
+
|
|
930
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
931
|
+
const worktree = createWorktreeFn({ cwd, slug: runContext.slug, base });
|
|
932
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
933
|
+
|
|
934
|
+
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
935
|
+
fs.writeFileSync(
|
|
936
|
+
runContext.statusPath,
|
|
937
|
+
`# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n- Parent: \`${parentSlug}\`\n- Worker: \`${workerId}\`\n`,
|
|
938
|
+
);
|
|
939
|
+
|
|
940
|
+
const testAccepted = await runTestLoop({
|
|
941
|
+
prompt,
|
|
942
|
+
worktreePath: worktree.worktreePath,
|
|
943
|
+
branch: worktree.branch,
|
|
944
|
+
taskPath: runContext.taskPath,
|
|
945
|
+
statusPath: runContext.statusPath,
|
|
946
|
+
maxRounds,
|
|
947
|
+
AgentClass,
|
|
948
|
+
verbose,
|
|
949
|
+
jobPatch,
|
|
950
|
+
jobCheckpoint,
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
|
|
954
|
+
.filter(Boolean)
|
|
955
|
+
.join('\n');
|
|
956
|
+
|
|
957
|
+
await runCodeLoop({
|
|
958
|
+
prompt,
|
|
959
|
+
worktreePath: worktree.worktreePath,
|
|
960
|
+
branch: worktree.branch,
|
|
961
|
+
taskPath: runContext.taskPath,
|
|
962
|
+
statusPath: runContext.statusPath,
|
|
963
|
+
maxRounds,
|
|
964
|
+
AgentClass,
|
|
965
|
+
verbose,
|
|
966
|
+
jobPatch,
|
|
967
|
+
jobCheckpoint,
|
|
968
|
+
acceptedVerification,
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
972
|
+
const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
|
|
973
|
+
const worktreeChanges = collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
|
|
974
|
+
printFilesChanged(worktreeChanges);
|
|
975
|
+
const commitResult = commitWorktreeFn({
|
|
976
|
+
worktreePath: worktree.worktreePath,
|
|
977
|
+
branch: worktree.branch,
|
|
978
|
+
message,
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
if (commitResult.committed) {
|
|
982
|
+
fs.appendFileSync(
|
|
983
|
+
runContext.statusPath,
|
|
984
|
+
`\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
|
|
985
|
+
);
|
|
986
|
+
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
987
|
+
} else {
|
|
988
|
+
fs.appendFileSync(
|
|
989
|
+
runContext.statusPath,
|
|
990
|
+
`\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
|
|
991
|
+
);
|
|
992
|
+
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
let changedFiles = [];
|
|
996
|
+
try {
|
|
997
|
+
changedFiles = recordChangedFilesFn({
|
|
998
|
+
repoRoot: worktree.repoRoot,
|
|
999
|
+
base,
|
|
1000
|
+
branch: worktree.branch,
|
|
1001
|
+
execFile: execFileFn,
|
|
1002
|
+
});
|
|
1003
|
+
} catch {
|
|
1004
|
+
// Best-effort: changedFiles is informational only, never masks a successful commit.
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
patchWorkerFn(cwd, parentSlug, workerId, {
|
|
1008
|
+
state: 'done',
|
|
1009
|
+
sha: commitResult.sha,
|
|
1010
|
+
changedFiles,
|
|
1011
|
+
});
|
|
1012
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
console.error(`Error: ${err.message}`);
|
|
1015
|
+
try {
|
|
1016
|
+
patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
|
|
1017
|
+
} catch {
|
|
1018
|
+
// Best-effort: don't let a fanout-state write failure mask the real error.
|
|
1019
|
+
}
|
|
1020
|
+
if (jobSlug) {
|
|
1021
|
+
try {
|
|
1022
|
+
patchJobFn(jobCwd, jobSlug, {
|
|
1023
|
+
state: 'failed',
|
|
1024
|
+
exitCode: 1,
|
|
1025
|
+
finishedAt: new Date().toISOString(),
|
|
1026
|
+
});
|
|
1027
|
+
} catch {
|
|
1028
|
+
// Best-effort: don't let a job-state write failure mask the real error.
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
process.exit(1);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* The `--integrate <parent>` driver: reuses (or creates) the integration worktree keyed
|
|
1037
|
+
* by the parent slug, merges `fanout.integration.candidates` in order (repairing conflicts
|
|
1038
|
+
* via the `integrator` agent, one conflict at a time), then runs a runner-first verify
|
|
1039
|
+
* loop and commits on green. Never invokes triage/research/planner/test-writer/test-critic.
|
|
1040
|
+
* Appends every step to `.orch/<job-slug>/integration.md` as it happens.
|
|
1041
|
+
*/
|
|
1042
|
+
export async function runIntegratePipeline(options = {}) {
|
|
1043
|
+
const verbose = Boolean(options.verbose);
|
|
1044
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
1045
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
1046
|
+
if (!backend) {
|
|
1047
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
1048
|
+
}
|
|
1049
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
1050
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1051
|
+
const { parentSlug } = options;
|
|
1052
|
+
|
|
1053
|
+
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
1054
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
1055
|
+
const readFanoutFn = options.readFanout ?? readFanout;
|
|
1056
|
+
const patchIntegrationFn = options.patchIntegration ?? patchIntegration;
|
|
1057
|
+
const mergeBranchesFn = options.mergeBranches ?? mergeBranches;
|
|
1058
|
+
const abortMergeFn = options.abortMerge ?? abortMerge;
|
|
1059
|
+
const conflictedFilesFn = options.conflictedFiles ?? conflictedFiles;
|
|
1060
|
+
const hasConflictMarkersFn = options.hasConflictMarkers ?? hasConflictMarkers;
|
|
1061
|
+
const execFileFn = options.execFile ?? defaultExecFile;
|
|
1062
|
+
|
|
1063
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
1064
|
+
const jobCwd = options.jobCwd ?? cwd;
|
|
1065
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
1066
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
1067
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
1068
|
+
|
|
1069
|
+
const jobPatch = (fields) => {
|
|
1070
|
+
if (!jobSlug) return;
|
|
1071
|
+
patchJobFn(jobCwd, jobSlug, fields);
|
|
1072
|
+
};
|
|
1073
|
+
const jobCheckpoint = async () => {
|
|
1074
|
+
if (!jobSlug) return;
|
|
1075
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
if (!options.AgentClass) {
|
|
1079
|
+
ensureBinaryOnPath(backend.binary, options.agent);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const fanout = readFanoutFn(cwd, parentSlug);
|
|
1083
|
+
if (!fanout) {
|
|
1084
|
+
console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
|
|
1085
|
+
process.exit(1);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const integrationSlug = jobSlug ?? parentSlug;
|
|
1090
|
+
const integrationDir = path.join(jobCwd, '.orch', integrationSlug);
|
|
1091
|
+
const integrationMdPath = path.join(integrationDir, 'integration.md');
|
|
1092
|
+
const logIntegration = (line) => {
|
|
1093
|
+
fs.mkdirSync(integrationDir, { recursive: true });
|
|
1094
|
+
fs.appendFileSync(integrationMdPath, `${line}\n`);
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
let merged = [...fanout.integration.merged];
|
|
1098
|
+
let skipped = [...(fanout.integration.skipped ?? [])];
|
|
1099
|
+
|
|
1100
|
+
try {
|
|
1101
|
+
fs.mkdirSync(integrationDir, { recursive: true });
|
|
1102
|
+
fs.appendFileSync(integrationMdPath, `# Integration: ${parentSlug}\n\n`);
|
|
1103
|
+
|
|
1104
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
1105
|
+
|
|
1106
|
+
const reuseWorktreePath = `${cwd}-${parentSlug}`;
|
|
1107
|
+
const expectedBranch = `orch/${parentSlug}`;
|
|
1108
|
+
let worktree = null;
|
|
1109
|
+
|
|
1110
|
+
if (fs.existsSync(reuseWorktreePath)) {
|
|
1111
|
+
const currentBranch = execFileFn('git', ['-C', reuseWorktreePath, 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
1112
|
+
if (currentBranch === expectedBranch) {
|
|
1113
|
+
worktree = { repoRoot: cwd, worktreePath: reuseWorktreePath, branch: expectedBranch };
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const reused = Boolean(worktree);
|
|
1118
|
+
if (!worktree) {
|
|
1119
|
+
worktree = createWorktreeFn({ cwd, slug: parentSlug, base: fanout.base });
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
logIntegration(
|
|
1123
|
+
reused
|
|
1124
|
+
? `- Reused existing worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`
|
|
1125
|
+
: `- Created worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`,
|
|
1126
|
+
);
|
|
1127
|
+
|
|
1128
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
1129
|
+
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
1130
|
+
worktree: current.worktree ?? worktree.worktreePath,
|
|
1131
|
+
branch: current.branch ?? worktree.branch,
|
|
1132
|
+
}));
|
|
1133
|
+
|
|
1134
|
+
let remaining = fanout.integration.candidates.filter(
|
|
1135
|
+
(branch) => !merged.includes(branch) && !skipped.includes(branch),
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
while (remaining.length > 0) {
|
|
1139
|
+
const results = mergeBranchesFn({
|
|
1140
|
+
cwd: worktree.worktreePath,
|
|
1141
|
+
candidates: remaining,
|
|
1142
|
+
merged,
|
|
1143
|
+
overlappingFiles: fanout.integration.overlappingFiles,
|
|
1144
|
+
execFile: execFileFn,
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
for (const result of results) {
|
|
1148
|
+
if (result.status === 'skipped') continue;
|
|
1149
|
+
|
|
1150
|
+
if (result.status === 'merged') {
|
|
1151
|
+
merged.push(result.branch);
|
|
1152
|
+
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
1153
|
+
merged: [...current.merged, result.branch],
|
|
1154
|
+
}));
|
|
1155
|
+
logIntegration(`- Merged \`${result.branch}\` cleanly.`);
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// status === 'conflict'
|
|
1160
|
+
logIntegration(`- Conflict merging \`${result.branch}\`; entering repair.`);
|
|
1161
|
+
patchIntegrationFn(cwd, parentSlug, { state: 'repairing' });
|
|
1162
|
+
|
|
1163
|
+
const conflicted = conflictedFilesFn({ cwd: worktree.worktreePath, execFile: execFileFn });
|
|
1164
|
+
const involvedWorkers = fanout.workers
|
|
1165
|
+
.filter((worker) => worker.branch === result.branch)
|
|
1166
|
+
.map(({ id, title, subtask, area }) => ({ id, title, subtask, area }));
|
|
1167
|
+
|
|
1168
|
+
jobPatch({ phase: 'integrate', stage: 'integrator', round: null });
|
|
1169
|
+
const integratorArgs = integratorAgentArgs({
|
|
1170
|
+
prompt: `Resolve the merge conflict from combining \`${result.branch}\` into the integration branch for "${fanout.task}".`,
|
|
1171
|
+
cwd: worktree.worktreePath,
|
|
1172
|
+
conflictedFiles: conflicted,
|
|
1173
|
+
mergeOutput: result.output,
|
|
1174
|
+
involvedWorkers,
|
|
1175
|
+
});
|
|
1176
|
+
const integratorAgent = new AgentClass(
|
|
1177
|
+
'integrator',
|
|
1178
|
+
integratorArgs.instructions,
|
|
1179
|
+
integratorArgs.prompt,
|
|
1180
|
+
integratorArgs.options,
|
|
1181
|
+
);
|
|
1182
|
+
|
|
1183
|
+
let integratorOk = false;
|
|
1184
|
+
try {
|
|
1185
|
+
const integratorOut = await integratorAgent.run({ verbose });
|
|
1186
|
+
await jobCheckpoint();
|
|
1187
|
+
const { summary: integratorSummary } = splitStageSummary(integratorOut.result);
|
|
1188
|
+
printStageSummary('integrator', integratorSummary);
|
|
1189
|
+
integratorOk = Boolean(integratorOut.ok);
|
|
1190
|
+
} catch (err) {
|
|
1191
|
+
logIntegration(`- Integrator agent errored: ${err.message}`);
|
|
1192
|
+
integratorOk = false;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const stillConflicted = integratorOk
|
|
1196
|
+
? hasConflictMarkersFn({ cwd: worktree.worktreePath, execFile: execFileFn })
|
|
1197
|
+
: true;
|
|
1198
|
+
|
|
1199
|
+
if (!stillConflicted) {
|
|
1200
|
+
execFileFn('git', ['-C', worktree.worktreePath, 'commit']);
|
|
1201
|
+
merged.push(result.branch);
|
|
1202
|
+
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
1203
|
+
merged: [...current.merged, result.branch],
|
|
1204
|
+
}));
|
|
1205
|
+
logIntegration(`- Integrator resolved conflicts in \`${result.branch}\`; merge completed.`);
|
|
1206
|
+
} else {
|
|
1207
|
+
abortMergeFn({ cwd: worktree.worktreePath, execFile: execFileFn });
|
|
1208
|
+
skipped.push(result.branch);
|
|
1209
|
+
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
1210
|
+
skipped: [...(current.skipped ?? []), result.branch],
|
|
1211
|
+
}));
|
|
1212
|
+
logIntegration(`- Conflicts in \`${result.branch}\` remained unresolved; aborted merge and skipped.`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
remaining = remaining.slice(results.length);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// --- runner-first verify loop: test-runner first, code-writer only on failure ---
|
|
1220
|
+
await runCodeLoop({
|
|
1221
|
+
prompt: fanout.task,
|
|
1222
|
+
worktreePath: worktree.worktreePath,
|
|
1223
|
+
branch: worktree.branch,
|
|
1224
|
+
taskPath: path.join(integrationDir, 'task.md'),
|
|
1225
|
+
statusPath: integrationMdPath,
|
|
1226
|
+
maxRounds,
|
|
1227
|
+
AgentClass,
|
|
1228
|
+
verbose,
|
|
1229
|
+
jobPatch,
|
|
1230
|
+
jobCheckpoint,
|
|
1231
|
+
acceptedVerification: '',
|
|
1232
|
+
runnerFirst: true,
|
|
1233
|
+
loopTitle: 'Verify loop',
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
1237
|
+
const message = `orch: ${parentSlug} ${fanout.task.split('\n')[0]}`;
|
|
1238
|
+
const commitResult = commitWorktreeFn({
|
|
1239
|
+
worktreePath: worktree.worktreePath,
|
|
1240
|
+
branch: `orch/${parentSlug}`,
|
|
1241
|
+
message,
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
logIntegration(
|
|
1245
|
+
commitResult.committed
|
|
1246
|
+
? `- Committed \`${commitResult.sha}\` on \`${commitResult.branch}\`.`
|
|
1247
|
+
: `- No changes to commit on \`${commitResult.branch}\`.`,
|
|
1248
|
+
);
|
|
1249
|
+
|
|
1250
|
+
patchIntegrationFn(cwd, parentSlug, {
|
|
1251
|
+
state: 'done',
|
|
1252
|
+
sha: commitResult.sha,
|
|
1253
|
+
merged,
|
|
1254
|
+
skipped,
|
|
1255
|
+
});
|
|
1256
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
console.error(`Error: ${err.message}`);
|
|
1259
|
+
try {
|
|
1260
|
+
logIntegration(`- Error: ${err.message}`);
|
|
1261
|
+
} catch {
|
|
1262
|
+
// Best-effort: don't let a log write failure mask the real error.
|
|
1263
|
+
}
|
|
1264
|
+
try {
|
|
1265
|
+
patchIntegrationFn(cwd, parentSlug, { state: 'failed', merged, skipped });
|
|
1266
|
+
} catch {
|
|
1267
|
+
// Best-effort: don't let a fanout-state write failure mask the real error.
|
|
1268
|
+
}
|
|
1269
|
+
if (jobSlug) {
|
|
1270
|
+
try {
|
|
1271
|
+
patchJobFn(jobCwd, jobSlug, {
|
|
1272
|
+
state: 'failed',
|
|
1273
|
+
exitCode: 1,
|
|
1274
|
+
finishedAt: new Date().toISOString(),
|
|
1275
|
+
});
|
|
1276
|
+
} catch {
|
|
1277
|
+
// Best-effort: don't let a job-state write failure mask the real error.
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
process.exit(1);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function sleep(ms) {
|
|
1285
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** Sentinel thrown by the coordinator's scheduling loop when a SIGINT/SIGTERM/SIGHUP
|
|
1289
|
+
* cascade fires mid-flight, so the pending awaits unwind without a second exit call. */
|
|
1290
|
+
class FanoutInterrupted extends Error {}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* SIGINT/SIGHUP/SIGTERM cascade: reads `fanout.json`, resolves every worker's and the
|
|
1294
|
+
* integration session's own pid via its `run.json`, filters through `isPidAlive`, and
|
|
1295
|
+
* SIGTERMs each live one. Never touches worktrees. Composes with (does not replace)
|
|
1296
|
+
* `lib/agent.js`'s existing `shutdown()` — the coordinator's own signal handling calls
|
|
1297
|
+
* both this and the normal in-process agent-CLI reaping.
|
|
1298
|
+
*/
|
|
1299
|
+
export function cascadeStopFanoutChildren(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
|
|
1300
|
+
const fanout = readFanout(cwd, parentSlug);
|
|
1301
|
+
if (!fanout) return;
|
|
1302
|
+
|
|
1303
|
+
const slugs = fanout.workers.filter((worker) => worker.slug).map((worker) => worker.slug);
|
|
1304
|
+
if (fanout.integration?.slug) slugs.push(fanout.integration.slug);
|
|
1305
|
+
|
|
1306
|
+
for (const slug of slugs) {
|
|
1307
|
+
const record = readJob(cwd, slug);
|
|
1308
|
+
if (!record) continue;
|
|
1309
|
+
if (isPidAliveFn(record.pid)) {
|
|
1310
|
+
kill(record.pid, 'SIGTERM');
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* CLI / management cascade stop: SIGTERM the parent pid if alive, then every
|
|
1317
|
+
* live child pid (via `cascadeStopFanoutChildren`). The coordinator signal
|
|
1318
|
+
* handler keeps calling the child-only helper so it does not re-signal itself.
|
|
1319
|
+
*/
|
|
1320
|
+
export function cascadeStop(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
|
|
1321
|
+
const parent = readJob(cwd, parentSlug);
|
|
1322
|
+
if (parent && isPidAliveFn(parent.pid)) {
|
|
1323
|
+
kill(parent.pid, 'SIGTERM');
|
|
1324
|
+
}
|
|
1325
|
+
cascadeStopFanoutChildren(cwd, parentSlug, { kill, isPidAlive: isPidAliveFn });
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* The `--fan-out` coordinator: triage → boundaries → decompose → schedule workers →
|
|
1330
|
+
* overlap detection → spawn integrate → report. Never creates its own worktree or runs
|
|
1331
|
+
* implementer stages itself — those only happen on the decline path (today's
|
|
1332
|
+
* single-worktree pipeline, reusing `runTestLoop`/`runCodeLoop`) or inside spawned
|
|
1333
|
+
* children. See `.spec/fanout-3-coordinator.md` and `.spec/fanout.md`.
|
|
1334
|
+
*/
|
|
1335
|
+
export async function runFanoutPipeline(prompt, options = {}) {
|
|
1336
|
+
const verbose = Boolean(options.verbose);
|
|
1337
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
1338
|
+
const maxWorkers = options.maxWorkers ?? 4;
|
|
1339
|
+
const maxConcurrency = options.maxConcurrency ?? null;
|
|
1340
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
1341
|
+
if (!backend) {
|
|
1342
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
1343
|
+
}
|
|
1344
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
1345
|
+
const invocationCwd = options.cwd ?? process.cwd();
|
|
1346
|
+
|
|
1347
|
+
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
1348
|
+
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
1349
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
1350
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
1351
|
+
const spawnFn = options.spawn ?? spawn;
|
|
1352
|
+
const execFileFn = options.execFile ?? defaultExecFile;
|
|
1353
|
+
const allocateJobFn = options.allocateJob ?? allocateJob;
|
|
1354
|
+
const reconcileJobFn = options.reconcileJob ?? reconcileJob;
|
|
1355
|
+
const readFanoutFn = options.readFanout ?? readFanout;
|
|
1356
|
+
const writeFanoutFn = options.writeFanout ?? writeFanout;
|
|
1357
|
+
const patchWorkerFn = options.patchWorker ?? patchWorker;
|
|
1358
|
+
const patchIntegrationFn = options.patchIntegration ?? patchIntegration;
|
|
1359
|
+
const exitFn = options.exit ?? ((code) => process.exit(code));
|
|
1360
|
+
const pollIntervalMs = options.pollIntervalMs ?? 500;
|
|
1361
|
+
|
|
1362
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
1363
|
+
const jobCwd = options.jobCwd ?? invocationCwd;
|
|
1364
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
1365
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
1366
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
1367
|
+
|
|
1368
|
+
const jobPatch = (fields) => {
|
|
1369
|
+
if (!jobSlug) return;
|
|
1370
|
+
patchJobFn(jobCwd, jobSlug, fields);
|
|
1371
|
+
};
|
|
1372
|
+
const jobCheckpoint = async () => {
|
|
1373
|
+
if (!jobSlug) return;
|
|
1374
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
if (!options.AgentClass) {
|
|
1378
|
+
ensureBinaryOnPath(backend.binary, options.agent);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
console.log(`cwd: ${invocationCwd}`);
|
|
1382
|
+
console.log(`agent: ${options.agent}`);
|
|
1383
|
+
console.log();
|
|
1384
|
+
|
|
1385
|
+
let interrupted = false;
|
|
1386
|
+
const onSignal = (signal) => {
|
|
1387
|
+
interrupted = true;
|
|
1388
|
+
try {
|
|
1389
|
+
cascadeStopFanoutChildren(invocationCwd, jobSlug);
|
|
1390
|
+
} catch {
|
|
1391
|
+
// Best-effort: never let the cascade itself block shutdown.
|
|
1392
|
+
}
|
|
1393
|
+
if (jobSlug) {
|
|
1394
|
+
try {
|
|
1395
|
+
patchJobFn(jobCwd, jobSlug, {
|
|
1396
|
+
state: 'stopped',
|
|
1397
|
+
exitCode: exitCodeForSignal(signal),
|
|
1398
|
+
finishedAt: new Date().toISOString(),
|
|
1399
|
+
});
|
|
1400
|
+
} catch {
|
|
1401
|
+
// Best-effort: don't let a job-state write failure block shutdown.
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
exitFn(exitCodeForSignal(signal));
|
|
1405
|
+
};
|
|
1406
|
+
process.on('SIGINT', () => onSignal('SIGINT'));
|
|
1407
|
+
process.on('SIGTERM', () => onSignal('SIGTERM'));
|
|
1408
|
+
process.on('SIGHUP', () => onSignal('SIGHUP'));
|
|
1409
|
+
|
|
1410
|
+
try {
|
|
1411
|
+
jobPatch({ phase: 'triage', stage: 'triage', round: null });
|
|
1412
|
+
const triage = triageAgentArgs({ prompt, cwd: invocationCwd });
|
|
1413
|
+
const triageAgent = new AgentClass(triage.name, triage.instructions, triage.prompt, triage.options);
|
|
1414
|
+
const triageResult = await triageAgent.run({ verbose });
|
|
1415
|
+
await jobCheckpoint();
|
|
1416
|
+
const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
|
|
1417
|
+
printStageSummary('triage', triageSummary);
|
|
1418
|
+
const parsed = parseTriageJson(triageContent);
|
|
1419
|
+
|
|
1420
|
+
if (parsed?.simple === true) {
|
|
1421
|
+
console.log('triage: simple — fan-out skipped (quick-fix)');
|
|
1422
|
+
jobPatch({ phase: 'quick-fix', stage: 'quick-fix', round: null });
|
|
1423
|
+
const quickFix = quickFixAgentArgs({ prompt, cwd: invocationCwd, fix_plan: parsed.fix_plan });
|
|
1424
|
+
const quickFixTracker = new FileTracker({ cwd: invocationCwd });
|
|
1425
|
+
const quickFixAgent = new AgentClass(
|
|
1426
|
+
quickFix.name,
|
|
1427
|
+
quickFix.instructions,
|
|
1428
|
+
quickFix.prompt,
|
|
1429
|
+
{ ...quickFix.options, fileTracker: quickFixTracker },
|
|
1430
|
+
);
|
|
1431
|
+
const quickFixResult = await quickFixAgent.run({ verbose });
|
|
1432
|
+
await jobCheckpoint();
|
|
1433
|
+
const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
1434
|
+
printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
|
|
1435
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
1436
|
+
exitFn(0);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
console.log('triage: complex — fan-out requested');
|
|
1441
|
+
|
|
1442
|
+
// --- boundaries: partitionability research only, runs exactly once ---
|
|
1443
|
+
const boundariesPath = path.join(jobCwd, '.orch', jobSlug, 'boundaries.md');
|
|
1444
|
+
jobPatch({ phase: 'boundaries', stage: 'boundaries', round: null });
|
|
1445
|
+
const boundaries = boundariesAgentArgs({ prompt, cwd: invocationCwd, boundariesPath });
|
|
1446
|
+
const boundariesAgent = new AgentClass(boundaries.name, boundaries.instructions, boundaries.prompt, boundaries.options);
|
|
1447
|
+
const boundariesResult = await boundariesAgent.run({ verbose });
|
|
1448
|
+
await jobCheckpoint();
|
|
1449
|
+
const { content: boundariesOutput, summary: boundariesSummary } = splitStageSummary(boundariesResult.result);
|
|
1450
|
+
printStageSummary('boundaries', boundariesSummary);
|
|
1451
|
+
console.log(`boundaries: ${boundariesSummary || 'done'}`);
|
|
1452
|
+
|
|
1453
|
+
// --- decomposer: up to two repair round-trips on validation failure ---
|
|
1454
|
+
let feedback;
|
|
1455
|
+
let decision = null;
|
|
1456
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
1457
|
+
jobPatch({ phase: 'decompose', stage: 'decomposer', round: attempt });
|
|
1458
|
+
const decomposer = decomposerAgentArgs({
|
|
1459
|
+
prompt, cwd: invocationCwd, boundariesOutput, maxWorkers, feedback,
|
|
1460
|
+
});
|
|
1461
|
+
const decomposerAgent = new AgentClass(decomposer.name, decomposer.instructions, decomposer.prompt, decomposer.options);
|
|
1462
|
+
const decomposerResult = await decomposerAgent.run({ verbose });
|
|
1463
|
+
await jobCheckpoint();
|
|
1464
|
+
const { content: decomposerContent, summary: decomposerSummary } = splitStageSummary(decomposerResult.result);
|
|
1465
|
+
printStageSummary('decomposer', decomposerSummary);
|
|
1466
|
+
|
|
1467
|
+
const parsedDecomposition = parseDecomposition(decomposerContent);
|
|
1468
|
+
if (!parsedDecomposition) {
|
|
1469
|
+
feedback = ['decomposer output was not valid JSON'];
|
|
1470
|
+
if (attempt === 3) decision = { decline: true, why: 'decomposer did not return valid JSON after repair attempts' };
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
if (parsedDecomposition.decomposable === false) {
|
|
1474
|
+
decision = { decline: true, why: parsedDecomposition.why };
|
|
1475
|
+
break;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const violations = validateDecomposition(parsedDecomposition, { maxWorkers });
|
|
1479
|
+
if (violations.length === 0) {
|
|
1480
|
+
decision = { decline: false, decomposition: parsedDecomposition };
|
|
1481
|
+
break;
|
|
1482
|
+
}
|
|
1483
|
+
feedback = violations;
|
|
1484
|
+
if (attempt === 3) {
|
|
1485
|
+
decision = { decline: true, why: `decomposition still invalid after repairs: ${violations.join('; ')}` };
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
if (decision.decline) {
|
|
1490
|
+
console.log(`decomposer: declined — ${decision.why}`);
|
|
1491
|
+
console.log('falling through to the single-worktree pipeline');
|
|
1492
|
+
|
|
1493
|
+
const runContext = createRunContextFn(jobSlug ? { cwd: invocationCwd, slug: jobSlug } : { cwd: invocationCwd });
|
|
1494
|
+
|
|
1495
|
+
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
1496
|
+
const research = researchAgentArgs({ prompt, cwd: invocationCwd, researchPath: runContext.researchPath });
|
|
1497
|
+
const researchAgent = new AgentClass(research.name, research.instructions, research.prompt, research.options);
|
|
1498
|
+
const researchResult = await researchAgent.run({ verbose });
|
|
1499
|
+
await jobCheckpoint();
|
|
1500
|
+
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
1501
|
+
printStageSummary('research', researchSummary);
|
|
1502
|
+
|
|
1503
|
+
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
1504
|
+
const planner = plannerAgentArgs({
|
|
1505
|
+
prompt, cwd: invocationCwd, researchPath: runContext.researchPath, taskPath: runContext.taskPath, researchOutput: researchContent,
|
|
1506
|
+
});
|
|
1507
|
+
const plannerAgent = new AgentClass(planner.name, planner.instructions, planner.prompt, planner.options);
|
|
1508
|
+
const plannerResult = await plannerAgent.run({ verbose });
|
|
1509
|
+
await jobCheckpoint();
|
|
1510
|
+
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
1511
|
+
printStageSummary('planner', plannerSummary);
|
|
1512
|
+
|
|
1513
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
1514
|
+
const worktree = createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
|
|
1515
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
1516
|
+
|
|
1517
|
+
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
1518
|
+
fs.writeFileSync(
|
|
1519
|
+
runContext.statusPath,
|
|
1520
|
+
`# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n`,
|
|
1521
|
+
);
|
|
1522
|
+
|
|
1523
|
+
const testAccepted = await runTestLoop({
|
|
1524
|
+
prompt,
|
|
1525
|
+
worktreePath: worktree.worktreePath,
|
|
1526
|
+
branch: worktree.branch,
|
|
1527
|
+
taskPath: runContext.taskPath,
|
|
1528
|
+
statusPath: runContext.statusPath,
|
|
1529
|
+
maxRounds,
|
|
1530
|
+
AgentClass,
|
|
1531
|
+
verbose,
|
|
1532
|
+
jobPatch,
|
|
1533
|
+
jobCheckpoint,
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent].filter(Boolean).join('\n');
|
|
1537
|
+
|
|
1538
|
+
await runCodeLoop({
|
|
1539
|
+
prompt,
|
|
1540
|
+
worktreePath: worktree.worktreePath,
|
|
1541
|
+
branch: worktree.branch,
|
|
1542
|
+
taskPath: runContext.taskPath,
|
|
1543
|
+
statusPath: runContext.statusPath,
|
|
1544
|
+
maxRounds,
|
|
1545
|
+
AgentClass,
|
|
1546
|
+
verbose,
|
|
1547
|
+
jobPatch,
|
|
1548
|
+
jobCheckpoint,
|
|
1549
|
+
acceptedVerification,
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
1553
|
+
const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
|
|
1554
|
+
const worktreeChanges = collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
|
|
1555
|
+
printFilesChanged(worktreeChanges);
|
|
1556
|
+
const commitResult = commitWorktreeFn({ worktreePath: worktree.worktreePath, branch: worktree.branch, message });
|
|
1557
|
+
|
|
1558
|
+
if (commitResult.committed) {
|
|
1559
|
+
fs.appendFileSync(
|
|
1560
|
+
runContext.statusPath,
|
|
1561
|
+
`\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
|
|
1562
|
+
);
|
|
1563
|
+
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
1564
|
+
console.log(`merge: git merge ${commitResult.branch}`);
|
|
1565
|
+
} else {
|
|
1566
|
+
fs.appendFileSync(
|
|
1567
|
+
runContext.statusPath,
|
|
1568
|
+
`\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
|
|
1569
|
+
);
|
|
1570
|
+
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
1574
|
+
exitFn(0);
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// --- validated: bootstrap fanout.json, never give the coordinator a worktree ---
|
|
1579
|
+
const { decomposition } = decision;
|
|
1580
|
+
const base = execFileFn('git', ['-C', invocationCwd, 'rev-parse', 'HEAD']).trim();
|
|
1581
|
+
|
|
1582
|
+
const workers = decomposition.workers.map((worker) => ({
|
|
1583
|
+
id: worker.id,
|
|
1584
|
+
title: worker.title,
|
|
1585
|
+
subtask: worker.scaffold ? ensureScaffoldSubtask(worker.subtask) : worker.subtask,
|
|
1586
|
+
area: worker.area,
|
|
1587
|
+
owns: worker.owns ?? [],
|
|
1588
|
+
dependsOn: worker.dependsOn ?? [],
|
|
1589
|
+
scaffold: Boolean(worker.scaffold),
|
|
1590
|
+
slug: null,
|
|
1591
|
+
branch: null,
|
|
1592
|
+
state: 'pending',
|
|
1593
|
+
sha: null,
|
|
1594
|
+
changedFiles: [],
|
|
1595
|
+
overlaps: [],
|
|
1596
|
+
}));
|
|
1597
|
+
|
|
1598
|
+
writeFanoutFn(invocationCwd, jobSlug, {
|
|
1599
|
+
parentSlug: jobSlug,
|
|
1600
|
+
task: prompt,
|
|
1601
|
+
base,
|
|
1602
|
+
maxWorkers,
|
|
1603
|
+
maxConcurrency,
|
|
1604
|
+
concurrency: workers.length,
|
|
1605
|
+
state: 'running',
|
|
1606
|
+
workers,
|
|
1607
|
+
integration: {
|
|
1608
|
+
slug: null,
|
|
1609
|
+
pid: null,
|
|
1610
|
+
branch: null,
|
|
1611
|
+
worktree: null,
|
|
1612
|
+
candidates: [],
|
|
1613
|
+
merged: [],
|
|
1614
|
+
skipped: [],
|
|
1615
|
+
overlappingFiles: [],
|
|
1616
|
+
state: 'pending',
|
|
1617
|
+
sha: null,
|
|
1618
|
+
},
|
|
1619
|
+
startedAt: new Date().toISOString(),
|
|
1620
|
+
finishedAt: null,
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
console.log(`decomposer: split into ${workers.length} worker${workers.length === 1 ? '' : 's'}`);
|
|
1624
|
+
|
|
1625
|
+
const spawnWorkerChild = (worker) => {
|
|
1626
|
+
const siblingTitles = workers.filter((w) => w.id !== worker.id).map((w) => w.title).filter(Boolean);
|
|
1627
|
+
const envelope = buildWorkerEnvelope({
|
|
1628
|
+
subtask: worker.subtask, area: worker.area, scaffold: worker.scaffold, siblingTitles,
|
|
1629
|
+
});
|
|
1630
|
+
const workerPrompt = `${worker.subtask}\n\n${envelope}`;
|
|
1631
|
+
|
|
1632
|
+
const { slug: workerSlug } = allocateJobFn({
|
|
1633
|
+
cwd: invocationCwd,
|
|
1634
|
+
prompt: workerPrompt,
|
|
1635
|
+
agent: options.agent,
|
|
1636
|
+
maxRounds,
|
|
1637
|
+
state: 'starting',
|
|
1638
|
+
parent: jobSlug,
|
|
1639
|
+
role: 'worker',
|
|
1640
|
+
workerId: worker.id,
|
|
1641
|
+
});
|
|
1642
|
+
patchWorkerFn(invocationCwd, jobSlug, worker.id, {
|
|
1643
|
+
slug: workerSlug,
|
|
1644
|
+
branch: `orch/${workerSlug}`,
|
|
1645
|
+
state: 'running',
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
const { logPath } = jobPaths(invocationCwd, workerSlug);
|
|
1649
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
1650
|
+
const childArgs = [
|
|
1651
|
+
__filename, workerPrompt,
|
|
1652
|
+
'--agent', options.agent,
|
|
1653
|
+
'--max-rounds', String(maxRounds),
|
|
1654
|
+
'--worker', `${jobSlug}:${worker.id}`,
|
|
1655
|
+
];
|
|
1656
|
+
const child = spawnFn(process.execPath, childArgs, {
|
|
1657
|
+
cwd: invocationCwd,
|
|
1658
|
+
env: { ...process.env, ORCH_JOB_SLUG: workerSlug, ORCH_DETACHED: '1', ORCH_FANOUT_DEPTH: '1' },
|
|
1659
|
+
detached: true,
|
|
1660
|
+
stdio: ['ignore', logFd, logFd],
|
|
1661
|
+
});
|
|
1662
|
+
child.unref();
|
|
1663
|
+
patchJobFn(invocationCwd, workerSlug, { pid: child.pid, state: 'running' });
|
|
1664
|
+
console.log(`[${worker.id} ${workerSlug}] running`);
|
|
1665
|
+
return workerSlug;
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
// A worker child self-patches its fanout.json entry to 'done'/'failed' the moment it
|
|
1669
|
+
// finishes (see runWorkerPipeline) — that is the primary settlement signal. reconcileJob
|
|
1670
|
+
// (dead-pid detection) is only consulted once a worker has been in-flight past a grace
|
|
1671
|
+
// period, so a worker that simply hasn't self-reported yet is never mistaken for a crash.
|
|
1672
|
+
const CRASH_CHECK_GRACE_MS = Math.max(pollIntervalMs * 10, 200);
|
|
1673
|
+
|
|
1674
|
+
const settleWorker = (workerId, workerSlug, spawnedAt) => {
|
|
1675
|
+
const fanoutWorker = readFanoutFn(invocationCwd, jobSlug).workers.find((w) => w.id === workerId);
|
|
1676
|
+
if (fanoutWorker.state === 'done' || fanoutWorker.state === 'failed') {
|
|
1677
|
+
console.log(`[${workerId} ${workerSlug}] ${fanoutWorker.state}`);
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
if (Date.now() - spawnedAt < CRASH_CHECK_GRACE_MS) return false;
|
|
1682
|
+
|
|
1683
|
+
const record = reconcileJobFn(invocationCwd, workerSlug, readJob(invocationCwd, workerSlug));
|
|
1684
|
+
if (!TERMINAL_JOB_STATES.includes(record.state)) return false;
|
|
1685
|
+
|
|
1686
|
+
if (fanoutWorker.state !== 'done' && fanoutWorker.state !== 'failed') {
|
|
1687
|
+
patchWorkerFn(invocationCwd, jobSlug, workerId, { state: 'failed' });
|
|
1688
|
+
}
|
|
1689
|
+
console.log(`[${workerId} ${workerSlug}] failed`);
|
|
1690
|
+
return true;
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
/** Spawns up to `concurrency` of `workerIds` at a time, polling to terminal state.
|
|
1694
|
+
* Honors `jobCheckpoint` before each spawn and on each poll tick; while paused does
|
|
1695
|
+
* not spawn or advance. Re-attaches to still-live children instead of re-spawning;
|
|
1696
|
+
* skips workers already `done`/`failed`/`skipped`. */
|
|
1697
|
+
const runWorkerGroup = async (workerIds, concurrency) => {
|
|
1698
|
+
const byId = new Map(workers.map((w) => [w.id, w]));
|
|
1699
|
+
const pending = [...workerIds];
|
|
1700
|
+
const active = new Map();
|
|
1701
|
+
|
|
1702
|
+
while (pending.length > 0 || active.size > 0) {
|
|
1703
|
+
while (active.size < concurrency && pending.length > 0) {
|
|
1704
|
+
await jobCheckpoint();
|
|
1705
|
+
if (interrupted) throw new FanoutInterrupted();
|
|
1706
|
+
|
|
1707
|
+
const id = pending.shift();
|
|
1708
|
+
const fanoutWorker = readFanoutFn(invocationCwd, jobSlug).workers.find((w) => w.id === id);
|
|
1709
|
+
|
|
1710
|
+
if (fanoutWorker && ['done', 'failed', 'skipped'].includes(fanoutWorker.state)) {
|
|
1711
|
+
continue;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
if (fanoutWorker?.slug) {
|
|
1715
|
+
const existing = readJob(invocationCwd, fanoutWorker.slug);
|
|
1716
|
+
if (existing && !TERMINAL_JOB_STATES.includes(existing.state)) {
|
|
1717
|
+
// Re-attach to a still-live child — do not spawn a duplicate.
|
|
1718
|
+
active.set(id, { workerSlug: fanoutWorker.slug, spawnedAt: Date.now() });
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1721
|
+
if (existing && TERMINAL_JOB_STATES.includes(existing.state)) {
|
|
1722
|
+
if (fanoutWorker.state !== 'done' && fanoutWorker.state !== 'failed') {
|
|
1723
|
+
patchWorkerFn(invocationCwd, jobSlug, id, { state: 'failed' });
|
|
1724
|
+
}
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// Spawn only still-pending workers (no slug / not live or terminal).
|
|
1730
|
+
const workerSlug = spawnWorkerChild(byId.get(id));
|
|
1731
|
+
active.set(id, { workerSlug, spawnedAt: Date.now() });
|
|
1732
|
+
}
|
|
1733
|
+
if (active.size === 0) break;
|
|
1734
|
+
|
|
1735
|
+
await sleep(pollIntervalMs);
|
|
1736
|
+
if (interrupted) throw new FanoutInterrupted();
|
|
1737
|
+
await jobCheckpoint();
|
|
1738
|
+
|
|
1739
|
+
for (const [id, { workerSlug, spawnedAt }] of [...active]) {
|
|
1740
|
+
if (settleWorker(id, workerSlug, spawnedAt)) active.delete(id);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
|
|
1745
|
+
const scaffoldWorker = workers.find((w) => w.scaffold);
|
|
1746
|
+
let aborted = false;
|
|
1747
|
+
|
|
1748
|
+
if (scaffoldWorker) {
|
|
1749
|
+
await runWorkerGroup([scaffoldWorker.id], 1);
|
|
1750
|
+
const scaffoldEntry = readFanoutFn(invocationCwd, jobSlug).workers.find((w) => w.id === scaffoldWorker.id);
|
|
1751
|
+
if (scaffoldEntry.state === 'done') {
|
|
1752
|
+
const current = readFanoutFn(invocationCwd, jobSlug);
|
|
1753
|
+
writeFanoutFn(invocationCwd, jobSlug, { ...current, base: scaffoldEntry.sha });
|
|
1754
|
+
console.log(`[${scaffoldWorker.id}] done — base updated to ${scaffoldEntry.sha.slice(0, 7)}`);
|
|
1755
|
+
} else {
|
|
1756
|
+
aborted = true;
|
|
1757
|
+
for (const worker of workers) {
|
|
1758
|
+
if (worker.id === scaffoldWorker.id) continue;
|
|
1759
|
+
patchWorkerFn(invocationCwd, jobSlug, worker.id, { state: 'skipped' });
|
|
1760
|
+
}
|
|
1761
|
+
console.log('scaffold worker failed; aborting fan-out before any parallel worker spawns');
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
if (!aborted) {
|
|
1766
|
+
const remaining = workers.filter((w) => !scaffoldWorker || w.id !== scaffoldWorker.id);
|
|
1767
|
+
const forLayering = remaining.map((w) => ({
|
|
1768
|
+
...w,
|
|
1769
|
+
dependsOn: (w.dependsOn || []).filter((dep) => !scaffoldWorker || dep !== scaffoldWorker.id),
|
|
1770
|
+
}));
|
|
1771
|
+
const layers = planLayers(forLayering);
|
|
1772
|
+
|
|
1773
|
+
for (const layerIds of layers) {
|
|
1774
|
+
if (interrupted) throw new FanoutInterrupted();
|
|
1775
|
+
const currentDoc = readFanoutFn(invocationCwd, jobSlug);
|
|
1776
|
+
const byId = new Map(workers.map((w) => [w.id, w]));
|
|
1777
|
+
const toRun = [];
|
|
1778
|
+
for (const id of layerIds) {
|
|
1779
|
+
const worker = byId.get(id);
|
|
1780
|
+
const depFailed = (worker.dependsOn || []).some(
|
|
1781
|
+
(dep) => currentDoc.workers.find((w) => w.id === dep)?.state === 'failed',
|
|
1782
|
+
);
|
|
1783
|
+
if (depFailed) {
|
|
1784
|
+
patchWorkerFn(invocationCwd, jobSlug, id, { state: 'skipped' });
|
|
1785
|
+
console.log(`[${id}] skipped — dependency failed`);
|
|
1786
|
+
} else {
|
|
1787
|
+
toRun.push(id);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
if (toRun.length === 0) continue;
|
|
1791
|
+
|
|
1792
|
+
const concurrency = chooseConcurrency({ layerSize: toRun.length, maxConcurrency });
|
|
1793
|
+
const currentForConcurrency = readFanoutFn(invocationCwd, jobSlug);
|
|
1794
|
+
writeFanoutFn(invocationCwd, jobSlug, { ...currentForConcurrency, concurrency });
|
|
1795
|
+
console.log(`schedule: concurrency ${concurrency}`);
|
|
1796
|
+
|
|
1797
|
+
await runWorkerGroup(toRun, concurrency);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
// --- overlap detection + integration candidates ---
|
|
1802
|
+
await jobCheckpoint();
|
|
1803
|
+
const settledDoc = readFanoutFn(invocationCwd, jobSlug);
|
|
1804
|
+
const overlapUnion = detectOverlaps(settledDoc.workers);
|
|
1805
|
+
for (const worker of settledDoc.workers) {
|
|
1806
|
+
if (worker.overlaps && worker.overlaps.length > 0) {
|
|
1807
|
+
patchWorkerFn(invocationCwd, jobSlug, worker.id, { overlaps: worker.overlaps });
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
console.log(`overlaps: ${overlapUnion.length > 0 ? overlapUnion.join(', ') : 'none'}`);
|
|
1811
|
+
|
|
1812
|
+
const doneWorkers = settledDoc.workers.filter((w) => w.state === 'done');
|
|
1813
|
+
const failedWorkers = settledDoc.workers.filter((w) => w.state === 'failed');
|
|
1814
|
+
patchIntegrationFn(invocationCwd, jobSlug, {
|
|
1815
|
+
candidates: doneWorkers.map((w) => w.branch),
|
|
1816
|
+
overlappingFiles: overlapUnion,
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
let integrationDone = false;
|
|
1820
|
+
await jobCheckpoint();
|
|
1821
|
+
const integrationGate = readFanoutFn(invocationCwd, jobSlug).integration;
|
|
1822
|
+
const integrationAlreadyDone = integrationGate?.state === 'done';
|
|
1823
|
+
let integrationSlug = integrationGate?.slug ?? null;
|
|
1824
|
+
let integrationLive = false;
|
|
1825
|
+
if (integrationSlug && !integrationAlreadyDone) {
|
|
1826
|
+
const existingIntegrate = readJob(invocationCwd, integrationSlug);
|
|
1827
|
+
integrationLive = Boolean(existingIntegrate && !TERMINAL_JOB_STATES.includes(existingIntegrate.state));
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
if (integrationAlreadyDone) {
|
|
1831
|
+
integrationDone = true;
|
|
1832
|
+
console.log(`[integrate ${integrationSlug ?? 'done'}] already done — skipping spawn`);
|
|
1833
|
+
} else if (integrationLive) {
|
|
1834
|
+
// Re-attach to an already-live integrate child; do not spawn a duplicate.
|
|
1835
|
+
console.log(`[integrate ${integrationSlug}] re-attached`);
|
|
1836
|
+
const integrateSpawnedAt = Date.now();
|
|
1837
|
+
for (;;) {
|
|
1838
|
+
await sleep(pollIntervalMs);
|
|
1839
|
+
if (interrupted) throw new FanoutInterrupted();
|
|
1840
|
+
await jobCheckpoint();
|
|
1841
|
+
|
|
1842
|
+
const integrationState = readFanoutFn(invocationCwd, jobSlug).integration.state;
|
|
1843
|
+
if (integrationState === 'done') { integrationDone = true; break; }
|
|
1844
|
+
if (integrationState === 'failed') break;
|
|
1845
|
+
|
|
1846
|
+
if (Date.now() - integrateSpawnedAt < CRASH_CHECK_GRACE_MS) continue;
|
|
1847
|
+
const record = reconcileJobFn(invocationCwd, integrationSlug, readJob(invocationCwd, integrationSlug));
|
|
1848
|
+
if (TERMINAL_JOB_STATES.includes(record.state)) {
|
|
1849
|
+
if (record.state === 'done') integrationDone = true;
|
|
1850
|
+
else patchIntegrationFn(invocationCwd, jobSlug, (current) => (current.state === 'done' ? {} : { state: 'failed' }));
|
|
1851
|
+
break;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
const finalIntegration = readFanoutFn(invocationCwd, jobSlug).integration;
|
|
1856
|
+
if (finalIntegration.state === 'done' && finalIntegration.sha) {
|
|
1857
|
+
console.log(`[integrate ${integrationSlug}] merged ${doneWorkers.length} branch${doneWorkers.length === 1 ? '' : 'es'}`);
|
|
1858
|
+
console.log(`commit: ${finalIntegration.sha.slice(0, 7)} on ${finalIntegration.branch ?? `orch/${jobSlug}`}`);
|
|
1859
|
+
console.log(`merge: git merge ${finalIntegration.branch ?? `orch/${jobSlug}`}`);
|
|
1860
|
+
} else {
|
|
1861
|
+
console.log(`[integrate ${integrationSlug}] failed`);
|
|
1862
|
+
}
|
|
1863
|
+
} else if (doneWorkers.length > 0) {
|
|
1864
|
+
const envelope = buildIntegrationEnvelope({
|
|
1865
|
+
task: prompt,
|
|
1866
|
+
branches: doneWorkers.map((w) => w.branch),
|
|
1867
|
+
overlappingFiles: overlapUnion,
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
const allocated = allocateJobFn({
|
|
1871
|
+
cwd: invocationCwd,
|
|
1872
|
+
prompt: envelope,
|
|
1873
|
+
agent: options.agent,
|
|
1874
|
+
maxRounds,
|
|
1875
|
+
state: 'starting',
|
|
1876
|
+
parent: jobSlug,
|
|
1877
|
+
role: 'integration',
|
|
1878
|
+
});
|
|
1879
|
+
integrationSlug = allocated.slug;
|
|
1880
|
+
patchIntegrationFn(invocationCwd, jobSlug, { slug: integrationSlug });
|
|
1881
|
+
|
|
1882
|
+
const { logPath } = jobPaths(invocationCwd, integrationSlug);
|
|
1883
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
1884
|
+
const childArgs = [
|
|
1885
|
+
__filename, envelope,
|
|
1886
|
+
'--agent', options.agent,
|
|
1887
|
+
'--max-rounds', String(maxRounds),
|
|
1888
|
+
'--integrate', jobSlug,
|
|
1889
|
+
];
|
|
1890
|
+
const child = spawnFn(process.execPath, childArgs, {
|
|
1891
|
+
cwd: invocationCwd,
|
|
1892
|
+
env: { ...process.env, ORCH_JOB_SLUG: integrationSlug, ORCH_DETACHED: '1', ORCH_FANOUT_DEPTH: '1' },
|
|
1893
|
+
detached: true,
|
|
1894
|
+
stdio: ['ignore', logFd, logFd],
|
|
1895
|
+
});
|
|
1896
|
+
child.unref();
|
|
1897
|
+
patchJobFn(invocationCwd, integrationSlug, { pid: child.pid, state: 'running' });
|
|
1898
|
+
patchIntegrationFn(invocationCwd, jobSlug, { pid: child.pid });
|
|
1899
|
+
console.log(`[integrate ${integrationSlug}] running`);
|
|
1900
|
+
|
|
1901
|
+
const integrateSpawnedAt = Date.now();
|
|
1902
|
+
for (;;) {
|
|
1903
|
+
await sleep(pollIntervalMs);
|
|
1904
|
+
if (interrupted) throw new FanoutInterrupted();
|
|
1905
|
+
await jobCheckpoint();
|
|
1906
|
+
|
|
1907
|
+
const integrationState = readFanoutFn(invocationCwd, jobSlug).integration.state;
|
|
1908
|
+
if (integrationState === 'done') { integrationDone = true; break; }
|
|
1909
|
+
if (integrationState === 'failed') break;
|
|
1910
|
+
|
|
1911
|
+
if (Date.now() - integrateSpawnedAt < CRASH_CHECK_GRACE_MS) continue;
|
|
1912
|
+
const record = reconcileJobFn(invocationCwd, integrationSlug, readJob(invocationCwd, integrationSlug));
|
|
1913
|
+
if (TERMINAL_JOB_STATES.includes(record.state)) {
|
|
1914
|
+
if (record.state === 'done') integrationDone = true;
|
|
1915
|
+
else patchIntegrationFn(invocationCwd, jobSlug, (current) => (current.state === 'done' ? {} : { state: 'failed' }));
|
|
1916
|
+
break;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
const finalIntegration = readFanoutFn(invocationCwd, jobSlug).integration;
|
|
1921
|
+
if (finalIntegration.state === 'done' && finalIntegration.sha) {
|
|
1922
|
+
console.log(`[integrate ${integrationSlug}] merged ${doneWorkers.length} branch${doneWorkers.length === 1 ? '' : 'es'}`);
|
|
1923
|
+
console.log(`commit: ${finalIntegration.sha.slice(0, 7)} on ${finalIntegration.branch ?? `orch/${jobSlug}`}`);
|
|
1924
|
+
console.log(`merge: git merge ${finalIntegration.branch ?? `orch/${jobSlug}`}`);
|
|
1925
|
+
} else {
|
|
1926
|
+
console.log(`[integrate ${integrationSlug}] failed`);
|
|
1927
|
+
}
|
|
1928
|
+
} else {
|
|
1929
|
+
console.log('no worker reached done; skipping integration');
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const finalDoc = readFanoutFn(invocationCwd, jobSlug);
|
|
1933
|
+
const success = failedWorkers.length === 0 && integrationDone && Boolean(finalDoc.integration.sha);
|
|
1934
|
+
writeFanoutFn(invocationCwd, jobSlug, {
|
|
1935
|
+
...finalDoc,
|
|
1936
|
+
state: success ? 'done' : 'failed',
|
|
1937
|
+
finishedAt: new Date().toISOString(),
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
if (failedWorkers.length > 0) {
|
|
1941
|
+
console.log(`${failedWorkers.length} worker${failedWorkers.length === 1 ? '' : 's'} failed: ${failedWorkers.map((w) => w.id).join(', ')}`);
|
|
1942
|
+
console.log(`retry integration after fixing it: orch --integrate ${jobSlug}`);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
jobPatch({ state: success ? 'done' : 'failed', exitCode: success ? 0 : 1, finishedAt: new Date().toISOString() });
|
|
1946
|
+
exitFn(success ? 0 : 1);
|
|
1947
|
+
} catch (err) {
|
|
1948
|
+
if (err instanceof FanoutInterrupted) return;
|
|
1949
|
+
console.error(`Error: ${err.message}`);
|
|
1950
|
+
if (jobSlug) {
|
|
1951
|
+
try {
|
|
1952
|
+
patchJobFn(jobCwd, jobSlug, {
|
|
1953
|
+
state: 'failed',
|
|
1954
|
+
exitCode: 1,
|
|
1955
|
+
finishedAt: new Date().toISOString(),
|
|
1956
|
+
});
|
|
1957
|
+
} catch {
|
|
1958
|
+
// Best-effort: don't let a job-state write failure mask the real error.
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
exitFn(1);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/** Splits `--worker`'s `<parent-slug>:<worker-id>` value on the first colon. */
|
|
1966
|
+
function splitParentWorker(value) {
|
|
1967
|
+
const idx = value.indexOf(':');
|
|
1968
|
+
if (idx === -1) return { parentSlug: null, workerId: null };
|
|
1969
|
+
return { parentSlug: value.slice(0, idx), workerId: value.slice(idx + 1) };
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
/** CLI glue for `--worker`: resolves the parent fan-out/worker record, builds the worker
|
|
1973
|
+
* envelope, allocates (or reuses) the job record, then calls `runWorkerPipeline`. */
|
|
1974
|
+
async function runWorkerFromCli(options) {
|
|
1975
|
+
const cwd = process.cwd();
|
|
1976
|
+
const { parentSlug, workerId } = splitParentWorker(options.worker);
|
|
1977
|
+
if (!parentSlug || !workerId) {
|
|
1978
|
+
console.error(`Error: --worker must be in the form <parent-slug>:<worker-id>, got "${options.worker}"`);
|
|
1979
|
+
process.exit(1);
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// ORCH_FANOUT_DEPTH guards against a future --fan-out spawning nested fan-outs.
|
|
1984
|
+
process.env.ORCH_FANOUT_DEPTH = '1';
|
|
1985
|
+
|
|
1986
|
+
const fanout = readFanout(cwd, parentSlug);
|
|
1987
|
+
if (!fanout) {
|
|
1988
|
+
console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
|
|
1989
|
+
process.exit(1);
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
const worker = fanout.workers.find((w) => w.id === workerId);
|
|
1994
|
+
if (!worker) {
|
|
1995
|
+
console.error(`Error: unknown worker ${workerId} in ${parentSlug}`);
|
|
1996
|
+
process.exit(1);
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
const siblingTitles = fanout.workers
|
|
2001
|
+
.filter((w) => w.id !== workerId)
|
|
2002
|
+
.map((w) => w.title)
|
|
2003
|
+
.filter(Boolean);
|
|
2004
|
+
const envelope = buildWorkerEnvelope({
|
|
2005
|
+
subtask: worker.subtask,
|
|
2006
|
+
area: worker.area,
|
|
2007
|
+
scaffold: worker.scaffold,
|
|
2008
|
+
siblingTitles,
|
|
2009
|
+
});
|
|
2010
|
+
const workerPrompt = `${worker.subtask}\n\n${envelope}`;
|
|
2011
|
+
|
|
2012
|
+
let jobSlug = process.env.ORCH_JOB_SLUG;
|
|
2013
|
+
if (!jobSlug) {
|
|
2014
|
+
const alloc = allocateJob({
|
|
2015
|
+
cwd,
|
|
2016
|
+
prompt: workerPrompt,
|
|
2017
|
+
agent: options.agent,
|
|
2018
|
+
maxRounds: options.maxRounds,
|
|
2019
|
+
state: 'running',
|
|
2020
|
+
pid: process.pid,
|
|
2021
|
+
parent: parentSlug,
|
|
2022
|
+
role: 'worker',
|
|
2023
|
+
workerId,
|
|
2024
|
+
});
|
|
2025
|
+
jobSlug = alloc.slug;
|
|
2026
|
+
}
|
|
2027
|
+
setJobSlug(jobSlug);
|
|
2028
|
+
|
|
2029
|
+
await runWorkerPipeline(workerPrompt, {
|
|
2030
|
+
agent: options.agent,
|
|
2031
|
+
maxRounds: options.maxRounds,
|
|
2032
|
+
verbose: options.verbose,
|
|
2033
|
+
cwd,
|
|
2034
|
+
parentSlug,
|
|
2035
|
+
workerId,
|
|
2036
|
+
base: fanout.base,
|
|
2037
|
+
jobSlug,
|
|
2038
|
+
jobCwd: cwd,
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/** CLI glue for `--integrate`: resolves the parent fan-out, allocates (or reuses) the
|
|
2043
|
+
* integration job record, then calls `runIntegratePipeline`. */
|
|
2044
|
+
async function runIntegrateFromCli(options) {
|
|
2045
|
+
const cwd = process.cwd();
|
|
2046
|
+
const parentSlug = options.integrate;
|
|
2047
|
+
|
|
2048
|
+
// ORCH_FANOUT_DEPTH guards against a future --fan-out spawning nested fan-outs.
|
|
2049
|
+
process.env.ORCH_FANOUT_DEPTH = '1';
|
|
2050
|
+
|
|
2051
|
+
const fanout = readFanout(cwd, parentSlug);
|
|
2052
|
+
if (!fanout) {
|
|
2053
|
+
console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
|
|
2054
|
+
process.exit(1);
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
let jobSlug = process.env.ORCH_JOB_SLUG;
|
|
2059
|
+
if (!jobSlug) {
|
|
2060
|
+
const alloc = allocateJob({
|
|
2061
|
+
cwd,
|
|
2062
|
+
prompt: fanout.task,
|
|
2063
|
+
agent: options.agent,
|
|
2064
|
+
maxRounds: options.maxRounds,
|
|
2065
|
+
state: 'running',
|
|
2066
|
+
pid: process.pid,
|
|
2067
|
+
parent: parentSlug,
|
|
2068
|
+
role: 'integration',
|
|
2069
|
+
});
|
|
2070
|
+
jobSlug = alloc.slug;
|
|
2071
|
+
}
|
|
2072
|
+
setJobSlug(jobSlug);
|
|
2073
|
+
|
|
2074
|
+
await runIntegratePipeline({
|
|
2075
|
+
agent: options.agent,
|
|
2076
|
+
maxRounds: options.maxRounds,
|
|
2077
|
+
verbose: options.verbose,
|
|
2078
|
+
cwd,
|
|
2079
|
+
parentSlug,
|
|
2080
|
+
jobSlug,
|
|
2081
|
+
jobCwd: cwd,
|
|
2082
|
+
});
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
/** Commander option parser shared by `--max-rounds`, `--max-workers`, and `--max-concurrency`. */
|
|
2086
|
+
function positiveIntParser(flagName) {
|
|
2087
|
+
return (value) => {
|
|
2088
|
+
const n = Number.parseInt(value, 10);
|
|
2089
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
2090
|
+
throw new Error(`${flagName} must be a positive integer`);
|
|
2091
|
+
}
|
|
2092
|
+
return n;
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
const program = new Command();
|
|
2097
|
+
|
|
2098
|
+
program
|
|
2099
|
+
.name('orch')
|
|
2100
|
+
.version(version)
|
|
2101
|
+
.description('The Orchestrator: triage → research → plan → implement pipeline against a task')
|
|
2102
|
+
.argument('<task...>', 'Task description to use as the prompt (mention a file path and the agent will read it)')
|
|
2103
|
+
.option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
|
|
2104
|
+
.option('--dry-run', 'Check that the selected agent CLI is on PATH and exit; do not run the pipeline')
|
|
2105
|
+
.option('--ask', 'Ask a read-only question about the codebase; print the reply and exit (skips triage and all write pipelines)')
|
|
2106
|
+
.option('--quick', 'Skip triage, run quick-fix directly in the current working tree; create no artifacts, worktrees, or commits')
|
|
2107
|
+
.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')
|
|
2108
|
+
.option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop (ignored with --ask and --quick)', positiveIntParser('--max-rounds'), 5)
|
|
2109
|
+
.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')
|
|
2110
|
+
.option('--max-workers <n>', 'Max number of parallel fan-out workers (only meaningful with --fan-out)', positiveIntParser('--max-workers'), 4)
|
|
2111
|
+
.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'))
|
|
2112
|
+
.addOption(
|
|
2113
|
+
new Option('--agent <agent>', 'Agent backend to run the pipeline with: "cursor" (Cursor Agent CLI), "claude" (Claude Code CLI), or "agn" (agn CLI)')
|
|
2114
|
+
.choices(['cursor', 'claude', 'agn'])
|
|
2115
|
+
.default('cursor'),
|
|
2116
|
+
)
|
|
2117
|
+
.addOption(new Option('--worker <value>', 'internal: run a single fan-out worker "<parent-slug>:<worker-id>"').hideHelp())
|
|
2118
|
+
.addOption(new Option('--integrate <value>', 'internal: (re)run fan-out integration for "<parent-slug>"').hideHelp())
|
|
2119
|
+
.addHelpText(
|
|
2120
|
+
'after',
|
|
2121
|
+
`
|
|
2122
|
+
Examples:
|
|
2123
|
+
$ orch "fix the typo in the README" --agent claude
|
|
2124
|
+
$ orch "fix the bug described in task.md" --agent cursor -v
|
|
2125
|
+
$ orch "implement the local spec" --agent agn -v
|
|
2126
|
+
$ orch --ask "where is the CLI entrypoint?" --agent claude
|
|
2127
|
+
$ orch --quick "fix the typo in the README" --agent claude
|
|
2128
|
+
$ orch "noop" --dry-run --agent cursor
|
|
2129
|
+
|
|
2130
|
+
Headless runs:
|
|
2131
|
+
$ orch "long-running task" --detach --agent claude # start in the background, prints the run slug
|
|
2132
|
+
$ orch list # show all tracked runs
|
|
2133
|
+
$ orch status [slug] # show full status (defaults to most recent)
|
|
2134
|
+
$ orch pause <slug> # request a pause at the next stage boundary
|
|
2135
|
+
$ orch resume <slug> # resume a paused/pausing run
|
|
2136
|
+
$ orch stop <slug> # send SIGTERM to a running job
|
|
2137
|
+
$ orch logs <slug> [-f] # print (or follow) a run's log file
|
|
2138
|
+
|
|
2139
|
+
Fan-out:
|
|
2140
|
+
$ orch "implement the billing module" --fan-out --agent claude # triage, decompose, run parallel workers, integrate
|
|
2141
|
+
$ orch "implement X" --fan-out --max-workers 6 --max-concurrency 3
|
|
2142
|
+
`,
|
|
2143
|
+
)
|
|
2144
|
+
.action(async (task, options) => {
|
|
2145
|
+
const prompt = task.join(' ').trim();
|
|
2146
|
+
if (!prompt) {
|
|
2147
|
+
console.error('Error: task cannot be empty');
|
|
2148
|
+
process.exit(1);
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
if (options.fanOut) {
|
|
2153
|
+
const conflicts = ['ask', 'quick', 'dryRun']
|
|
2154
|
+
.filter((key) => options[key])
|
|
2155
|
+
.map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
|
|
2156
|
+
if (conflicts.length > 0) {
|
|
2157
|
+
console.error(`Error: --fan-out cannot be combined with ${conflicts.join(', ')}`);
|
|
2158
|
+
process.exit(1);
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
if (process.env.ORCH_FANOUT_DEPTH) {
|
|
2162
|
+
console.error('Error: --fan-out cannot be used inside a fan-out child (ORCH_FANOUT_DEPTH is already set)');
|
|
2163
|
+
process.exit(1);
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
const cwd = process.cwd();
|
|
2168
|
+
const { slug } = allocateJob({
|
|
2169
|
+
cwd,
|
|
2170
|
+
prompt,
|
|
2171
|
+
agent: options.agent,
|
|
2172
|
+
maxRounds: options.maxRounds,
|
|
2173
|
+
state: 'running',
|
|
2174
|
+
pid: process.pid,
|
|
2175
|
+
role: 'coordinator',
|
|
2176
|
+
});
|
|
2177
|
+
setJobSlug(slug);
|
|
2178
|
+
|
|
2179
|
+
await runFanoutPipeline(prompt, {
|
|
2180
|
+
...options,
|
|
2181
|
+
cwd,
|
|
2182
|
+
jobSlug: slug,
|
|
2183
|
+
jobCwd: cwd,
|
|
2184
|
+
maxWorkers: options.maxWorkers,
|
|
2185
|
+
maxConcurrency: options.maxConcurrency ?? null,
|
|
2186
|
+
});
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
if (options.worker || options.integrate) {
|
|
2191
|
+
const flagName = options.worker ? '--worker' : '--integrate';
|
|
2192
|
+
const conflicts = ['ask', 'quick', 'detach', 'dryRun']
|
|
2193
|
+
.filter((key) => options[key])
|
|
2194
|
+
.map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
|
|
2195
|
+
if (conflicts.length > 0) {
|
|
2196
|
+
console.error(`Error: ${flagName} cannot be combined with ${conflicts.join(', ')}`);
|
|
2197
|
+
process.exit(1);
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
if (options.worker) {
|
|
2202
|
+
await runWorkerFromCli(options);
|
|
2203
|
+
} else {
|
|
2204
|
+
await runIntegrateFromCli(options);
|
|
2205
|
+
}
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
if (options.detach) {
|
|
2210
|
+
const conflicts = ['ask', 'quick', 'dryRun']
|
|
2211
|
+
.filter((key) => options[key])
|
|
2212
|
+
.map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
|
|
2213
|
+
if (conflicts.length > 0) {
|
|
2214
|
+
console.error(`Error: --detach cannot be combined with ${conflicts.join(', ')}`);
|
|
2215
|
+
process.exit(1);
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
await runDetached(prompt, options);
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
if (!options.dryRun) {
|
|
2223
|
+
const { slug } = allocateJob({
|
|
2224
|
+
cwd: process.cwd(),
|
|
2225
|
+
prompt,
|
|
2226
|
+
agent: options.agent,
|
|
2227
|
+
maxRounds: options.ask || options.quick ? null : options.maxRounds,
|
|
2228
|
+
state: 'running',
|
|
2229
|
+
pid: process.pid,
|
|
2230
|
+
});
|
|
2231
|
+
options.jobSlug = slug;
|
|
2232
|
+
setJobSlug(slug);
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
await runPipeline(prompt, options);
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
program
|
|
2239
|
+
.command('list')
|
|
2240
|
+
.description('List all runs (active and finished) tracked under .orch/ in this directory')
|
|
2241
|
+
.action(() => {
|
|
2242
|
+
const jobs = listJobs(process.cwd());
|
|
2243
|
+
if (jobs.length === 0) {
|
|
2244
|
+
console.log('no runs');
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
console.log(formatJobsTable(jobs));
|
|
2248
|
+
});
|
|
2249
|
+
|
|
2250
|
+
program
|
|
2251
|
+
.command('status')
|
|
2252
|
+
.argument('[slug]', 'Run slug to show; defaults to the most recently started run in this directory')
|
|
2253
|
+
.description('Show full status for a run')
|
|
2254
|
+
.action((slug) => {
|
|
2255
|
+
const cwd = process.cwd();
|
|
2256
|
+
let record;
|
|
2257
|
+
if (slug) {
|
|
2258
|
+
record = readJob(cwd, slug);
|
|
2259
|
+
if (!record) {
|
|
2260
|
+
console.error(`Error: unknown run ${slug}`);
|
|
2261
|
+
process.exit(1);
|
|
2262
|
+
return;
|
|
2263
|
+
}
|
|
2264
|
+
record = reconcileJob(cwd, slug, record);
|
|
2265
|
+
} else {
|
|
2266
|
+
const jobs = listJobs(cwd);
|
|
2267
|
+
if (jobs.length === 0) {
|
|
2268
|
+
console.error('Error: no runs found in this directory');
|
|
2269
|
+
process.exit(1);
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
[record] = jobs;
|
|
2273
|
+
}
|
|
2274
|
+
console.log(formatStatus(cwd, record));
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
program
|
|
2278
|
+
.command('pause')
|
|
2279
|
+
.argument('<slug>', 'Run slug to pause')
|
|
2280
|
+
.description('Request a running job to pause at its next stage-boundary checkpoint')
|
|
2281
|
+
.action((slug) => {
|
|
2282
|
+
const cwd = process.cwd();
|
|
2283
|
+
try {
|
|
2284
|
+
const record = readJob(cwd, slug);
|
|
2285
|
+
if (!record) throw new Error(`requestPause: unknown job ${slug}`);
|
|
2286
|
+
if (isCascadeParent(cwd, record)) {
|
|
2287
|
+
const { childrenSignaled } = cascadePause(cwd, slug);
|
|
2288
|
+
console.log(`pause requested for ${slug} (${childrenSignaled} children signaled)`);
|
|
2289
|
+
} else {
|
|
2290
|
+
requestPause(cwd, slug);
|
|
2291
|
+
console.log(`pause requested for ${slug}`);
|
|
2292
|
+
}
|
|
2293
|
+
} catch (err) {
|
|
2294
|
+
console.error(`Error: ${err.message}`);
|
|
2295
|
+
process.exit(1);
|
|
2296
|
+
}
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
program
|
|
2300
|
+
.command('resume')
|
|
2301
|
+
.argument('<slug>', 'Run slug to resume')
|
|
2302
|
+
.description('Resume a paused (or pausing) job')
|
|
2303
|
+
.action((slug) => {
|
|
2304
|
+
const cwd = process.cwd();
|
|
2305
|
+
try {
|
|
2306
|
+
const record = readJob(cwd, slug);
|
|
2307
|
+
if (!record) throw new Error(`requestResume: unknown job ${slug}`);
|
|
2308
|
+
if (isCascadeParent(cwd, record)) {
|
|
2309
|
+
cascadeResume(cwd, slug);
|
|
2310
|
+
console.log(`resumed ${slug}`);
|
|
2311
|
+
} else {
|
|
2312
|
+
requestResume(cwd, slug);
|
|
2313
|
+
console.log(`resumed ${slug}`);
|
|
2314
|
+
}
|
|
2315
|
+
} catch (err) {
|
|
2316
|
+
console.error(`Error: ${err.message}`);
|
|
2317
|
+
process.exit(1);
|
|
2318
|
+
}
|
|
2319
|
+
});
|
|
2320
|
+
|
|
2321
|
+
program
|
|
2322
|
+
.command('stop')
|
|
2323
|
+
.argument('<slug>', 'Run slug to stop')
|
|
2324
|
+
.description('Send SIGTERM to a running job (or reconcile a dead one to crashed)')
|
|
2325
|
+
.action((slug) => {
|
|
2326
|
+
const cwd = process.cwd();
|
|
2327
|
+
try {
|
|
2328
|
+
const record = readJob(cwd, slug);
|
|
2329
|
+
if (!record) throw new Error(`stopJob: unknown job ${slug}`);
|
|
2330
|
+
if (isCascadeParent(cwd, record)) {
|
|
2331
|
+
cascadeStop(cwd, slug);
|
|
2332
|
+
console.log(`stop signal sent to ${slug} and its children`);
|
|
2333
|
+
} else {
|
|
2334
|
+
const result = stopJob(cwd, slug);
|
|
2335
|
+
if (result.action === 'signaled') {
|
|
2336
|
+
console.log(`stop signal sent to ${slug} (pid ${result.record.pid})`);
|
|
2337
|
+
} else if (result.action === 'crashed') {
|
|
2338
|
+
console.log(`${slug} process was already gone; marked crashed`);
|
|
2339
|
+
} else {
|
|
2340
|
+
console.log(`${slug} is already ${result.record.state}`);
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
} catch (err) {
|
|
2344
|
+
console.error(`Error: ${err.message}`);
|
|
2345
|
+
process.exit(1);
|
|
2346
|
+
}
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
const jobsCmd = program
|
|
2350
|
+
.command('jobs')
|
|
2351
|
+
.description('Manage orch job artifacts under .orch/');
|
|
2352
|
+
|
|
2353
|
+
jobsCmd
|
|
2354
|
+
.command('clean')
|
|
2355
|
+
.description('Delete all orch jobs from the .orch folder')
|
|
2356
|
+
.action(async () => {
|
|
2357
|
+
const cwd = process.cwd();
|
|
2358
|
+
const orchDir = path.join(cwd, '.orch');
|
|
2359
|
+
if (!fs.existsSync(orchDir) || fs.readdirSync(orchDir).length === 0) {
|
|
2360
|
+
console.log('no jobs to clean');
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
const rl = readline.createInterface({ input, output });
|
|
2365
|
+
let answer;
|
|
2366
|
+
try {
|
|
2367
|
+
answer = await rl.question('Are you sure? [y/N] ');
|
|
2368
|
+
} finally {
|
|
2369
|
+
rl.close();
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
2373
|
+
console.log('aborted');
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
const removed = cleanJobs(cwd);
|
|
2378
|
+
console.log(`deleted ${removed.length} job${removed.length === 1 ? '' : 's'} from .orch/`);
|
|
2379
|
+
});
|
|
2380
|
+
|
|
2381
|
+
program
|
|
2382
|
+
.command('logs')
|
|
2383
|
+
.argument('<slug>', 'Run slug to show logs for')
|
|
2384
|
+
.option('-f, --follow', 'Follow the log file until the job reaches a terminal state (Ctrl+C to stop)')
|
|
2385
|
+
.description("Print a run's orch.log")
|
|
2386
|
+
.action((slug, options) => {
|
|
2387
|
+
const cwd = process.cwd();
|
|
2388
|
+
const record = readJob(cwd, slug);
|
|
2389
|
+
if (!record) {
|
|
2390
|
+
console.error(`Error: unknown run ${slug}`);
|
|
2391
|
+
process.exit(1);
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
const { logPath } = jobPaths(cwd, slug);
|
|
2395
|
+
if (!fs.existsSync(logPath)) {
|
|
2396
|
+
console.error(`Error: no log file for ${slug}`);
|
|
2397
|
+
process.exit(1);
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
if (!options.follow) {
|
|
2402
|
+
process.stdout.write(fs.readFileSync(logPath));
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
const fd = fs.openSync(logPath, 'r');
|
|
2407
|
+
let position = 0;
|
|
2408
|
+
const pump = () => {
|
|
2409
|
+
const { size } = fs.fstatSync(fd);
|
|
2410
|
+
if (size > position) {
|
|
2411
|
+
const buf = Buffer.alloc(size - position);
|
|
2412
|
+
fs.readSync(fd, buf, 0, buf.length, position);
|
|
2413
|
+
process.stdout.write(buf);
|
|
2414
|
+
position = size;
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2417
|
+
pump();
|
|
2418
|
+
|
|
2419
|
+
const stop = () => {
|
|
2420
|
+
clearInterval(interval);
|
|
2421
|
+
fs.closeSync(fd);
|
|
2422
|
+
process.exit(0);
|
|
2423
|
+
};
|
|
2424
|
+
|
|
2425
|
+
const interval = setInterval(() => {
|
|
2426
|
+
pump();
|
|
2427
|
+
const current = reconcileJob(cwd, slug, readJob(cwd, slug));
|
|
2428
|
+
if (TERMINAL_JOB_STATES.includes(current.state)) stop();
|
|
2429
|
+
}, 500);
|
|
2430
|
+
|
|
2431
|
+
process.once('SIGINT', stop);
|
|
504
2432
|
});
|
|
505
2433
|
|
|
506
2434
|
const invokedPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
|