@welluable/orch 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -14
- package/agents/adjust.js +66 -0
- package/agents/ask.js +3 -5
- package/agents/boundaries.js +3 -4
- package/agents/code-writer.js +3 -5
- package/agents/decomposer.js +5 -5
- package/agents/index.js +2 -0
- package/agents/integrator.js +3 -4
- package/agents/planner.js +3 -5
- package/agents/quick-fix.js +3 -5
- package/agents/research.js +3 -5
- package/agents/seq-decomposer.js +58 -0
- package/agents/summary-footer.js +23 -0
- package/agents/test-critic.js +5 -6
- package/agents/test-runner.js +5 -6
- package/agents/test-writer.js +3 -5
- package/agents/triage.js +5 -6
- package/lib/agent-agn.js +4 -2
- package/lib/agent-cursor.js +4 -2
- package/lib/agent-opencode.js +177 -0
- package/lib/agent.js +165 -14
- package/lib/config.js +190 -0
- package/lib/continue.js +150 -0
- package/lib/failure-log.js +72 -0
- package/lib/file-tracker.js +68 -12
- package/lib/jobs.js +209 -7
- package/lib/notify.js +71 -0
- package/lib/resume.js +264 -0
- package/lib/seq.js +281 -0
- package/lib/stage-summary.js +24 -0
- package/lib/tool-status.js +53 -6
- package/main.js +3116 -360
- package/package.json +1 -1
package/main.js
CHANGED
|
@@ -10,15 +10,16 @@ import { fileURLToPath } from 'url';
|
|
|
10
10
|
import { AgentCursor } from './lib/agent-cursor.js';
|
|
11
11
|
import { AgentClaude } from './lib/agent-claude.js';
|
|
12
12
|
import { AgentAgn } from './lib/agent-agn.js';
|
|
13
|
+
import { AgentOpencode } from './lib/agent-opencode.js';
|
|
13
14
|
import { parseTriageJson } from './lib/parse-triage-json.js';
|
|
14
15
|
import { parseVerdict } from './lib/parse-verdict.js';
|
|
15
|
-
import { splitStageSummary, printStageSummary } from './lib/stage-summary.js';
|
|
16
|
+
import { splitStageSummary, printStageSummary, resolveStageSummary } from './lib/stage-summary.js';
|
|
16
17
|
import { createRunContext } from './lib/run-context.js';
|
|
17
18
|
import { createWorktree } from './lib/worktree.js';
|
|
18
19
|
import { commitWorktree, collectWorktreeChanges, printFilesChanged } from './lib/commit.js';
|
|
19
20
|
import { FileTracker } from './lib/file-tracker.js';
|
|
20
21
|
import { allocateJob } from './lib/job-lifecycle.js';
|
|
21
|
-
import { setJobSlug, exitCodeForSignal, formatElapsed } from './lib/agent.js';
|
|
22
|
+
import { setJobSlug, exitCodeForSignal, formatElapsed, flushFailureLog, beginStageCapture } from './lib/agent.js';
|
|
22
23
|
import {
|
|
23
24
|
jobPaths,
|
|
24
25
|
readJob,
|
|
@@ -32,8 +33,21 @@ import {
|
|
|
32
33
|
cascadeResume,
|
|
33
34
|
stopJob,
|
|
34
35
|
cleanJobs,
|
|
36
|
+
liveSlugsBlockingClean,
|
|
35
37
|
isPidAlive,
|
|
38
|
+
reopenJob,
|
|
39
|
+
buildLastOutcome,
|
|
36
40
|
} from './lib/jobs.js';
|
|
41
|
+
import {
|
|
42
|
+
validateContinue,
|
|
43
|
+
snapshotPriorOutcome,
|
|
44
|
+
buildPriorOutcomeText,
|
|
45
|
+
} from './lib/continue.js';
|
|
46
|
+
import {
|
|
47
|
+
validateResume,
|
|
48
|
+
reopenForResume,
|
|
49
|
+
runRecover,
|
|
50
|
+
} from './lib/resume.js';
|
|
37
51
|
import { askAgentArgs } from './agents/ask.js';
|
|
38
52
|
import { triageAgentArgs } from './agents/triage.js';
|
|
39
53
|
import { quickFixAgentArgs } from './agents/quick-fix.js';
|
|
@@ -46,6 +60,8 @@ import { testRunnerAgentArgs } from './agents/test-runner.js';
|
|
|
46
60
|
import { integratorAgentArgs } from './agents/integrator.js';
|
|
47
61
|
import { boundariesAgentArgs } from './agents/boundaries.js';
|
|
48
62
|
import { decomposerAgentArgs } from './agents/decomposer.js';
|
|
63
|
+
import { seqDecomposerAgentArgs } from './agents/seq-decomposer.js';
|
|
64
|
+
import { adjustAgentArgs } from './agents/adjust.js';
|
|
49
65
|
import {
|
|
50
66
|
readFanout,
|
|
51
67
|
writeFanout,
|
|
@@ -60,6 +76,17 @@ import {
|
|
|
60
76
|
detectOverlaps,
|
|
61
77
|
ensureScaffoldSubtask,
|
|
62
78
|
} from './lib/fanout.js';
|
|
79
|
+
import {
|
|
80
|
+
readSeq,
|
|
81
|
+
writeSeq,
|
|
82
|
+
patchUnit,
|
|
83
|
+
patchTip,
|
|
84
|
+
appendAdjustment,
|
|
85
|
+
validateSeqDecomposition,
|
|
86
|
+
buildUnitEnvelope,
|
|
87
|
+
validateAdjustResult,
|
|
88
|
+
applyAdjustResult,
|
|
89
|
+
} from './lib/seq.js';
|
|
63
90
|
import { parseDecomposition } from './lib/parse-decomposition.js';
|
|
64
91
|
import {
|
|
65
92
|
mergeBranches,
|
|
@@ -67,6 +94,15 @@ import {
|
|
|
67
94
|
conflictedFiles,
|
|
68
95
|
hasConflictMarkers,
|
|
69
96
|
} from './lib/integrate.js';
|
|
97
|
+
import {
|
|
98
|
+
resolveAgent,
|
|
99
|
+
resolveNotify,
|
|
100
|
+
writeConfig,
|
|
101
|
+
printConfig,
|
|
102
|
+
globalConfigPath,
|
|
103
|
+
localConfigPath,
|
|
104
|
+
} from './lib/config.js';
|
|
105
|
+
import { setNotifyEnabled } from './lib/notify.js';
|
|
70
106
|
|
|
71
107
|
const __filename = fileURLToPath(import.meta.url);
|
|
72
108
|
const __dirname = path.dirname(__filename);
|
|
@@ -91,6 +127,12 @@ const AGENT_BACKENDS = {
|
|
|
91
127
|
binary: 'agn',
|
|
92
128
|
missingHint: 'agn not found; run npm install -g @welluable/agn-cli or use --agent cursor',
|
|
93
129
|
},
|
|
130
|
+
opencode: {
|
|
131
|
+
AgentClass: AgentOpencode,
|
|
132
|
+
binary: 'opencode',
|
|
133
|
+
missingHint:
|
|
134
|
+
'opencode not found; install OpenCode (https://opencode.ai) or use --agent cursor',
|
|
135
|
+
},
|
|
94
136
|
};
|
|
95
137
|
|
|
96
138
|
function isBinaryOnPath(binary) {
|
|
@@ -173,9 +215,9 @@ export function formatJobsTable(jobs) {
|
|
|
173
215
|
}
|
|
174
216
|
|
|
175
217
|
const rows = ordered.map(({ job, indent }) => [
|
|
176
|
-
`${indent}${job.slug}`,
|
|
218
|
+
`${indent}${job.slug ?? '-'}`,
|
|
177
219
|
displayJobRole(job.role),
|
|
178
|
-
job.state,
|
|
220
|
+
job.state ?? '-',
|
|
179
221
|
job.phase ?? '-',
|
|
180
222
|
job.agent ?? '-',
|
|
181
223
|
formatRelativeTime(job.startedAt),
|
|
@@ -192,6 +234,21 @@ function lastNonEmptyLine(content) {
|
|
|
192
234
|
return lines[lines.length - 1];
|
|
193
235
|
}
|
|
194
236
|
|
|
237
|
+
/** Status / list `next:` hint per `.spec/resume.md` decision 28. */
|
|
238
|
+
function nextHintForRecord(record) {
|
|
239
|
+
if (!record) return null;
|
|
240
|
+
if (record.state === 'paused' || record.state === 'pausing') {
|
|
241
|
+
return `orch resume ${record.slug}`;
|
|
242
|
+
}
|
|
243
|
+
if (record.state === 'failed' || record.state === 'stopped' || record.state === 'crashed') {
|
|
244
|
+
return `orch resume ${record.slug}`;
|
|
245
|
+
}
|
|
246
|
+
if (record.state === 'done') {
|
|
247
|
+
return `orch continue ${record.slug} "<follow-up>"`;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
195
252
|
export function formatStatus(cwd, record) {
|
|
196
253
|
const lines = [
|
|
197
254
|
`slug: ${record.slug}`,
|
|
@@ -211,12 +268,27 @@ export function formatStatus(cwd, record) {
|
|
|
211
268
|
lines.splice(1, 0, `parent: ${record.parent}`);
|
|
212
269
|
}
|
|
213
270
|
|
|
271
|
+
if (record.continuation > 1) {
|
|
272
|
+
const stateIdx = lines.findIndex((line) => line.startsWith('state:'));
|
|
273
|
+
lines.splice(stateIdx + 1, 0, `continuation: ${record.continuation}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (record.lastOutcome) {
|
|
277
|
+
const o = record.lastOutcome;
|
|
278
|
+
lines.push(`outcome: ${o.phase ?? '-'} / ${o.stage ?? '-'} (round ${o.round ?? '-'})`);
|
|
279
|
+
if (o.summary) lines.push(`summary: ${o.summary}`);
|
|
280
|
+
if (o.error) lines.push(`error: ${o.error}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
214
283
|
const statusPath = path.join(jobPaths(cwd, record.slug).dir, 'status.md');
|
|
215
284
|
if (fs.existsSync(statusPath)) {
|
|
216
285
|
const last = lastNonEmptyLine(fs.readFileSync(statusPath, 'utf8'));
|
|
217
286
|
if (last) lines.push(`status: ${last}`);
|
|
218
287
|
}
|
|
219
288
|
|
|
289
|
+
const nextHint = nextHintForRecord(record);
|
|
290
|
+
if (nextHint) lines.push(`next: ${nextHint}`);
|
|
291
|
+
|
|
220
292
|
// Child view: parent line only — do not expand siblings.
|
|
221
293
|
// Read children from disk without reconcile so status reflects recorded
|
|
222
294
|
// state/phase/branch (listJobs would rewrite dead-pid live states to crashed).
|
|
@@ -275,6 +347,58 @@ function defaultExecFile(command, args, options = {}) {
|
|
|
275
347
|
return execFileSync(command, args, { encoding: 'utf8', ...options });
|
|
276
348
|
}
|
|
277
349
|
|
|
350
|
+
/** Patch a job to a terminal state and write a matching `lastOutcome` in the same write. */
|
|
351
|
+
function patchTerminalJob(patchJobFn, jobCwd, jobSlug, { state, exitCode, summary = '', error = null, task }) {
|
|
352
|
+
if (!jobSlug) return null;
|
|
353
|
+
const finishedAt = new Date().toISOString();
|
|
354
|
+
let outcomeError = error;
|
|
355
|
+
if (state === 'failed') {
|
|
356
|
+
const pointer = flushFailureLog({
|
|
357
|
+
cwd: jobCwd,
|
|
358
|
+
slug: jobSlug,
|
|
359
|
+
state,
|
|
360
|
+
exitCode,
|
|
361
|
+
finishedAt,
|
|
362
|
+
task,
|
|
363
|
+
error,
|
|
364
|
+
});
|
|
365
|
+
if (pointer) {
|
|
366
|
+
outcomeError = pointer;
|
|
367
|
+
console.error(`error: ${pointer}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
patchJobFn(jobCwd, jobSlug, (current) => ({
|
|
371
|
+
state,
|
|
372
|
+
exitCode,
|
|
373
|
+
finishedAt,
|
|
374
|
+
lastOutcome: buildLastOutcome({
|
|
375
|
+
state,
|
|
376
|
+
phase: current.phase,
|
|
377
|
+
stage: current.stage,
|
|
378
|
+
round: current.round,
|
|
379
|
+
exitCode,
|
|
380
|
+
finishedAt,
|
|
381
|
+
task: task ?? current.task,
|
|
382
|
+
summary,
|
|
383
|
+
error: outcomeError,
|
|
384
|
+
}),
|
|
385
|
+
}));
|
|
386
|
+
return outcomeError;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Patch live cursor fields and start stage-verbose capture when a job is active. */
|
|
390
|
+
function patchJobCursor(patchJobFn, jobCwd, jobSlug, fields) {
|
|
391
|
+
if (!jobSlug) return;
|
|
392
|
+
if ('phase' in fields || 'stage' in fields) {
|
|
393
|
+
beginStageCapture({
|
|
394
|
+
phase: fields.phase ?? null,
|
|
395
|
+
stage: fields.stage ?? null,
|
|
396
|
+
round: 'round' in fields ? fields.round : null,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
return patchJobFn(jobCwd, jobSlug, fields);
|
|
400
|
+
}
|
|
401
|
+
|
|
278
402
|
/** The test-writer ⇄ test-critic loop shared by `runPipeline` and `runWorkerPipeline`. */
|
|
279
403
|
async function runTestLoop({
|
|
280
404
|
prompt,
|
|
@@ -287,13 +411,17 @@ async function runTestLoop({
|
|
|
287
411
|
verbose,
|
|
288
412
|
jobPatch,
|
|
289
413
|
jobCheckpoint,
|
|
414
|
+
startAt = null,
|
|
290
415
|
}) {
|
|
291
416
|
let testAccepted = null;
|
|
292
417
|
let criticFeedback = null;
|
|
293
418
|
let testRound = 0;
|
|
294
419
|
let testSummary = '';
|
|
295
420
|
|
|
296
|
-
|
|
421
|
+
// Resume at recorded round; always re-run writer for that round (need output for critic).
|
|
422
|
+
const startRound = Math.max(1, Number(startAt?.round) || 1);
|
|
423
|
+
|
|
424
|
+
for (let round = startRound; round <= maxRounds; round++) {
|
|
297
425
|
testRound = round;
|
|
298
426
|
|
|
299
427
|
jobPatch({ phase: 'test-loop', stage: 'test-writer', round });
|
|
@@ -319,7 +447,7 @@ async function runTestLoop({
|
|
|
319
447
|
const { content: testWriterContent, summary: testWriterSummary } = splitStageSummary(testOut.result);
|
|
320
448
|
printStageSummary(
|
|
321
449
|
roundLabel('test-writer', round, maxRounds),
|
|
322
|
-
testWriterSummary,
|
|
450
|
+
resolveStageSummary(roundLabel('test-writer', round, maxRounds), testWriterSummary, testWriterContent),
|
|
323
451
|
testWriterTracker.getFiles(),
|
|
324
452
|
);
|
|
325
453
|
if (!testOut.ok) {
|
|
@@ -352,7 +480,10 @@ async function runTestLoop({
|
|
|
352
480
|
const criticOut = await testCritic.run({ verbose });
|
|
353
481
|
await jobCheckpoint();
|
|
354
482
|
const { content: testCriticContent, summary: testCriticSummary } = splitStageSummary(criticOut.result);
|
|
355
|
-
printStageSummary(
|
|
483
|
+
printStageSummary(
|
|
484
|
+
roundLabel('test-critic', round, maxRounds),
|
|
485
|
+
resolveStageSummary(roundLabel('test-critic', round, maxRounds), testCriticSummary, testCriticContent),
|
|
486
|
+
);
|
|
356
487
|
if (!criticOut.ok) {
|
|
357
488
|
appendLoopStatus(statusPath, 'Test loop', {
|
|
358
489
|
round: testRound,
|
|
@@ -406,6 +537,7 @@ async function runCodeLoop({
|
|
|
406
537
|
acceptedVerification,
|
|
407
538
|
runnerFirst = false,
|
|
408
539
|
loopTitle = 'Code loop',
|
|
540
|
+
startAt = null,
|
|
409
541
|
}) {
|
|
410
542
|
let codeAccepted = null;
|
|
411
543
|
let runnerFeedback = null;
|
|
@@ -413,9 +545,14 @@ async function runCodeLoop({
|
|
|
413
545
|
let codeSummary = '';
|
|
414
546
|
let codeWriterContent = null;
|
|
415
547
|
|
|
416
|
-
|
|
548
|
+
const startRound = Math.max(1, Number(startAt?.round) || 1);
|
|
549
|
+
// When resuming at test-runner, skip writer once for that round (runner-first style).
|
|
550
|
+
let resumeSkipWriter = startAt?.stage === 'test-runner';
|
|
551
|
+
|
|
552
|
+
for (let round = startRound; round <= maxRounds; round++) {
|
|
417
553
|
codeRound = round;
|
|
418
|
-
const skipWriter = runnerFirst && round === 1;
|
|
554
|
+
const skipWriter = (runnerFirst && round === 1) || (resumeSkipWriter && round === startRound);
|
|
555
|
+
if (resumeSkipWriter && round === startRound) resumeSkipWriter = false;
|
|
419
556
|
|
|
420
557
|
if (!skipWriter) {
|
|
421
558
|
jobPatch({ phase: 'code-loop', stage: 'code-writer', round });
|
|
@@ -444,7 +581,7 @@ async function runCodeLoop({
|
|
|
444
581
|
codeWriterContent = content;
|
|
445
582
|
printStageSummary(
|
|
446
583
|
roundLabel('code-writer', round, maxRounds),
|
|
447
|
-
summary,
|
|
584
|
+
resolveStageSummary(roundLabel('code-writer', round, maxRounds), summary, content),
|
|
448
585
|
codeWriterTracker.getFiles(),
|
|
449
586
|
);
|
|
450
587
|
if (!codeOut.ok) {
|
|
@@ -477,7 +614,10 @@ async function runCodeLoop({
|
|
|
477
614
|
const runnerOut = await testRunner.run({ verbose });
|
|
478
615
|
await jobCheckpoint();
|
|
479
616
|
const { content: testRunnerContent, summary: testRunnerSummary } = splitStageSummary(runnerOut.result);
|
|
480
|
-
printStageSummary(
|
|
617
|
+
printStageSummary(
|
|
618
|
+
roundLabel('test-runner', round, maxRounds),
|
|
619
|
+
resolveStageSummary(roundLabel('test-runner', round, maxRounds), testRunnerSummary, testRunnerContent),
|
|
620
|
+
);
|
|
481
621
|
if (!runnerOut.ok) {
|
|
482
622
|
appendLoopStatus(statusPath, loopTitle, {
|
|
483
623
|
round: codeRound,
|
|
@@ -534,7 +674,7 @@ export async function runPipeline(prompt, options) {
|
|
|
534
674
|
|
|
535
675
|
const jobPatch = (fields) => {
|
|
536
676
|
if (!jobSlug) return;
|
|
537
|
-
patchJobFn
|
|
677
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
538
678
|
};
|
|
539
679
|
const jobCheckpoint = async () => {
|
|
540
680
|
if (!jobSlug) return;
|
|
@@ -573,7 +713,7 @@ export async function runPipeline(prompt, options) {
|
|
|
573
713
|
return;
|
|
574
714
|
}
|
|
575
715
|
const { content, summary } = splitStageSummary(askResult.result);
|
|
576
|
-
printStageSummary('ask', summary);
|
|
716
|
+
printStageSummary('ask', resolveStageSummary('ask', summary, content));
|
|
577
717
|
console.log(content);
|
|
578
718
|
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
579
719
|
} catch (err) {
|
|
@@ -603,8 +743,8 @@ export async function runPipeline(prompt, options) {
|
|
|
603
743
|
process.exit(1);
|
|
604
744
|
return;
|
|
605
745
|
}
|
|
606
|
-
const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
607
|
-
printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
|
|
746
|
+
const { content: quickFixContent, summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
747
|
+
printStageSummary('quick-fix', resolveStageSummary('quick-fix', quickFixSummary, quickFixContent), quickFixTracker.getFiles());
|
|
608
748
|
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
609
749
|
} catch (err) {
|
|
610
750
|
console.error(`Error: ${err.message}`);
|
|
@@ -628,7 +768,7 @@ export async function runPipeline(prompt, options) {
|
|
|
628
768
|
const triageResult = await triageAgent.run({ verbose });
|
|
629
769
|
await jobCheckpoint();
|
|
630
770
|
const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
|
|
631
|
-
printStageSummary('triage', triageSummary);
|
|
771
|
+
printStageSummary('triage', resolveStageSummary('triage', triageSummary, triageContent));
|
|
632
772
|
const parsed = parseTriageJson(triageContent);
|
|
633
773
|
|
|
634
774
|
if (parsed?.simple === true) {
|
|
@@ -648,8 +788,8 @@ export async function runPipeline(prompt, options) {
|
|
|
648
788
|
|
|
649
789
|
const quickFixResult = await quickFixAgent.run({ verbose });
|
|
650
790
|
await jobCheckpoint();
|
|
651
|
-
const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
652
|
-
printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
|
|
791
|
+
const { content: quickFixContent, summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
792
|
+
printStageSummary('quick-fix', resolveStageSummary('quick-fix', quickFixSummary, quickFixContent), quickFixTracker.getFiles());
|
|
653
793
|
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
654
794
|
return;
|
|
655
795
|
}
|
|
@@ -675,7 +815,7 @@ export async function runPipeline(prompt, options) {
|
|
|
675
815
|
const result = await researchAgent.run({ verbose });
|
|
676
816
|
await jobCheckpoint();
|
|
677
817
|
const { content: researchContent, summary: researchSummary } = splitStageSummary(result.result);
|
|
678
|
-
printStageSummary('research', researchSummary);
|
|
818
|
+
printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
|
|
679
819
|
|
|
680
820
|
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
681
821
|
const planner = plannerAgentArgs({
|
|
@@ -694,8 +834,8 @@ export async function runPipeline(prompt, options) {
|
|
|
694
834
|
|
|
695
835
|
const plannerResult = await plannerAgent.run({ verbose });
|
|
696
836
|
await jobCheckpoint();
|
|
697
|
-
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
698
|
-
printStageSummary('planner', plannerSummary);
|
|
837
|
+
const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
838
|
+
printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
|
|
699
839
|
|
|
700
840
|
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
701
841
|
const worktree = createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
|
|
@@ -729,7 +869,7 @@ export async function runPipeline(prompt, options) {
|
|
|
729
869
|
.filter(Boolean)
|
|
730
870
|
.join('\n');
|
|
731
871
|
|
|
732
|
-
await runCodeLoop({
|
|
872
|
+
const codeAccepted = await runCodeLoop({
|
|
733
873
|
prompt,
|
|
734
874
|
worktreePath: worktree.worktreePath,
|
|
735
875
|
branch: worktree.branch,
|
|
@@ -762,6 +902,7 @@ export async function runPipeline(prompt, options) {
|
|
|
762
902
|
);
|
|
763
903
|
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
764
904
|
console.log(`merge: git merge ${commitResult.branch}`);
|
|
905
|
+
console.log(`next: orch continue ${runContext.slug} "…"`);
|
|
765
906
|
} else {
|
|
766
907
|
fs.appendFileSync(
|
|
767
908
|
runContext.statusPath,
|
|
@@ -770,20 +911,29 @@ export async function runPipeline(prompt, options) {
|
|
|
770
911
|
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
771
912
|
}
|
|
772
913
|
|
|
773
|
-
|
|
914
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
915
|
+
state: 'done',
|
|
916
|
+
exitCode: 0,
|
|
917
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
918
|
+
error: null,
|
|
919
|
+
task: prompt,
|
|
920
|
+
});
|
|
774
921
|
} catch (err) {
|
|
775
922
|
console.error(`Error: ${err.message}`);
|
|
776
923
|
if (jobSlug) {
|
|
777
924
|
try {
|
|
778
|
-
patchJobFn
|
|
925
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
779
926
|
state: 'failed',
|
|
780
927
|
exitCode: 1,
|
|
781
|
-
|
|
928
|
+
summary: '',
|
|
929
|
+
error: err.message,
|
|
930
|
+
task: prompt,
|
|
782
931
|
});
|
|
783
932
|
} catch {
|
|
784
933
|
// Best-effort: don't let a job-state write failure mask the real error.
|
|
785
934
|
}
|
|
786
935
|
}
|
|
936
|
+
console.error(`next: orch resume ${jobSlug ?? '<slug>'}`);
|
|
787
937
|
process.exit(1);
|
|
788
938
|
}
|
|
789
939
|
}
|
|
@@ -801,6 +951,9 @@ export async function runDetached(prompt, options = {}) {
|
|
|
801
951
|
maxRounds = 5,
|
|
802
952
|
verbose,
|
|
803
953
|
cwd = process.cwd(),
|
|
954
|
+
seq = false,
|
|
955
|
+
maxUnits = 8,
|
|
956
|
+
notify,
|
|
804
957
|
createRunContext: createRunContextFn = createRunContext,
|
|
805
958
|
spawn: spawnFn = spawn,
|
|
806
959
|
exit = (code) => process.exit(code),
|
|
@@ -824,6 +977,7 @@ export async function runDetached(prompt, options = {}) {
|
|
|
824
977
|
maxRounds,
|
|
825
978
|
state: 'starting',
|
|
826
979
|
createRunContext: createRunContextFn,
|
|
980
|
+
role: seq ? 'coordinator' : null,
|
|
827
981
|
});
|
|
828
982
|
const { logPath } = jobPaths(cwd, slug);
|
|
829
983
|
|
|
@@ -831,6 +985,10 @@ export async function runDetached(prompt, options = {}) {
|
|
|
831
985
|
|
|
832
986
|
const childArgs = [__filename, prompt, '--agent', agent, '--max-rounds', String(maxRounds)];
|
|
833
987
|
if (verbose) childArgs.push('--verbose');
|
|
988
|
+
if (seq) {
|
|
989
|
+
childArgs.push('--seq', '--max-units', String(maxUnits));
|
|
990
|
+
}
|
|
991
|
+
appendNotifyArgs(childArgs, notify);
|
|
834
992
|
|
|
835
993
|
const child = spawnFn(process.execPath, childArgs, {
|
|
836
994
|
cwd,
|
|
@@ -847,14 +1005,11 @@ export async function runDetached(prompt, options = {}) {
|
|
|
847
1005
|
}
|
|
848
1006
|
|
|
849
1007
|
/**
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
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.
|
|
1008
|
+
* Continue pipeline: full complex stages on an existing worktree/branch.
|
|
1009
|
+
* Skips triage and `createWorktree`. Injects prior-outcome text into
|
|
1010
|
+
* research/planner only. See `.spec/continue.md`.
|
|
856
1011
|
*/
|
|
857
|
-
export async function
|
|
1012
|
+
export async function runContinuePipeline(prompt, options = {}) {
|
|
858
1013
|
const verbose = Boolean(options.verbose);
|
|
859
1014
|
const maxRounds = options.maxRounds ?? 5;
|
|
860
1015
|
const backend = AGENT_BACKENDS[options.agent];
|
|
@@ -863,25 +1018,35 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
863
1018
|
}
|
|
864
1019
|
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
865
1020
|
const cwd = options.cwd ?? process.cwd();
|
|
866
|
-
const {
|
|
1021
|
+
const {
|
|
1022
|
+
slug,
|
|
1023
|
+
priorOutcome,
|
|
1024
|
+
continuation,
|
|
1025
|
+
} = options;
|
|
867
1026
|
|
|
868
1027
|
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
869
|
-
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
870
1028
|
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
871
1029
|
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
872
1030
|
const patchWorkerFn = options.patchWorker ?? patchWorker;
|
|
873
1031
|
const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
|
|
874
1032
|
const execFileFn = options.execFile;
|
|
875
1033
|
|
|
876
|
-
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
1034
|
+
const jobSlug = options.jobSlug ?? slug ?? process.env.ORCH_JOB_SLUG;
|
|
877
1035
|
const jobCwd = options.jobCwd ?? cwd;
|
|
878
1036
|
const patchJobFn = options.patchJob ?? patchJob;
|
|
879
1037
|
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
880
1038
|
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
881
1039
|
|
|
1040
|
+
const jobRecord = (jobSlug && readJob(jobCwd, jobSlug)) || null;
|
|
1041
|
+
const worktreePath = options.worktreePath ?? jobRecord?.worktree;
|
|
1042
|
+
const branch = options.branch ?? jobRecord?.branch;
|
|
1043
|
+
const role = options.role ?? jobRecord?.role;
|
|
1044
|
+
const parentSlug = options.parentSlug ?? jobRecord?.parent;
|
|
1045
|
+
const workerId = options.workerId ?? jobRecord?.workerId;
|
|
1046
|
+
|
|
882
1047
|
const jobPatch = (fields) => {
|
|
883
1048
|
if (!jobSlug) return;
|
|
884
|
-
patchJobFn
|
|
1049
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
885
1050
|
};
|
|
886
1051
|
const jobCheckpoint = async () => {
|
|
887
1052
|
if (!jobSlug) return;
|
|
@@ -892,11 +1057,44 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
892
1057
|
ensureBinaryOnPath(backend.binary, options.agent);
|
|
893
1058
|
}
|
|
894
1059
|
|
|
1060
|
+
const priorBlock = buildPriorOutcomeText(priorOutcome, {
|
|
1061
|
+
slug,
|
|
1062
|
+
continuation,
|
|
1063
|
+
worktreePath,
|
|
1064
|
+
branch,
|
|
1065
|
+
parentSlug,
|
|
1066
|
+
workerId,
|
|
1067
|
+
});
|
|
1068
|
+
const researchPlannerPrompt = `${priorBlock}\n\nUser follow-up:\n${prompt}`;
|
|
1069
|
+
|
|
895
1070
|
try {
|
|
896
|
-
const runContext = createRunContextFn(
|
|
1071
|
+
const runContext = createRunContextFn({ cwd, slug });
|
|
1072
|
+
|
|
1073
|
+
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
1074
|
+
const prior = priorOutcome ?? {};
|
|
1075
|
+
const startedIso = new Date().toISOString();
|
|
1076
|
+
let continueSection = `\n## Continue ${continuation}\n\n`;
|
|
1077
|
+
continueSection += `- Task: ${prompt.split('\n')[0]}\n`;
|
|
1078
|
+
continueSection += `- Started: ${startedIso}\n`;
|
|
1079
|
+
continueSection += `- Branch: \`${branch}\`\n`;
|
|
1080
|
+
continueSection += `- Worktree: \`${worktreePath}\`\n`;
|
|
1081
|
+
if (parentSlug) continueSection += `- Fan-out parent: \`${parentSlug}\`\n`;
|
|
1082
|
+
if (workerId) continueSection += `- Worker id: \`${workerId}\`\n`;
|
|
1083
|
+
continueSection += `\n### Prior outcome\n\n`;
|
|
1084
|
+
continueSection += `- State: ${prior.state ?? '(none recorded)'}\n`;
|
|
1085
|
+
continueSection += `- Phase: ${prior.phase ?? '(none recorded)'}\n`;
|
|
1086
|
+
continueSection += `- Stage: ${prior.stage ?? '(none recorded)'}\n`;
|
|
1087
|
+
continueSection += `- Round: ${prior.round ?? 'null'}\n`;
|
|
1088
|
+
continueSection += `- Summary: ${prior.summary || '(none recorded)'}\n`;
|
|
1089
|
+
if (prior.error) continueSection += `- Error: ${prior.error}\n`;
|
|
1090
|
+
fs.appendFileSync(runContext.statusPath, continueSection);
|
|
897
1091
|
|
|
898
1092
|
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
899
|
-
const research = researchAgentArgs({
|
|
1093
|
+
const research = researchAgentArgs({
|
|
1094
|
+
prompt: researchPlannerPrompt,
|
|
1095
|
+
cwd: worktreePath,
|
|
1096
|
+
researchPath: runContext.researchPath,
|
|
1097
|
+
});
|
|
900
1098
|
const researchAgent = new AgentClass(
|
|
901
1099
|
research.name,
|
|
902
1100
|
research.instructions,
|
|
@@ -906,12 +1104,12 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
906
1104
|
const researchResult = await researchAgent.run({ verbose });
|
|
907
1105
|
await jobCheckpoint();
|
|
908
1106
|
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
909
|
-
printStageSummary('research', researchSummary);
|
|
1107
|
+
printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
|
|
910
1108
|
|
|
911
1109
|
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
912
1110
|
const planner = plannerAgentArgs({
|
|
913
|
-
prompt,
|
|
914
|
-
cwd,
|
|
1111
|
+
prompt: researchPlannerPrompt,
|
|
1112
|
+
cwd: worktreePath,
|
|
915
1113
|
researchPath: runContext.researchPath,
|
|
916
1114
|
taskPath: runContext.taskPath,
|
|
917
1115
|
researchOutput: researchContent,
|
|
@@ -924,23 +1122,13 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
924
1122
|
);
|
|
925
1123
|
const plannerResult = await plannerAgent.run({ verbose });
|
|
926
1124
|
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
|
-
);
|
|
1125
|
+
const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
1126
|
+
printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
|
|
939
1127
|
|
|
940
1128
|
const testAccepted = await runTestLoop({
|
|
941
1129
|
prompt,
|
|
942
|
-
worktreePath
|
|
943
|
-
branch
|
|
1130
|
+
worktreePath,
|
|
1131
|
+
branch,
|
|
944
1132
|
taskPath: runContext.taskPath,
|
|
945
1133
|
statusPath: runContext.statusPath,
|
|
946
1134
|
maxRounds,
|
|
@@ -954,10 +1142,10 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
954
1142
|
.filter(Boolean)
|
|
955
1143
|
.join('\n');
|
|
956
1144
|
|
|
957
|
-
await runCodeLoop({
|
|
1145
|
+
const codeAccepted = await runCodeLoop({
|
|
958
1146
|
prompt,
|
|
959
|
-
worktreePath
|
|
960
|
-
branch
|
|
1147
|
+
worktreePath,
|
|
1148
|
+
branch,
|
|
961
1149
|
taskPath: runContext.taskPath,
|
|
962
1150
|
statusPath: runContext.statusPath,
|
|
963
1151
|
maxRounds,
|
|
@@ -969,12 +1157,12 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
969
1157
|
});
|
|
970
1158
|
|
|
971
1159
|
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
972
|
-
const message = `orch: ${
|
|
973
|
-
const worktreeChanges = collectWorktreeChangesFn({ worktreePath
|
|
1160
|
+
const message = `orch: ${slug} (continue ${continuation}): ${prompt.split('\n')[0]}`;
|
|
1161
|
+
const worktreeChanges = await collectWorktreeChangesFn({ worktreePath });
|
|
974
1162
|
printFilesChanged(worktreeChanges);
|
|
975
|
-
const commitResult = commitWorktreeFn({
|
|
976
|
-
worktreePath
|
|
977
|
-
branch
|
|
1163
|
+
const commitResult = await commitWorktreeFn({
|
|
1164
|
+
worktreePath,
|
|
1165
|
+
branch,
|
|
978
1166
|
message,
|
|
979
1167
|
});
|
|
980
1168
|
|
|
@@ -984,6 +1172,7 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
984
1172
|
`\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
|
|
985
1173
|
);
|
|
986
1174
|
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
1175
|
+
console.log(`merge: git merge ${commitResult.branch}`);
|
|
987
1176
|
} else {
|
|
988
1177
|
fs.appendFileSync(
|
|
989
1178
|
runContext.statusPath,
|
|
@@ -992,40 +1181,71 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
992
1181
|
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
993
1182
|
}
|
|
994
1183
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1184
|
+
if (role === 'worker' && parentSlug && workerId) {
|
|
1185
|
+
let changedFiles = [];
|
|
1186
|
+
try {
|
|
1187
|
+
changedFiles = recordChangedFilesFn({
|
|
1188
|
+
repoRoot: cwd,
|
|
1189
|
+
base: undefined,
|
|
1190
|
+
branch,
|
|
1191
|
+
execFile: execFileFn,
|
|
1192
|
+
});
|
|
1193
|
+
} catch {
|
|
1194
|
+
// Best-effort.
|
|
1195
|
+
}
|
|
1196
|
+
const seqDoc = readSeq(cwd, parentSlug);
|
|
1197
|
+
if (seqDoc) {
|
|
1198
|
+
const patchUnitFn = options.patchUnit ?? patchUnit;
|
|
1199
|
+
patchUnitFn(cwd, parentSlug, workerId, {
|
|
1200
|
+
state: 'done',
|
|
1201
|
+
sha: commitResult.sha,
|
|
1202
|
+
changedFiles,
|
|
1203
|
+
slug,
|
|
1204
|
+
});
|
|
1205
|
+
console.log(`next: orch --seq-continue ${parentSlug}`);
|
|
1206
|
+
} else if (commitResult.committed) {
|
|
1207
|
+
patchWorkerFn(cwd, parentSlug, workerId, {
|
|
1208
|
+
state: 'done',
|
|
1209
|
+
sha: commitResult.sha,
|
|
1210
|
+
changedFiles,
|
|
1211
|
+
});
|
|
1212
|
+
console.log(`next: orch --integrate ${parentSlug}`);
|
|
1213
|
+
}
|
|
1005
1214
|
}
|
|
1006
1215
|
|
|
1007
|
-
|
|
1216
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1008
1217
|
state: 'done',
|
|
1009
|
-
|
|
1010
|
-
|
|
1218
|
+
exitCode: 0,
|
|
1219
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
1220
|
+
error: null,
|
|
1221
|
+
task: prompt,
|
|
1011
1222
|
});
|
|
1012
|
-
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
1013
1223
|
} catch (err) {
|
|
1014
1224
|
console.error(`Error: ${err.message}`);
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1225
|
+
if (role === 'worker' && parentSlug && workerId) {
|
|
1226
|
+
try {
|
|
1227
|
+
const seqDoc = readSeq(cwd, parentSlug);
|
|
1228
|
+
if (seqDoc) {
|
|
1229
|
+
const patchUnitFn = options.patchUnit ?? patchUnit;
|
|
1230
|
+
patchUnitFn(cwd, parentSlug, workerId, { state: 'failed' });
|
|
1231
|
+
} else {
|
|
1232
|
+
patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
|
|
1233
|
+
}
|
|
1234
|
+
} catch {
|
|
1235
|
+
// Best-effort.
|
|
1236
|
+
}
|
|
1019
1237
|
}
|
|
1020
1238
|
if (jobSlug) {
|
|
1021
1239
|
try {
|
|
1022
|
-
patchJobFn
|
|
1240
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1023
1241
|
state: 'failed',
|
|
1024
1242
|
exitCode: 1,
|
|
1025
|
-
|
|
1243
|
+
summary: '',
|
|
1244
|
+
error: err.message,
|
|
1245
|
+
task: prompt,
|
|
1026
1246
|
});
|
|
1027
1247
|
} catch {
|
|
1028
|
-
// Best-effort
|
|
1248
|
+
// Best-effort.
|
|
1029
1249
|
}
|
|
1030
1250
|
}
|
|
1031
1251
|
process.exit(1);
|
|
@@ -1033,13 +1253,86 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
1033
1253
|
}
|
|
1034
1254
|
|
|
1035
1255
|
/**
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
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.
|
|
1256
|
+
* Detach-parent path for `orch continue`: validate, PATH-check, spawn a
|
|
1257
|
+
* `--detach`-stripped re-invocation, reopen the existing slug with the child
|
|
1258
|
+
* pid, print `started`, exit. Never runs pipeline stages itself.
|
|
1041
1259
|
*/
|
|
1042
|
-
export async function
|
|
1260
|
+
export async function runContinueDetached(slug, prompt, options = {}) {
|
|
1261
|
+
const {
|
|
1262
|
+
agent,
|
|
1263
|
+
maxRounds = 5,
|
|
1264
|
+
verbose,
|
|
1265
|
+
cwd = process.cwd(),
|
|
1266
|
+
spawn: spawnFn = spawn,
|
|
1267
|
+
exit = (code) => process.exit(code),
|
|
1268
|
+
validateContinue: validateContinueFn = validateContinue,
|
|
1269
|
+
reopenJob: reopenJobFn = reopenJob,
|
|
1270
|
+
snapshotPriorOutcome: snapshotPriorOutcomeFn = snapshotPriorOutcome,
|
|
1271
|
+
} = options;
|
|
1272
|
+
|
|
1273
|
+
const backend = AGENT_BACKENDS[agent];
|
|
1274
|
+
if (!backend) {
|
|
1275
|
+
throw new Error(`Unknown agent backend: ${agent}`);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
let record;
|
|
1279
|
+
try {
|
|
1280
|
+
record = validateContinueFn(cwd, slug, { task: prompt });
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
console.error(`Error: ${err.message}`);
|
|
1283
|
+
exit(1);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (!isBinaryOnPath(backend.binary)) {
|
|
1288
|
+
console.error(binaryMissingHint(agent));
|
|
1289
|
+
exit(1);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const prior = snapshotPriorOutcomeFn(cwd, slug, record);
|
|
1294
|
+
const { logPath } = jobPaths(cwd, slug);
|
|
1295
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
1296
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
1297
|
+
|
|
1298
|
+
const childArgs = [
|
|
1299
|
+
__filename,
|
|
1300
|
+
'continue',
|
|
1301
|
+
slug,
|
|
1302
|
+
prompt,
|
|
1303
|
+
'--agent',
|
|
1304
|
+
agent,
|
|
1305
|
+
'--max-rounds',
|
|
1306
|
+
String(maxRounds),
|
|
1307
|
+
];
|
|
1308
|
+
if (verbose) childArgs.push('--verbose');
|
|
1309
|
+
appendNotifyArgs(childArgs, options.notify);
|
|
1310
|
+
|
|
1311
|
+
const child = spawnFn(process.execPath, childArgs, {
|
|
1312
|
+
cwd,
|
|
1313
|
+
env: { ...process.env, ORCH_JOB_SLUG: slug, ORCH_DETACHED: '1' },
|
|
1314
|
+
detached: true,
|
|
1315
|
+
stdio: ['ignore', logFd, logFd],
|
|
1316
|
+
});
|
|
1317
|
+
child.unref();
|
|
1318
|
+
|
|
1319
|
+
const updated = reopenJobFn(cwd, slug, {
|
|
1320
|
+
task: prompt,
|
|
1321
|
+
agent,
|
|
1322
|
+
maxRounds,
|
|
1323
|
+
pid: child.pid,
|
|
1324
|
+
prior,
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
console.log(`started ${slug} (pid ${child.pid}, continuation ${updated.continuation})`);
|
|
1328
|
+
exit(0);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Failure-resume pipeline: recover → re-enter unfinished stage → remaining phases.
|
|
1333
|
+
* Distinct from `runContinuePipeline` (new-task continue). See `.spec/resume.md`.
|
|
1334
|
+
*/
|
|
1335
|
+
export async function runResumePipeline(options = {}) {
|
|
1043
1336
|
const verbose = Boolean(options.verbose);
|
|
1044
1337
|
const maxRounds = options.maxRounds ?? 5;
|
|
1045
1338
|
const backend = AGENT_BACKENDS[options.agent];
|
|
@@ -1048,27 +1341,37 @@ export async function runIntegratePipeline(options = {}) {
|
|
|
1048
1341
|
}
|
|
1049
1342
|
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
1050
1343
|
const cwd = options.cwd ?? process.cwd();
|
|
1051
|
-
const {
|
|
1344
|
+
const {
|
|
1345
|
+
slug,
|
|
1346
|
+
priorOutcome,
|
|
1347
|
+
recoverBrief = '',
|
|
1348
|
+
} = options;
|
|
1052
1349
|
|
|
1350
|
+
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
1053
1351
|
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
1054
1352
|
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
1055
|
-
const
|
|
1056
|
-
const
|
|
1057
|
-
const
|
|
1058
|
-
const
|
|
1059
|
-
const conflictedFilesFn = options.conflictedFiles ?? conflictedFiles;
|
|
1060
|
-
const hasConflictMarkersFn = options.hasConflictMarkers ?? hasConflictMarkers;
|
|
1061
|
-
const execFileFn = options.execFile ?? defaultExecFile;
|
|
1353
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
1354
|
+
const patchWorkerFn = options.patchWorker ?? patchWorker;
|
|
1355
|
+
const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
|
|
1356
|
+
const execFileFn = options.execFile;
|
|
1062
1357
|
|
|
1063
|
-
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
1358
|
+
const jobSlug = options.jobSlug ?? slug ?? process.env.ORCH_JOB_SLUG;
|
|
1064
1359
|
const jobCwd = options.jobCwd ?? cwd;
|
|
1065
1360
|
const patchJobFn = options.patchJob ?? patchJob;
|
|
1066
1361
|
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
1067
1362
|
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
1068
1363
|
|
|
1364
|
+
const jobRecord = (jobSlug && readJob(jobCwd, jobSlug)) || null;
|
|
1365
|
+
const worktreePath = options.worktreePath ?? jobRecord?.worktree;
|
|
1366
|
+
const branch = options.branch ?? jobRecord?.branch;
|
|
1367
|
+
const role = options.role ?? jobRecord?.role;
|
|
1368
|
+
const parentSlug = options.parentSlug ?? jobRecord?.parent;
|
|
1369
|
+
const workerId = options.workerId ?? jobRecord?.workerId;
|
|
1370
|
+
const prompt = options.prompt ?? jobRecord?.task ?? priorOutcome?.task ?? '';
|
|
1371
|
+
|
|
1069
1372
|
const jobPatch = (fields) => {
|
|
1070
1373
|
if (!jobSlug) return;
|
|
1071
|
-
patchJobFn
|
|
1374
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
1072
1375
|
};
|
|
1073
1376
|
const jobCheckpoint = async () => {
|
|
1074
1377
|
if (!jobSlug) return;
|
|
@@ -1079,250 +1382,2084 @@ export async function runIntegratePipeline(options = {}) {
|
|
|
1079
1382
|
ensureBinaryOnPath(backend.binary, options.agent);
|
|
1080
1383
|
}
|
|
1081
1384
|
|
|
1082
|
-
const
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
};
|
|
1385
|
+
const prior = priorOutcome ?? {};
|
|
1386
|
+
const phase = prior.phase ?? jobRecord?.phase ?? null;
|
|
1387
|
+
const stage = prior.stage ?? jobRecord?.stage ?? null;
|
|
1388
|
+
const round = prior.round ?? jobRecord?.round ?? null;
|
|
1096
1389
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1390
|
+
const withRecover = (basePrompt) => (
|
|
1391
|
+
recoverBrief ? `${recoverBrief}\n\n${basePrompt}` : basePrompt
|
|
1392
|
+
);
|
|
1099
1393
|
|
|
1100
1394
|
try {
|
|
1101
|
-
|
|
1102
|
-
fs.
|
|
1103
|
-
|
|
1104
|
-
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
1395
|
+
const runContext = createRunContextFn({ cwd, slug: jobSlug });
|
|
1396
|
+
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
1105
1397
|
|
|
1106
|
-
const
|
|
1107
|
-
|
|
1108
|
-
|
|
1398
|
+
const resumeSection = [
|
|
1399
|
+
'',
|
|
1400
|
+
'## Resume',
|
|
1401
|
+
'',
|
|
1402
|
+
`- Started: ${new Date().toISOString()}`,
|
|
1403
|
+
`- Prior state: ${prior.state ?? '(none)'}`,
|
|
1404
|
+
`- Reentry: ${phase ?? '-'}/${stage ?? '-'} (round ${round ?? '-'})`,
|
|
1405
|
+
`- Branch: \`${branch}\``,
|
|
1406
|
+
`- Worktree: \`${worktreePath}\``,
|
|
1407
|
+
'',
|
|
1408
|
+
].join('\n');
|
|
1409
|
+
fs.appendFileSync(runContext.statusPath, resumeSection);
|
|
1410
|
+
|
|
1411
|
+
const hasResearch = fs.existsSync(runContext.researchPath);
|
|
1412
|
+
const hasTask = fs.existsSync(runContext.taskPath);
|
|
1413
|
+
const skipEarly = ['worktree', 'test-loop', 'code-loop', 'commit'].includes(phase)
|
|
1414
|
+
&& hasResearch && hasTask;
|
|
1415
|
+
|
|
1416
|
+
let liveWorktree = worktreePath;
|
|
1417
|
+
let liveBranch = branch;
|
|
1418
|
+
let researchContent = hasResearch
|
|
1419
|
+
? fs.readFileSync(runContext.researchPath, 'utf8')
|
|
1420
|
+
: '';
|
|
1421
|
+
|
|
1422
|
+
// --- research (only when cursor is research, or early artifacts missing) ---
|
|
1423
|
+
if (phase === 'research' || (!skipEarly && !hasResearch && ['research', 'plan', null].includes(phase))) {
|
|
1424
|
+
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
1425
|
+
const research = researchAgentArgs({
|
|
1426
|
+
prompt: withRecover(prompt),
|
|
1427
|
+
cwd: liveWorktree || cwd,
|
|
1428
|
+
researchPath: runContext.researchPath,
|
|
1429
|
+
});
|
|
1430
|
+
const researchAgent = new AgentClass(
|
|
1431
|
+
research.name,
|
|
1432
|
+
research.instructions,
|
|
1433
|
+
research.prompt,
|
|
1434
|
+
research.options,
|
|
1435
|
+
);
|
|
1436
|
+
const researchResult = await researchAgent.run({ verbose });
|
|
1437
|
+
await jobCheckpoint();
|
|
1438
|
+
const split = splitStageSummary(researchResult.result);
|
|
1439
|
+
researchContent = split.content;
|
|
1440
|
+
printStageSummary('research', resolveStageSummary('research', split.summary, researchContent));
|
|
1441
|
+
}
|
|
1109
1442
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1443
|
+
// --- plan ---
|
|
1444
|
+
if (phase === 'plan' || phase === 'research' || (!skipEarly && !hasTask)) {
|
|
1445
|
+
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
1446
|
+
const plannerPrompt = phase === 'plan' ? withRecover(prompt) : prompt;
|
|
1447
|
+
const researchOut = researchContent
|
|
1448
|
+
|| (hasResearch ? fs.readFileSync(runContext.researchPath, 'utf8') : '');
|
|
1449
|
+
const planner = plannerAgentArgs({
|
|
1450
|
+
prompt: plannerPrompt,
|
|
1451
|
+
cwd: liveWorktree || cwd,
|
|
1452
|
+
researchPath: runContext.researchPath,
|
|
1453
|
+
taskPath: runContext.taskPath,
|
|
1454
|
+
researchOutput: researchOut,
|
|
1455
|
+
});
|
|
1456
|
+
const plannerAgent = new AgentClass(
|
|
1457
|
+
planner.name,
|
|
1458
|
+
planner.instructions,
|
|
1459
|
+
planner.prompt,
|
|
1460
|
+
planner.options,
|
|
1461
|
+
);
|
|
1462
|
+
const plannerResult = await plannerAgent.run({ verbose });
|
|
1463
|
+
await jobCheckpoint();
|
|
1464
|
+
const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
1465
|
+
printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
|
|
1115
1466
|
}
|
|
1116
1467
|
|
|
1117
|
-
|
|
1118
|
-
if (!worktree) {
|
|
1119
|
-
|
|
1468
|
+
// --- worktree ensure ---
|
|
1469
|
+
if (!liveWorktree || !fs.existsSync(liveWorktree) || phase === 'worktree') {
|
|
1470
|
+
if (liveWorktree && fs.existsSync(liveWorktree) && liveBranch) {
|
|
1471
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null, branch: liveBranch, worktree: liveWorktree });
|
|
1472
|
+
} else {
|
|
1473
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
1474
|
+
const wt = createWorktreeFn({ cwd, slug: jobSlug });
|
|
1475
|
+
liveWorktree = wt.worktreePath;
|
|
1476
|
+
liveBranch = wt.branch;
|
|
1477
|
+
jobPatch({ branch: liveBranch, worktree: liveWorktree });
|
|
1478
|
+
}
|
|
1120
1479
|
}
|
|
1121
1480
|
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
);
|
|
1481
|
+
let acceptedVerification = '';
|
|
1482
|
+
const enterTest = !phase || ['research', 'plan', 'worktree', 'test-loop'].includes(phase);
|
|
1483
|
+
const enterCode = !phase || ['research', 'plan', 'worktree', 'test-loop', 'code-loop'].includes(phase);
|
|
1484
|
+
const enterCommit = true;
|
|
1127
1485
|
|
|
1128
|
-
|
|
1486
|
+
if (enterTest && phase !== 'code-loop' && phase !== 'commit') {
|
|
1487
|
+
const testStartAt = phase === 'test-loop'
|
|
1488
|
+
? { stage: stage ?? 'test-writer', round: round ?? 1 }
|
|
1489
|
+
: null;
|
|
1490
|
+
const testPrompt = phase === 'test-loop' ? withRecover(prompt) : prompt;
|
|
1491
|
+
const testAccepted = await runTestLoop({
|
|
1492
|
+
prompt: testPrompt,
|
|
1493
|
+
worktreePath: liveWorktree,
|
|
1494
|
+
branch: liveBranch,
|
|
1495
|
+
taskPath: runContext.taskPath,
|
|
1496
|
+
statusPath: runContext.statusPath,
|
|
1497
|
+
maxRounds,
|
|
1498
|
+
AgentClass,
|
|
1499
|
+
verbose,
|
|
1500
|
+
jobPatch,
|
|
1501
|
+
jobCheckpoint,
|
|
1502
|
+
startAt: testStartAt,
|
|
1503
|
+
});
|
|
1504
|
+
acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
|
|
1505
|
+
.filter(Boolean)
|
|
1506
|
+
.join('\n');
|
|
1507
|
+
} else if (hasTask) {
|
|
1508
|
+
// Code-loop / commit reentry: best-effort verification from task.md
|
|
1509
|
+
acceptedVerification = fs.readFileSync(runContext.taskPath, 'utf8').slice(0, 2000);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
if (enterCode && phase !== 'commit') {
|
|
1513
|
+
const codeStartAt = phase === 'code-loop'
|
|
1514
|
+
? { stage: stage ?? 'code-writer', round: round ?? 1 }
|
|
1515
|
+
: null;
|
|
1516
|
+
const codePrompt = phase === 'code-loop' ? withRecover(prompt) : prompt;
|
|
1517
|
+
const codeAccepted = await runCodeLoop({
|
|
1518
|
+
prompt: codePrompt,
|
|
1519
|
+
worktreePath: liveWorktree,
|
|
1520
|
+
branch: liveBranch,
|
|
1521
|
+
taskPath: runContext.taskPath,
|
|
1522
|
+
statusPath: runContext.statusPath,
|
|
1523
|
+
maxRounds,
|
|
1524
|
+
AgentClass,
|
|
1525
|
+
verbose,
|
|
1526
|
+
jobPatch,
|
|
1527
|
+
jobCheckpoint,
|
|
1528
|
+
acceptedVerification,
|
|
1529
|
+
startAt: codeStartAt,
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
if (enterCommit) {
|
|
1533
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
1534
|
+
const message = `orch: ${jobSlug} (resume): ${String(prompt).split('\n')[0]}`;
|
|
1535
|
+
const worktreeChanges = await collectWorktreeChangesFn({ worktreePath: liveWorktree });
|
|
1536
|
+
printFilesChanged(worktreeChanges);
|
|
1537
|
+
const commitResult = await commitWorktreeFn({
|
|
1538
|
+
worktreePath: liveWorktree,
|
|
1539
|
+
branch: liveBranch,
|
|
1540
|
+
message,
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
if (commitResult.committed) {
|
|
1544
|
+
fs.appendFileSync(
|
|
1545
|
+
runContext.statusPath,
|
|
1546
|
+
`\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
|
|
1547
|
+
);
|
|
1548
|
+
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
1549
|
+
console.log(`merge: git merge ${commitResult.branch}`);
|
|
1550
|
+
console.log(`next: orch continue ${jobSlug} "…"`);
|
|
1551
|
+
} else {
|
|
1552
|
+
fs.appendFileSync(
|
|
1553
|
+
runContext.statusPath,
|
|
1554
|
+
`\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
|
|
1555
|
+
);
|
|
1556
|
+
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
if (role === 'worker' && parentSlug && workerId) {
|
|
1560
|
+
let changedFiles = [];
|
|
1561
|
+
try {
|
|
1562
|
+
changedFiles = recordChangedFilesFn({
|
|
1563
|
+
repoRoot: cwd,
|
|
1564
|
+
base: undefined,
|
|
1565
|
+
branch: liveBranch,
|
|
1566
|
+
execFile: execFileFn,
|
|
1567
|
+
});
|
|
1568
|
+
} catch {
|
|
1569
|
+
// Best-effort.
|
|
1570
|
+
}
|
|
1571
|
+
const seqDoc = readSeq(cwd, parentSlug);
|
|
1572
|
+
if (seqDoc) {
|
|
1573
|
+
const patchUnitFn = options.patchUnit ?? patchUnit;
|
|
1574
|
+
patchUnitFn(cwd, parentSlug, workerId, {
|
|
1575
|
+
state: 'done',
|
|
1576
|
+
sha: commitResult.sha,
|
|
1577
|
+
changedFiles,
|
|
1578
|
+
slug: jobSlug,
|
|
1579
|
+
});
|
|
1580
|
+
console.log(`next: orch --seq-continue ${parentSlug}`);
|
|
1581
|
+
} else if (commitResult.committed) {
|
|
1582
|
+
patchWorkerFn(cwd, parentSlug, workerId, {
|
|
1583
|
+
state: 'done',
|
|
1584
|
+
sha: commitResult.sha,
|
|
1585
|
+
changedFiles,
|
|
1586
|
+
});
|
|
1587
|
+
console.log(`next: orch --integrate ${parentSlug}`);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1592
|
+
state: 'done',
|
|
1593
|
+
exitCode: 0,
|
|
1594
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
1595
|
+
error: null,
|
|
1596
|
+
task: prompt,
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// commit-only reentry
|
|
1603
|
+
if (phase === 'commit') {
|
|
1604
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
1605
|
+
const message = `orch: ${jobSlug} (resume): ${String(prompt).split('\n')[0]}`;
|
|
1606
|
+
const worktreeChanges = await collectWorktreeChangesFn({ worktreePath: liveWorktree });
|
|
1607
|
+
printFilesChanged(worktreeChanges);
|
|
1608
|
+
const commitResult = await commitWorktreeFn({
|
|
1609
|
+
worktreePath: liveWorktree,
|
|
1610
|
+
branch: liveBranch,
|
|
1611
|
+
message,
|
|
1612
|
+
});
|
|
1613
|
+
if (commitResult.committed) {
|
|
1614
|
+
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
1615
|
+
console.log(`next: orch continue ${jobSlug} "…"`);
|
|
1616
|
+
} else {
|
|
1617
|
+
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
1618
|
+
}
|
|
1619
|
+
if (role === 'worker' && parentSlug && workerId && commitResult.committed) {
|
|
1620
|
+
const seqDoc = readSeq(cwd, parentSlug);
|
|
1621
|
+
if (seqDoc) {
|
|
1622
|
+
const patchUnitFn = options.patchUnit ?? patchUnit;
|
|
1623
|
+
patchUnitFn(cwd, parentSlug, workerId, {
|
|
1624
|
+
state: 'done',
|
|
1625
|
+
sha: commitResult.sha,
|
|
1626
|
+
changedFiles: [],
|
|
1627
|
+
slug: jobSlug,
|
|
1628
|
+
});
|
|
1629
|
+
} else {
|
|
1630
|
+
patchWorkerFn(cwd, parentSlug, workerId, {
|
|
1631
|
+
state: 'done',
|
|
1632
|
+
sha: commitResult.sha,
|
|
1633
|
+
changedFiles: [],
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1638
|
+
state: 'done',
|
|
1639
|
+
exitCode: 0,
|
|
1640
|
+
summary: '',
|
|
1641
|
+
error: null,
|
|
1642
|
+
task: prompt,
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
} catch (err) {
|
|
1646
|
+
console.error(`Error: ${err.message}`);
|
|
1647
|
+
if (role === 'worker' && parentSlug && workerId) {
|
|
1648
|
+
try {
|
|
1649
|
+
const seqDoc = readSeq(cwd, parentSlug);
|
|
1650
|
+
if (seqDoc) {
|
|
1651
|
+
const patchUnitFn = options.patchUnit ?? patchUnit;
|
|
1652
|
+
patchUnitFn(cwd, parentSlug, workerId, { state: 'failed' });
|
|
1653
|
+
} else {
|
|
1654
|
+
patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
|
|
1655
|
+
}
|
|
1656
|
+
} catch {
|
|
1657
|
+
// Best-effort.
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
if (jobSlug) {
|
|
1661
|
+
try {
|
|
1662
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1663
|
+
state: 'failed',
|
|
1664
|
+
exitCode: 1,
|
|
1665
|
+
summary: '',
|
|
1666
|
+
error: err.message,
|
|
1667
|
+
task: prompt,
|
|
1668
|
+
});
|
|
1669
|
+
} catch {
|
|
1670
|
+
// Best-effort.
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
console.error(`next: orch resume ${jobSlug ?? '<slug>'}`);
|
|
1674
|
+
process.exit(1);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/**
|
|
1679
|
+
* Detach-parent path for failure `orch resume`: spawn background child with
|
|
1680
|
+
* ORCH_JOB_SLUG, reopen with child pid, print resumed, exit.
|
|
1681
|
+
*/
|
|
1682
|
+
export async function runResumeDetached(slug, options = {}) {
|
|
1683
|
+
const {
|
|
1684
|
+
agent,
|
|
1685
|
+
maxRounds = 5,
|
|
1686
|
+
verbose,
|
|
1687
|
+
cwd = process.cwd(),
|
|
1688
|
+
spawn: spawnFn = spawn,
|
|
1689
|
+
exit = (code) => process.exit(code),
|
|
1690
|
+
validateResume: validateResumeFn = validateResume,
|
|
1691
|
+
reopenForResume: reopenForResumeFn = reopenForResume,
|
|
1692
|
+
snapshotPriorOutcome: snapshotPriorOutcomeFn = snapshotPriorOutcome,
|
|
1693
|
+
} = options;
|
|
1694
|
+
|
|
1695
|
+
const backend = AGENT_BACKENDS[agent];
|
|
1696
|
+
if (!backend) {
|
|
1697
|
+
throw new Error(`Unknown agent backend: ${agent}`);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
let validated;
|
|
1701
|
+
try {
|
|
1702
|
+
validated = validateResumeFn(cwd, slug, {});
|
|
1703
|
+
} catch (err) {
|
|
1704
|
+
console.error(`Error: ${err.message}`);
|
|
1705
|
+
exit(1);
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
if (validated.mode !== 'failure') {
|
|
1710
|
+
console.error(`Error: --detach only applies to failure resume (got mode ${validated.mode})`);
|
|
1711
|
+
exit(1);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
if (!isBinaryOnPath(backend.binary)) {
|
|
1716
|
+
console.error(binaryMissingHint(agent));
|
|
1717
|
+
exit(1);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
const record = validated.record;
|
|
1722
|
+
const prior = snapshotPriorOutcomeFn(cwd, slug, record);
|
|
1723
|
+
const { logPath } = jobPaths(cwd, slug);
|
|
1724
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
1725
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
1726
|
+
|
|
1727
|
+
const childArgs = [
|
|
1728
|
+
__filename,
|
|
1729
|
+
'resume',
|
|
1730
|
+
slug,
|
|
1731
|
+
'--agent',
|
|
1732
|
+
agent,
|
|
1733
|
+
'--max-rounds',
|
|
1734
|
+
String(maxRounds),
|
|
1735
|
+
];
|
|
1736
|
+
if (verbose) childArgs.push('--verbose');
|
|
1737
|
+
appendNotifyArgs(childArgs, options.notify);
|
|
1738
|
+
|
|
1739
|
+
const child = spawnFn(process.execPath, childArgs, {
|
|
1740
|
+
cwd,
|
|
1741
|
+
env: { ...process.env, ORCH_JOB_SLUG: slug, ORCH_DETACHED: '1' },
|
|
1742
|
+
detached: true,
|
|
1743
|
+
stdio: ['ignore', logFd, logFd],
|
|
1744
|
+
});
|
|
1745
|
+
child.unref();
|
|
1746
|
+
|
|
1747
|
+
reopenForResumeFn(cwd, slug, {
|
|
1748
|
+
agent,
|
|
1749
|
+
maxRounds,
|
|
1750
|
+
pid: child.pid,
|
|
1751
|
+
prior,
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
// Recover runs in the child; parent only prints the resume line.
|
|
1755
|
+
console.log(`resumed ${slug} (pid ${child.pid})`);
|
|
1756
|
+
exit(0);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* The `--worker <parent>:<workerId>` driver: skips triage and runs research → planner →
|
|
1761
|
+
* worktree (from the fan-out's recorded `base`) → test loop → code loop (writer-first) →
|
|
1762
|
+
* commit, exactly like `runPipeline` minus triage. `prompt` is the worker's subtask text
|
|
1763
|
+
* with the envelope already appended by the CLI wiring. On success, patches the parent's
|
|
1764
|
+
* `fanout.json.workers[]` entry to `state:'done'` with `sha`/`changedFiles`; on failure,
|
|
1765
|
+
* patches it (and this job's own `run.json`) to `state:'failed'` before exiting non-zero.
|
|
1766
|
+
*/
|
|
1767
|
+
export async function runWorkerPipeline(prompt, options = {}) {
|
|
1768
|
+
const verbose = Boolean(options.verbose);
|
|
1769
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
1770
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
1771
|
+
if (!backend) {
|
|
1772
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
1773
|
+
}
|
|
1774
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
1775
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1776
|
+
const { parentSlug, workerId, base } = options;
|
|
1777
|
+
|
|
1778
|
+
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
1779
|
+
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
1780
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
1781
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
1782
|
+
const patchWorkerFn = options.patchWorker ?? patchWorker;
|
|
1783
|
+
const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
|
|
1784
|
+
const execFileFn = options.execFile;
|
|
1785
|
+
|
|
1786
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
1787
|
+
const jobCwd = options.jobCwd ?? cwd;
|
|
1788
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
1789
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
1790
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
1791
|
+
|
|
1792
|
+
const jobPatch = (fields) => {
|
|
1793
|
+
if (!jobSlug) return;
|
|
1794
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
1795
|
+
};
|
|
1796
|
+
const jobCheckpoint = async () => {
|
|
1797
|
+
if (!jobSlug) return;
|
|
1798
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
1799
|
+
};
|
|
1800
|
+
|
|
1801
|
+
if (!options.AgentClass) {
|
|
1802
|
+
ensureBinaryOnPath(backend.binary, options.agent);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
try {
|
|
1806
|
+
const runContext = createRunContextFn(jobSlug ? { cwd, slug: jobSlug } : { cwd });
|
|
1807
|
+
|
|
1808
|
+
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
1809
|
+
const research = researchAgentArgs({ prompt, cwd, researchPath: runContext.researchPath });
|
|
1810
|
+
const researchAgent = new AgentClass(
|
|
1811
|
+
research.name,
|
|
1812
|
+
research.instructions,
|
|
1813
|
+
research.prompt,
|
|
1814
|
+
research.options,
|
|
1815
|
+
);
|
|
1816
|
+
const researchResult = await researchAgent.run({ verbose });
|
|
1817
|
+
await jobCheckpoint();
|
|
1818
|
+
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
1819
|
+
printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
|
|
1820
|
+
|
|
1821
|
+
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
1822
|
+
const planner = plannerAgentArgs({
|
|
1823
|
+
prompt,
|
|
1824
|
+
cwd,
|
|
1825
|
+
researchPath: runContext.researchPath,
|
|
1826
|
+
taskPath: runContext.taskPath,
|
|
1827
|
+
researchOutput: researchContent,
|
|
1828
|
+
});
|
|
1829
|
+
const plannerAgent = new AgentClass(
|
|
1830
|
+
planner.name,
|
|
1831
|
+
planner.instructions,
|
|
1832
|
+
planner.prompt,
|
|
1833
|
+
planner.options,
|
|
1834
|
+
);
|
|
1835
|
+
const plannerResult = await plannerAgent.run({ verbose });
|
|
1836
|
+
await jobCheckpoint();
|
|
1837
|
+
const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
1838
|
+
printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
|
|
1839
|
+
|
|
1840
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
1841
|
+
const worktree = createWorktreeFn({ cwd, slug: runContext.slug, base });
|
|
1842
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
1843
|
+
|
|
1844
|
+
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
1845
|
+
fs.writeFileSync(
|
|
1846
|
+
runContext.statusPath,
|
|
1847
|
+
`# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n- Parent: \`${parentSlug}\`\n- Worker: \`${workerId}\`\n`,
|
|
1848
|
+
);
|
|
1849
|
+
|
|
1850
|
+
const testAccepted = await runTestLoop({
|
|
1851
|
+
prompt,
|
|
1852
|
+
worktreePath: worktree.worktreePath,
|
|
1853
|
+
branch: worktree.branch,
|
|
1854
|
+
taskPath: runContext.taskPath,
|
|
1855
|
+
statusPath: runContext.statusPath,
|
|
1856
|
+
maxRounds,
|
|
1857
|
+
AgentClass,
|
|
1858
|
+
verbose,
|
|
1859
|
+
jobPatch,
|
|
1860
|
+
jobCheckpoint,
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
|
|
1864
|
+
.filter(Boolean)
|
|
1865
|
+
.join('\n');
|
|
1866
|
+
|
|
1867
|
+
const codeAccepted = await runCodeLoop({
|
|
1868
|
+
prompt,
|
|
1869
|
+
worktreePath: worktree.worktreePath,
|
|
1870
|
+
branch: worktree.branch,
|
|
1871
|
+
taskPath: runContext.taskPath,
|
|
1872
|
+
statusPath: runContext.statusPath,
|
|
1873
|
+
maxRounds,
|
|
1874
|
+
AgentClass,
|
|
1875
|
+
verbose,
|
|
1876
|
+
jobPatch,
|
|
1877
|
+
jobCheckpoint,
|
|
1878
|
+
acceptedVerification,
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
1882
|
+
const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
|
|
1883
|
+
const worktreeChanges = collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
|
|
1884
|
+
printFilesChanged(worktreeChanges);
|
|
1885
|
+
const commitResult = commitWorktreeFn({
|
|
1886
|
+
worktreePath: worktree.worktreePath,
|
|
1887
|
+
branch: worktree.branch,
|
|
1888
|
+
message,
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
if (commitResult.committed) {
|
|
1892
|
+
fs.appendFileSync(
|
|
1893
|
+
runContext.statusPath,
|
|
1894
|
+
`\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
|
|
1895
|
+
);
|
|
1896
|
+
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
1897
|
+
} else {
|
|
1898
|
+
fs.appendFileSync(
|
|
1899
|
+
runContext.statusPath,
|
|
1900
|
+
`\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
|
|
1901
|
+
);
|
|
1902
|
+
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
let changedFiles = [];
|
|
1906
|
+
try {
|
|
1907
|
+
changedFiles = recordChangedFilesFn({
|
|
1908
|
+
repoRoot: worktree.repoRoot,
|
|
1909
|
+
base,
|
|
1910
|
+
branch: worktree.branch,
|
|
1911
|
+
execFile: execFileFn,
|
|
1912
|
+
});
|
|
1913
|
+
} catch {
|
|
1914
|
+
// Best-effort: changedFiles is informational only, never masks a successful commit.
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
patchWorkerFn(cwd, parentSlug, workerId, {
|
|
1918
|
+
state: 'done',
|
|
1919
|
+
sha: commitResult.sha,
|
|
1920
|
+
changedFiles,
|
|
1921
|
+
});
|
|
1922
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1923
|
+
state: 'done',
|
|
1924
|
+
exitCode: 0,
|
|
1925
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
1926
|
+
error: null,
|
|
1927
|
+
task: prompt,
|
|
1928
|
+
});
|
|
1929
|
+
} catch (err) {
|
|
1930
|
+
console.error(`Error: ${err.message}`);
|
|
1931
|
+
try {
|
|
1932
|
+
patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
|
|
1933
|
+
} catch {
|
|
1934
|
+
// Best-effort: don't let a fanout-state write failure mask the real error.
|
|
1935
|
+
}
|
|
1936
|
+
if (jobSlug) {
|
|
1937
|
+
try {
|
|
1938
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1939
|
+
state: 'failed',
|
|
1940
|
+
exitCode: 1,
|
|
1941
|
+
summary: '',
|
|
1942
|
+
error: err.message,
|
|
1943
|
+
task: prompt,
|
|
1944
|
+
});
|
|
1945
|
+
} catch {
|
|
1946
|
+
// Best-effort: don't let a job-state write failure mask the real error.
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
console.error(`next: orch resume ${jobSlug ?? '<slug>'}`);
|
|
1950
|
+
if (parentSlug) console.error(` orch --integrate ${parentSlug}`);
|
|
1951
|
+
process.exit(1);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* The `--unit <parent>:<unitId>` driver: skips triage and runs research → planner →
|
|
1957
|
+
* worktree (from the seq tip) → test loop → code loop → commit. On success patches
|
|
1958
|
+
* `seq.json.units[]` to `done` with sha/changedFiles/slug; on failure, `failed` then exit 1.
|
|
1959
|
+
*/
|
|
1960
|
+
export async function runUnitPipeline(prompt, options = {}) {
|
|
1961
|
+
const verbose = Boolean(options.verbose);
|
|
1962
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
1963
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
1964
|
+
if (!backend) {
|
|
1965
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
1966
|
+
}
|
|
1967
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
1968
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1969
|
+
const { parentSlug, unitId, base } = options;
|
|
1970
|
+
const exitFn = options.exit ?? ((code) => process.exit(code));
|
|
1971
|
+
|
|
1972
|
+
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
1973
|
+
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
1974
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
1975
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
1976
|
+
const patchUnitFn = options.patchUnit ?? patchUnit;
|
|
1977
|
+
const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
|
|
1978
|
+
const execFileFn = options.execFile;
|
|
1979
|
+
|
|
1980
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
1981
|
+
const jobCwd = options.jobCwd ?? cwd;
|
|
1982
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
1983
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
1984
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
1985
|
+
|
|
1986
|
+
const jobPatch = (fields) => {
|
|
1987
|
+
if (!jobSlug) return;
|
|
1988
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
1989
|
+
};
|
|
1990
|
+
const jobCheckpoint = async () => {
|
|
1991
|
+
if (!jobSlug) return;
|
|
1992
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
1993
|
+
};
|
|
1994
|
+
|
|
1995
|
+
if (!options.AgentClass) {
|
|
1996
|
+
ensureBinaryOnPath(backend.binary, options.agent);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
try {
|
|
2000
|
+
const runContext = createRunContextFn(jobSlug ? { cwd, slug: jobSlug } : { cwd });
|
|
2001
|
+
|
|
2002
|
+
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
2003
|
+
const research = researchAgentArgs({ prompt, cwd, researchPath: runContext.researchPath });
|
|
2004
|
+
const researchAgent = new AgentClass(
|
|
2005
|
+
research.name,
|
|
2006
|
+
research.instructions,
|
|
2007
|
+
research.prompt,
|
|
2008
|
+
research.options,
|
|
2009
|
+
);
|
|
2010
|
+
const researchResult = await researchAgent.run({ verbose });
|
|
2011
|
+
if (!researchResult.ok) throw researchResult.error ?? new Error('research failed');
|
|
2012
|
+
await jobCheckpoint();
|
|
2013
|
+
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
2014
|
+
printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
|
|
2015
|
+
|
|
2016
|
+
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
2017
|
+
const planner = plannerAgentArgs({
|
|
2018
|
+
prompt,
|
|
2019
|
+
cwd,
|
|
2020
|
+
researchPath: runContext.researchPath,
|
|
2021
|
+
taskPath: runContext.taskPath,
|
|
2022
|
+
researchOutput: researchContent,
|
|
2023
|
+
});
|
|
2024
|
+
const plannerAgent = new AgentClass(
|
|
2025
|
+
planner.name,
|
|
2026
|
+
planner.instructions,
|
|
2027
|
+
planner.prompt,
|
|
2028
|
+
planner.options,
|
|
2029
|
+
);
|
|
2030
|
+
const plannerResult = await plannerAgent.run({ verbose });
|
|
2031
|
+
await jobCheckpoint();
|
|
2032
|
+
const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
2033
|
+
printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
|
|
2034
|
+
|
|
2035
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
2036
|
+
const worktree = await createWorktreeFn({ cwd, slug: runContext.slug, base });
|
|
2037
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
2038
|
+
|
|
2039
|
+
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
2040
|
+
fs.writeFileSync(
|
|
2041
|
+
runContext.statusPath,
|
|
2042
|
+
`# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n- Parent: \`${parentSlug}\`\n- Worker: \`${unitId}\`\n`,
|
|
2043
|
+
);
|
|
2044
|
+
|
|
2045
|
+
const testAccepted = await runTestLoop({
|
|
2046
|
+
prompt,
|
|
2047
|
+
worktreePath: worktree.worktreePath,
|
|
2048
|
+
branch: worktree.branch,
|
|
2049
|
+
taskPath: runContext.taskPath,
|
|
2050
|
+
statusPath: runContext.statusPath,
|
|
2051
|
+
maxRounds,
|
|
2052
|
+
AgentClass,
|
|
2053
|
+
verbose,
|
|
2054
|
+
jobPatch,
|
|
2055
|
+
jobCheckpoint,
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
|
|
2059
|
+
.filter(Boolean)
|
|
2060
|
+
.join('\n');
|
|
2061
|
+
|
|
2062
|
+
const codeAccepted = await runCodeLoop({
|
|
2063
|
+
prompt,
|
|
2064
|
+
worktreePath: worktree.worktreePath,
|
|
2065
|
+
branch: worktree.branch,
|
|
2066
|
+
taskPath: runContext.taskPath,
|
|
2067
|
+
statusPath: runContext.statusPath,
|
|
2068
|
+
maxRounds,
|
|
2069
|
+
AgentClass,
|
|
2070
|
+
verbose,
|
|
2071
|
+
jobPatch,
|
|
2072
|
+
jobCheckpoint,
|
|
2073
|
+
acceptedVerification,
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
2077
|
+
const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
|
|
2078
|
+
const worktreeChanges = await collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
|
|
2079
|
+
printFilesChanged(worktreeChanges);
|
|
2080
|
+
const commitResult = await commitWorktreeFn({
|
|
2081
|
+
worktreePath: worktree.worktreePath,
|
|
2082
|
+
branch: worktree.branch,
|
|
2083
|
+
message,
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
if (commitResult.committed) {
|
|
2087
|
+
fs.appendFileSync(
|
|
2088
|
+
runContext.statusPath,
|
|
2089
|
+
`\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
|
|
2090
|
+
);
|
|
2091
|
+
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
2092
|
+
} else if (commitResult.sha) {
|
|
2093
|
+
console.log(`commit: ${String(commitResult.sha).slice(0, 7)} on ${worktree.branch}`);
|
|
2094
|
+
} else {
|
|
2095
|
+
fs.appendFileSync(
|
|
2096
|
+
runContext.statusPath,
|
|
2097
|
+
`\n## Commit\n\n- No changes to commit on \`${worktree.branch}\`.\n`,
|
|
2098
|
+
);
|
|
2099
|
+
console.log(`commit: no changes on ${worktree.branch}`);
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
let changedFiles = [];
|
|
2103
|
+
try {
|
|
2104
|
+
changedFiles = recordChangedFilesFn({
|
|
2105
|
+
repoRoot: worktree.repoRoot ?? cwd,
|
|
2106
|
+
base,
|
|
2107
|
+
branch: worktree.branch,
|
|
2108
|
+
execFile: execFileFn,
|
|
2109
|
+
});
|
|
2110
|
+
} catch {
|
|
2111
|
+
// Best-effort: changedFiles is informational only.
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
patchUnitFn(cwd, parentSlug, unitId, {
|
|
2115
|
+
state: 'done',
|
|
2116
|
+
sha: commitResult.sha,
|
|
2117
|
+
changedFiles,
|
|
2118
|
+
slug: runContext.slug,
|
|
2119
|
+
});
|
|
2120
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
2121
|
+
state: 'done',
|
|
2122
|
+
exitCode: 0,
|
|
2123
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
2124
|
+
error: null,
|
|
2125
|
+
task: prompt,
|
|
2126
|
+
});
|
|
2127
|
+
} catch (err) {
|
|
2128
|
+
console.error(`Error: ${err.message}`);
|
|
2129
|
+
try {
|
|
2130
|
+
patchUnitFn(cwd, parentSlug, unitId, { state: 'failed' });
|
|
2131
|
+
} catch {
|
|
2132
|
+
// Best-effort.
|
|
2133
|
+
}
|
|
2134
|
+
if (jobSlug) {
|
|
2135
|
+
try {
|
|
2136
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
2137
|
+
state: 'failed',
|
|
2138
|
+
exitCode: 1,
|
|
2139
|
+
summary: '',
|
|
2140
|
+
error: err.message,
|
|
2141
|
+
task: prompt,
|
|
2142
|
+
});
|
|
2143
|
+
} catch {
|
|
2144
|
+
// Best-effort.
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
console.error(`next: orch resume ${jobSlug ?? '<slug>'}`);
|
|
2148
|
+
if (parentSlug) console.error(` orch --seq-continue ${parentSlug}`);
|
|
2149
|
+
exitFn(1);
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/**
|
|
2154
|
+
* Merge one unit branch into `orch/<parentSlug>`, repair conflicts once via
|
|
2155
|
+
* integrator, runner-first verify, then advance `seq.tip`. Merge/verify failure
|
|
2156
|
+
* marks the unit `failed` and exits non-zero.
|
|
2157
|
+
*/
|
|
2158
|
+
export async function mergeOneUnit(options = {}) {
|
|
2159
|
+
const verbose = Boolean(options.verbose);
|
|
2160
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
2161
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
2162
|
+
if (!backend) {
|
|
2163
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
2164
|
+
}
|
|
2165
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
2166
|
+
const cwd = options.cwd ?? process.cwd();
|
|
2167
|
+
const { parentSlug, unitId, unitBranch } = options;
|
|
2168
|
+
const exitFn = options.exit ?? ((code) => process.exit(code));
|
|
2169
|
+
const logFn = options.log ?? ((line) => console.log(line));
|
|
2170
|
+
|
|
2171
|
+
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
2172
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
2173
|
+
const readSeqFn = options.readSeq ?? readSeq;
|
|
2174
|
+
const patchUnitFn = options.patchUnit ?? patchUnit;
|
|
2175
|
+
const patchTipFn = options.patchTip ?? patchTip;
|
|
2176
|
+
const mergeBranchesFn = options.mergeBranches ?? mergeBranches;
|
|
2177
|
+
const abortMergeFn = options.abortMerge ?? abortMerge;
|
|
2178
|
+
const conflictedFilesFn = options.conflictedFiles ?? conflictedFiles;
|
|
2179
|
+
const hasConflictMarkersFn = options.hasConflictMarkers ?? hasConflictMarkers;
|
|
2180
|
+
const execFileFn = options.execFile ?? defaultExecFile;
|
|
2181
|
+
|
|
2182
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
2183
|
+
const jobCwd = options.jobCwd ?? cwd;
|
|
2184
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
2185
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
2186
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
2187
|
+
|
|
2188
|
+
const jobPatch = (fields) => {
|
|
2189
|
+
if (!jobSlug) return;
|
|
2190
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
2191
|
+
};
|
|
2192
|
+
const jobCheckpoint = async () => {
|
|
2193
|
+
if (!jobSlug) return;
|
|
2194
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2197
|
+
const failUnit = async (message) => {
|
|
2198
|
+
if (message) console.error(`Error: ${message}`);
|
|
2199
|
+
try {
|
|
2200
|
+
patchUnitFn(cwd, parentSlug, unitId, { state: 'failed' });
|
|
2201
|
+
} catch {
|
|
2202
|
+
// Best-effort.
|
|
2203
|
+
}
|
|
2204
|
+
exitFn(1);
|
|
2205
|
+
};
|
|
2206
|
+
|
|
2207
|
+
try {
|
|
2208
|
+
const seq = readSeqFn(cwd, parentSlug);
|
|
2209
|
+
if (!seq) {
|
|
2210
|
+
await failUnit(`unknown parent ${parentSlug} (no seq.json found)`);
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
jobPatch({ phase: 'merge', stage: 'merge', round: null });
|
|
2215
|
+
|
|
2216
|
+
const reuseWorktreePath = `${cwd}-${parentSlug}`;
|
|
2217
|
+
const expectedBranch = `orch/${parentSlug}`;
|
|
2218
|
+
let worktree = null;
|
|
2219
|
+
|
|
2220
|
+
if (fs.existsSync(reuseWorktreePath)) {
|
|
2221
|
+
try {
|
|
2222
|
+
const currentBranch = execFileFn('git', ['-C', reuseWorktreePath, 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
2223
|
+
if (currentBranch === expectedBranch) {
|
|
2224
|
+
worktree = { repoRoot: cwd, worktreePath: reuseWorktreePath, branch: expectedBranch };
|
|
2225
|
+
}
|
|
2226
|
+
} catch {
|
|
2227
|
+
// Fall through to create.
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
if (!worktree) {
|
|
2232
|
+
worktree = await createWorktreeFn({
|
|
2233
|
+
cwd,
|
|
2234
|
+
slug: parentSlug,
|
|
2235
|
+
base: seq.tip || seq.base,
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
2240
|
+
|
|
2241
|
+
const mergeResult = await mergeBranchesFn({
|
|
2242
|
+
cwd: worktree.worktreePath,
|
|
2243
|
+
candidates: [unitBranch],
|
|
2244
|
+
merged: [],
|
|
2245
|
+
overlappingFiles: [],
|
|
2246
|
+
execFile: execFileFn,
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
let conflicts = [];
|
|
2250
|
+
let mergedOk = false;
|
|
2251
|
+
let mergeOutput = '';
|
|
2252
|
+
if (Array.isArray(mergeResult)) {
|
|
2253
|
+
conflicts = mergeResult.filter((r) => r.status === 'conflict').map((r) => r.branch);
|
|
2254
|
+
mergedOk = mergeResult.some((r) => r.status === 'merged');
|
|
2255
|
+
mergeOutput = mergeResult.find((r) => r.status === 'conflict')?.output ?? '';
|
|
2256
|
+
} else {
|
|
2257
|
+
conflicts = mergeResult?.conflicts ?? [];
|
|
2258
|
+
mergedOk = (mergeResult?.merged ?? []).includes(unitBranch) || (mergeResult?.merged ?? []).length > 0;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
if (conflicts.length > 0) {
|
|
2262
|
+
jobPatch({ phase: 'merge', stage: 'integrator', round: null });
|
|
2263
|
+
const conflicted = conflictedFilesFn({ cwd: worktree.worktreePath, execFile: execFileFn });
|
|
2264
|
+
const integratorArgs = integratorAgentArgs({
|
|
2265
|
+
prompt: `Resolve the merge conflict from combining \`${unitBranch}\` into the seq integration branch for "${seq.task}".`,
|
|
2266
|
+
cwd: worktree.worktreePath,
|
|
2267
|
+
conflictedFiles: conflicted,
|
|
2268
|
+
mergeOutput,
|
|
2269
|
+
involvedWorkers: [{ id: unitId, title: unitId, subtask: unitId, area: '' }],
|
|
2270
|
+
});
|
|
2271
|
+
const integratorAgent = new AgentClass(
|
|
2272
|
+
'integrator',
|
|
2273
|
+
integratorArgs.instructions,
|
|
2274
|
+
integratorArgs.prompt,
|
|
2275
|
+
integratorArgs.options,
|
|
2276
|
+
);
|
|
2277
|
+
|
|
2278
|
+
let integratorOk = false;
|
|
2279
|
+
try {
|
|
2280
|
+
const integratorOut = await integratorAgent.run({ verbose });
|
|
2281
|
+
await jobCheckpoint();
|
|
2282
|
+
integratorOk = Boolean(integratorOut.ok);
|
|
2283
|
+
} catch {
|
|
2284
|
+
integratorOk = false;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
const stillConflicted = integratorOk
|
|
2288
|
+
? hasConflictMarkersFn({ cwd: worktree.worktreePath, execFile: execFileFn })
|
|
2289
|
+
: true;
|
|
2290
|
+
|
|
2291
|
+
if (!stillConflicted) {
|
|
2292
|
+
try {
|
|
2293
|
+
execFileFn('git', ['-C', worktree.worktreePath, 'commit']);
|
|
2294
|
+
} catch {
|
|
2295
|
+
// May already be committed by integrator.
|
|
2296
|
+
}
|
|
2297
|
+
mergedOk = true;
|
|
2298
|
+
} else {
|
|
2299
|
+
try {
|
|
2300
|
+
abortMergeFn({ cwd: worktree.worktreePath, execFile: execFileFn });
|
|
2301
|
+
} catch {
|
|
2302
|
+
// Best-effort.
|
|
2303
|
+
}
|
|
2304
|
+
await failUnit(`merge conflict for ${unitId} could not be repaired`);
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
if (!mergedOk && conflicts.length === 0) {
|
|
2310
|
+
await failUnit(`failed to merge ${unitBranch}`);
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
const artifactDir = path.join(jobCwd, '.orch', parentSlug);
|
|
2315
|
+
await runCodeLoop({
|
|
2316
|
+
prompt: seq.task,
|
|
2317
|
+
worktreePath: worktree.worktreePath,
|
|
2318
|
+
branch: worktree.branch,
|
|
2319
|
+
taskPath: path.join(artifactDir, 'task.md'),
|
|
2320
|
+
statusPath: path.join(artifactDir, 'status.md'),
|
|
2321
|
+
maxRounds,
|
|
2322
|
+
AgentClass,
|
|
2323
|
+
verbose,
|
|
2324
|
+
jobPatch,
|
|
2325
|
+
jobCheckpoint,
|
|
2326
|
+
acceptedVerification: '',
|
|
2327
|
+
runnerFirst: true,
|
|
2328
|
+
loopTitle: 'Verify loop',
|
|
2329
|
+
});
|
|
2330
|
+
|
|
2331
|
+
const commitResult = await commitWorktreeFn({
|
|
2332
|
+
worktreePath: worktree.worktreePath,
|
|
2333
|
+
branch: worktree.branch,
|
|
2334
|
+
message: `orch: merge ${unitId} into ${parentSlug}`,
|
|
2335
|
+
});
|
|
2336
|
+
|
|
2337
|
+
let tipSha = commitResult?.sha;
|
|
2338
|
+
if (!tipSha) {
|
|
2339
|
+
tipSha = execFileFn('git', ['-C', worktree.worktreePath, 'rev-parse', 'HEAD']).trim();
|
|
2340
|
+
}
|
|
2341
|
+
patchTipFn(cwd, parentSlug, tipSha);
|
|
2342
|
+
logFn(`merged ${unitId} → tip ${String(tipSha).slice(0, 7)}`);
|
|
2343
|
+
} catch (err) {
|
|
2344
|
+
await failUnit(err.message);
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
/**
|
|
2349
|
+
* The `--integrate <parent>` driver: reuses (or creates) the integration worktree keyed
|
|
2350
|
+
* by the parent slug, merges `fanout.integration.candidates` in order (repairing conflicts
|
|
2351
|
+
* via the `integrator` agent, one conflict at a time), then runs a runner-first verify
|
|
2352
|
+
* loop and commits on green. Never invokes triage/research/planner/test-writer/test-critic.
|
|
2353
|
+
* Appends every step to `.orch/<job-slug>/integration.md` as it happens.
|
|
2354
|
+
*/
|
|
2355
|
+
export async function runIntegratePipeline(options = {}) {
|
|
2356
|
+
const verbose = Boolean(options.verbose);
|
|
2357
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
2358
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
2359
|
+
if (!backend) {
|
|
2360
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
2361
|
+
}
|
|
2362
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
2363
|
+
const cwd = options.cwd ?? process.cwd();
|
|
2364
|
+
const { parentSlug } = options;
|
|
2365
|
+
|
|
2366
|
+
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
2367
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
2368
|
+
const readFanoutFn = options.readFanout ?? readFanout;
|
|
2369
|
+
const patchIntegrationFn = options.patchIntegration ?? patchIntegration;
|
|
2370
|
+
const mergeBranchesFn = options.mergeBranches ?? mergeBranches;
|
|
2371
|
+
const abortMergeFn = options.abortMerge ?? abortMerge;
|
|
2372
|
+
const conflictedFilesFn = options.conflictedFiles ?? conflictedFiles;
|
|
2373
|
+
const hasConflictMarkersFn = options.hasConflictMarkers ?? hasConflictMarkers;
|
|
2374
|
+
const execFileFn = options.execFile ?? defaultExecFile;
|
|
2375
|
+
|
|
2376
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
2377
|
+
const jobCwd = options.jobCwd ?? cwd;
|
|
2378
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
2379
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
2380
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
2381
|
+
|
|
2382
|
+
const jobPatch = (fields) => {
|
|
2383
|
+
if (!jobSlug) return;
|
|
2384
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
2385
|
+
};
|
|
2386
|
+
const jobCheckpoint = async () => {
|
|
2387
|
+
if (!jobSlug) return;
|
|
2388
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
2389
|
+
};
|
|
2390
|
+
|
|
2391
|
+
if (!options.AgentClass) {
|
|
2392
|
+
ensureBinaryOnPath(backend.binary, options.agent);
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
const fanout = readFanoutFn(cwd, parentSlug);
|
|
2396
|
+
if (!fanout) {
|
|
2397
|
+
console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
|
|
2398
|
+
process.exit(1);
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
const integrationSlug = jobSlug ?? parentSlug;
|
|
2403
|
+
const integrationDir = path.join(jobCwd, '.orch', integrationSlug);
|
|
2404
|
+
const integrationMdPath = path.join(integrationDir, 'integration.md');
|
|
2405
|
+
const logIntegration = (line) => {
|
|
2406
|
+
fs.mkdirSync(integrationDir, { recursive: true });
|
|
2407
|
+
fs.appendFileSync(integrationMdPath, `${line}\n`);
|
|
2408
|
+
};
|
|
2409
|
+
|
|
2410
|
+
let merged = [...fanout.integration.merged];
|
|
2411
|
+
let skipped = [...(fanout.integration.skipped ?? [])];
|
|
2412
|
+
|
|
2413
|
+
try {
|
|
2414
|
+
fs.mkdirSync(integrationDir, { recursive: true });
|
|
2415
|
+
fs.appendFileSync(integrationMdPath, `# Integration: ${parentSlug}\n\n`);
|
|
2416
|
+
|
|
2417
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
2418
|
+
|
|
2419
|
+
const reuseWorktreePath = `${cwd}-${parentSlug}`;
|
|
2420
|
+
const expectedBranch = `orch/${parentSlug}`;
|
|
2421
|
+
let worktree = null;
|
|
2422
|
+
|
|
2423
|
+
if (fs.existsSync(reuseWorktreePath)) {
|
|
2424
|
+
const currentBranch = execFileFn('git', ['-C', reuseWorktreePath, 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
2425
|
+
if (currentBranch === expectedBranch) {
|
|
2426
|
+
worktree = { repoRoot: cwd, worktreePath: reuseWorktreePath, branch: expectedBranch };
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
const reused = Boolean(worktree);
|
|
2431
|
+
if (!worktree) {
|
|
2432
|
+
worktree = createWorktreeFn({ cwd, slug: parentSlug, base: fanout.base });
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
logIntegration(
|
|
2436
|
+
reused
|
|
2437
|
+
? `- Reused existing worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`
|
|
2438
|
+
: `- Created worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`,
|
|
2439
|
+
);
|
|
2440
|
+
|
|
2441
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
1129
2442
|
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
1130
2443
|
worktree: current.worktree ?? worktree.worktreePath,
|
|
1131
2444
|
branch: current.branch ?? worktree.branch,
|
|
1132
2445
|
}));
|
|
1133
2446
|
|
|
1134
|
-
let remaining = fanout.integration.candidates.filter(
|
|
1135
|
-
(branch) => !merged.includes(branch) && !skipped.includes(branch),
|
|
2447
|
+
let remaining = fanout.integration.candidates.filter(
|
|
2448
|
+
(branch) => !merged.includes(branch) && !skipped.includes(branch),
|
|
2449
|
+
);
|
|
2450
|
+
|
|
2451
|
+
while (remaining.length > 0) {
|
|
2452
|
+
const results = mergeBranchesFn({
|
|
2453
|
+
cwd: worktree.worktreePath,
|
|
2454
|
+
candidates: remaining,
|
|
2455
|
+
merged,
|
|
2456
|
+
overlappingFiles: fanout.integration.overlappingFiles,
|
|
2457
|
+
execFile: execFileFn,
|
|
2458
|
+
});
|
|
2459
|
+
|
|
2460
|
+
for (const result of results) {
|
|
2461
|
+
if (result.status === 'skipped') continue;
|
|
2462
|
+
|
|
2463
|
+
if (result.status === 'merged') {
|
|
2464
|
+
merged.push(result.branch);
|
|
2465
|
+
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
2466
|
+
merged: [...current.merged, result.branch],
|
|
2467
|
+
}));
|
|
2468
|
+
logIntegration(`- Merged \`${result.branch}\` cleanly.`);
|
|
2469
|
+
continue;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// status === 'conflict'
|
|
2473
|
+
logIntegration(`- Conflict merging \`${result.branch}\`; entering repair.`);
|
|
2474
|
+
patchIntegrationFn(cwd, parentSlug, { state: 'repairing' });
|
|
2475
|
+
|
|
2476
|
+
const conflicted = conflictedFilesFn({ cwd: worktree.worktreePath, execFile: execFileFn });
|
|
2477
|
+
const involvedWorkers = fanout.workers
|
|
2478
|
+
.filter((worker) => worker.branch === result.branch)
|
|
2479
|
+
.map(({ id, title, subtask, area }) => ({ id, title, subtask, area }));
|
|
2480
|
+
|
|
2481
|
+
jobPatch({ phase: 'integrate', stage: 'integrator', round: null });
|
|
2482
|
+
const integratorArgs = integratorAgentArgs({
|
|
2483
|
+
prompt: `Resolve the merge conflict from combining \`${result.branch}\` into the integration branch for "${fanout.task}".`,
|
|
2484
|
+
cwd: worktree.worktreePath,
|
|
2485
|
+
conflictedFiles: conflicted,
|
|
2486
|
+
mergeOutput: result.output,
|
|
2487
|
+
involvedWorkers,
|
|
2488
|
+
});
|
|
2489
|
+
const integratorAgent = new AgentClass(
|
|
2490
|
+
'integrator',
|
|
2491
|
+
integratorArgs.instructions,
|
|
2492
|
+
integratorArgs.prompt,
|
|
2493
|
+
integratorArgs.options,
|
|
2494
|
+
);
|
|
2495
|
+
|
|
2496
|
+
let integratorOk = false;
|
|
2497
|
+
try {
|
|
2498
|
+
const integratorOut = await integratorAgent.run({ verbose });
|
|
2499
|
+
await jobCheckpoint();
|
|
2500
|
+
const { content: integratorContent, summary: integratorSummary } = splitStageSummary(integratorOut.result);
|
|
2501
|
+
printStageSummary('integrator', resolveStageSummary('integrator', integratorSummary, integratorContent));
|
|
2502
|
+
integratorOk = Boolean(integratorOut.ok);
|
|
2503
|
+
} catch (err) {
|
|
2504
|
+
logIntegration(`- Integrator agent errored: ${err.message}`);
|
|
2505
|
+
integratorOk = false;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
const stillConflicted = integratorOk
|
|
2509
|
+
? hasConflictMarkersFn({ cwd: worktree.worktreePath, execFile: execFileFn })
|
|
2510
|
+
: true;
|
|
2511
|
+
|
|
2512
|
+
if (!stillConflicted) {
|
|
2513
|
+
execFileFn('git', ['-C', worktree.worktreePath, 'commit']);
|
|
2514
|
+
merged.push(result.branch);
|
|
2515
|
+
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
2516
|
+
merged: [...current.merged, result.branch],
|
|
2517
|
+
}));
|
|
2518
|
+
logIntegration(`- Integrator resolved conflicts in \`${result.branch}\`; merge completed.`);
|
|
2519
|
+
} else {
|
|
2520
|
+
abortMergeFn({ cwd: worktree.worktreePath, execFile: execFileFn });
|
|
2521
|
+
skipped.push(result.branch);
|
|
2522
|
+
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
2523
|
+
skipped: [...(current.skipped ?? []), result.branch],
|
|
2524
|
+
}));
|
|
2525
|
+
logIntegration(`- Conflicts in \`${result.branch}\` remained unresolved; aborted merge and skipped.`);
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
remaining = remaining.slice(results.length);
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
// --- runner-first verify loop: test-runner first, code-writer only on failure ---
|
|
2533
|
+
const codeAccepted = await runCodeLoop({
|
|
2534
|
+
prompt: fanout.task,
|
|
2535
|
+
worktreePath: worktree.worktreePath,
|
|
2536
|
+
branch: worktree.branch,
|
|
2537
|
+
taskPath: path.join(integrationDir, 'task.md'),
|
|
2538
|
+
statusPath: integrationMdPath,
|
|
2539
|
+
maxRounds,
|
|
2540
|
+
AgentClass,
|
|
2541
|
+
verbose,
|
|
2542
|
+
jobPatch,
|
|
2543
|
+
jobCheckpoint,
|
|
2544
|
+
acceptedVerification: '',
|
|
2545
|
+
runnerFirst: true,
|
|
2546
|
+
loopTitle: 'Verify loop',
|
|
2547
|
+
});
|
|
2548
|
+
|
|
2549
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
2550
|
+
const message = `orch: ${parentSlug} ${fanout.task.split('\n')[0]}`;
|
|
2551
|
+
const commitResult = commitWorktreeFn({
|
|
2552
|
+
worktreePath: worktree.worktreePath,
|
|
2553
|
+
branch: `orch/${parentSlug}`,
|
|
2554
|
+
message,
|
|
2555
|
+
});
|
|
2556
|
+
|
|
2557
|
+
logIntegration(
|
|
2558
|
+
commitResult.committed
|
|
2559
|
+
? `- Committed \`${commitResult.sha}\` on \`${commitResult.branch}\`.`
|
|
2560
|
+
: `- No changes to commit on \`${commitResult.branch}\`.`,
|
|
2561
|
+
);
|
|
2562
|
+
|
|
2563
|
+
patchIntegrationFn(cwd, parentSlug, {
|
|
2564
|
+
state: 'done',
|
|
2565
|
+
sha: commitResult.sha,
|
|
2566
|
+
merged,
|
|
2567
|
+
skipped,
|
|
2568
|
+
});
|
|
2569
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
2570
|
+
state: 'done',
|
|
2571
|
+
exitCode: 0,
|
|
2572
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
2573
|
+
error: null,
|
|
2574
|
+
task: fanout.task,
|
|
2575
|
+
});
|
|
2576
|
+
} catch (err) {
|
|
2577
|
+
console.error(`Error: ${err.message}`);
|
|
2578
|
+
try {
|
|
2579
|
+
logIntegration(`- Error: ${err.message}`);
|
|
2580
|
+
} catch {
|
|
2581
|
+
// Best-effort: don't let a log write failure mask the real error.
|
|
2582
|
+
}
|
|
2583
|
+
try {
|
|
2584
|
+
patchIntegrationFn(cwd, parentSlug, { state: 'failed', merged, skipped });
|
|
2585
|
+
} catch {
|
|
2586
|
+
// Best-effort: don't let a fanout-state write failure mask the real error.
|
|
2587
|
+
}
|
|
2588
|
+
if (jobSlug) {
|
|
2589
|
+
try {
|
|
2590
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
2591
|
+
state: 'failed',
|
|
2592
|
+
exitCode: 1,
|
|
2593
|
+
summary: '',
|
|
2594
|
+
error: err.message,
|
|
2595
|
+
task: fanout?.task,
|
|
2596
|
+
});
|
|
2597
|
+
} catch {
|
|
2598
|
+
// Best-effort: don't let a job-state write failure mask the real error.
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
process.exit(1);
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
function sleep(ms) {
|
|
2606
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
/** Sentinel thrown by the coordinator's scheduling loop when a SIGINT/SIGTERM/SIGHUP
|
|
2610
|
+
* cascade fires mid-flight, so the pending awaits unwind without a second exit call. */
|
|
2611
|
+
class FanoutInterrupted extends Error {}
|
|
2612
|
+
|
|
2613
|
+
/**
|
|
2614
|
+
* SIGINT/SIGHUP/SIGTERM cascade: reads `fanout.json`, resolves every worker's and the
|
|
2615
|
+
* integration session's own pid via its `run.json`, filters through `isPidAlive`, and
|
|
2616
|
+
* SIGTERMs each live one. Never touches worktrees. Composes with (does not replace)
|
|
2617
|
+
* `lib/agent.js`'s existing `shutdown()` — the coordinator's own signal handling calls
|
|
2618
|
+
* both this and the normal in-process agent-CLI reaping.
|
|
2619
|
+
*/
|
|
2620
|
+
export function cascadeStopFanoutChildren(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
|
|
2621
|
+
const fanout = readFanout(cwd, parentSlug);
|
|
2622
|
+
if (!fanout) return;
|
|
2623
|
+
|
|
2624
|
+
const slugs = fanout.workers.filter((worker) => worker.slug).map((worker) => worker.slug);
|
|
2625
|
+
if (fanout.integration?.slug) slugs.push(fanout.integration.slug);
|
|
2626
|
+
|
|
2627
|
+
for (const slug of slugs) {
|
|
2628
|
+
const record = readJob(cwd, slug);
|
|
2629
|
+
if (!record) continue;
|
|
2630
|
+
if (isPidAliveFn(record.pid)) {
|
|
2631
|
+
kill(record.pid, 'SIGTERM');
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
/**
|
|
2637
|
+
* SIGINT/SIGHUP/SIGTERM cascade for seq: reads `seq.json` unit slugs and SIGTERMs
|
|
2638
|
+
* live non-terminal children. Never touches worktrees.
|
|
2639
|
+
*/
|
|
2640
|
+
export function cascadeStopSeqChildren(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
|
|
2641
|
+
const seq = readSeq(cwd, parentSlug);
|
|
2642
|
+
if (!seq) return;
|
|
2643
|
+
|
|
2644
|
+
const slugs = (seq.units || []).filter((unit) => unit.slug).map((unit) => unit.slug);
|
|
2645
|
+
for (const slug of slugs) {
|
|
2646
|
+
const record = readJob(cwd, slug);
|
|
2647
|
+
if (!record) continue;
|
|
2648
|
+
if (TERMINAL_JOB_STATES.includes(record.state)) continue;
|
|
2649
|
+
if (isPidAliveFn(record.pid)) {
|
|
2650
|
+
kill(record.pid, 'SIGTERM');
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/**
|
|
2656
|
+
* CLI / management cascade stop: SIGTERM the parent pid if alive, then every
|
|
2657
|
+
* live child pid (via fan-out and/or seq helpers). The coordinator signal
|
|
2658
|
+
* handler keeps calling the child-only helpers so it does not re-signal itself.
|
|
2659
|
+
*/
|
|
2660
|
+
export function cascadeStop(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
|
|
2661
|
+
const parent = readJob(cwd, parentSlug);
|
|
2662
|
+
if (parent && isPidAliveFn(parent.pid)) {
|
|
2663
|
+
kill(parent.pid, 'SIGTERM');
|
|
2664
|
+
}
|
|
2665
|
+
cascadeStopFanoutChildren(cwd, parentSlug, { kill, isPidAlive: isPidAliveFn });
|
|
2666
|
+
cascadeStopSeqChildren(cwd, parentSlug, { kill, isPidAlive: isPidAliveFn });
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
/**
|
|
2670
|
+
* The `--seq` coordinator: triage → seq-decomposer (no boundaries) → schedule
|
|
2671
|
+
* units concurrency 1 → merge+verify after each → hybrid adjust. Decline falls
|
|
2672
|
+
* through to the single-worktree pipeline with no seq.json.
|
|
2673
|
+
*/
|
|
2674
|
+
export async function runSeqPipeline(prompt, options = {}) {
|
|
2675
|
+
const verbose = Boolean(options.verbose);
|
|
2676
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
2677
|
+
const maxUnits = options.maxUnits ?? 8;
|
|
2678
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
2679
|
+
if (!backend) {
|
|
2680
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
2681
|
+
}
|
|
2682
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
2683
|
+
const invocationCwd = options.cwd ?? process.cwd();
|
|
2684
|
+
|
|
2685
|
+
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
2686
|
+
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
2687
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
2688
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
2689
|
+
const spawnFn = options.spawn ?? spawn;
|
|
2690
|
+
const execFileFn = options.execFile ?? defaultExecFile;
|
|
2691
|
+
const allocateJobFn = options.allocateJob ?? allocateJob;
|
|
2692
|
+
const reconcileJobFn = options.reconcileJob ?? reconcileJob;
|
|
2693
|
+
const readSeqFn = options.readSeq ?? readSeq;
|
|
2694
|
+
const writeSeqFn = options.writeSeq ?? writeSeq;
|
|
2695
|
+
const patchUnitFn = options.patchUnit ?? patchUnit;
|
|
2696
|
+
const appendAdjustmentFn = options.appendAdjustment ?? appendAdjustment;
|
|
2697
|
+
const mergeOneUnitFn = options.mergeOneUnit ?? mergeOneUnit;
|
|
2698
|
+
const exitFn = options.exit ?? ((code) => process.exit(code));
|
|
2699
|
+
const pollIntervalMs = options.pollIntervalMs ?? 500;
|
|
2700
|
+
|
|
2701
|
+
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
2702
|
+
const jobCwd = options.jobCwd ?? invocationCwd;
|
|
2703
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
2704
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
2705
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
2706
|
+
|
|
2707
|
+
const jobPatch = (fields) => {
|
|
2708
|
+
if (!jobSlug) return;
|
|
2709
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
2710
|
+
};
|
|
2711
|
+
const jobCheckpoint = async () => {
|
|
2712
|
+
if (!jobSlug) return;
|
|
2713
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
2714
|
+
};
|
|
2715
|
+
|
|
2716
|
+
if (!options.AgentClass) {
|
|
2717
|
+
ensureBinaryOnPath(backend.binary, options.agent);
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
console.log(`cwd: ${invocationCwd}`);
|
|
2721
|
+
console.log(`agent: ${options.agent}`);
|
|
2722
|
+
console.log();
|
|
2723
|
+
|
|
2724
|
+
let interrupted = false;
|
|
2725
|
+
const onSignal = (signal) => {
|
|
2726
|
+
interrupted = true;
|
|
2727
|
+
try {
|
|
2728
|
+
cascadeStopSeqChildren(invocationCwd, jobSlug);
|
|
2729
|
+
} catch {
|
|
2730
|
+
// Best-effort.
|
|
2731
|
+
}
|
|
2732
|
+
if (jobSlug) {
|
|
2733
|
+
try {
|
|
2734
|
+
const exitCode = exitCodeForSignal(signal);
|
|
2735
|
+
const finishedAt = new Date().toISOString();
|
|
2736
|
+
patchJobFn(jobCwd, jobSlug, (current) => ({
|
|
2737
|
+
state: 'stopped',
|
|
2738
|
+
exitCode,
|
|
2739
|
+
finishedAt,
|
|
2740
|
+
lastOutcome: buildLastOutcome({
|
|
2741
|
+
state: 'stopped',
|
|
2742
|
+
phase: current.phase,
|
|
2743
|
+
stage: current.stage,
|
|
2744
|
+
round: current.round,
|
|
2745
|
+
exitCode,
|
|
2746
|
+
finishedAt,
|
|
2747
|
+
task: prompt,
|
|
2748
|
+
summary: '',
|
|
2749
|
+
error: null,
|
|
2750
|
+
}),
|
|
2751
|
+
}));
|
|
2752
|
+
} catch {
|
|
2753
|
+
// Best-effort.
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
exitFn(exitCodeForSignal(signal));
|
|
2757
|
+
};
|
|
2758
|
+
process.on('SIGINT', () => onSignal('SIGINT'));
|
|
2759
|
+
process.on('SIGTERM', () => onSignal('SIGTERM'));
|
|
2760
|
+
process.on('SIGHUP', () => onSignal('SIGHUP'));
|
|
2761
|
+
|
|
2762
|
+
const runDeclinePipeline = async () => {
|
|
2763
|
+
const runContext = createRunContextFn(jobSlug ? { cwd: invocationCwd, slug: jobSlug } : { cwd: invocationCwd });
|
|
2764
|
+
|
|
2765
|
+
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
2766
|
+
const research = researchAgentArgs({ prompt, cwd: invocationCwd, researchPath: runContext.researchPath });
|
|
2767
|
+
const researchAgent = new AgentClass(research.name, research.instructions, research.prompt, research.options);
|
|
2768
|
+
const researchResult = await researchAgent.run({ verbose });
|
|
2769
|
+
await jobCheckpoint();
|
|
2770
|
+
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
2771
|
+
printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
|
|
2772
|
+
|
|
2773
|
+
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
2774
|
+
const planner = plannerAgentArgs({
|
|
2775
|
+
prompt, cwd: invocationCwd, researchPath: runContext.researchPath, taskPath: runContext.taskPath, researchOutput: researchContent,
|
|
2776
|
+
});
|
|
2777
|
+
const plannerAgent = new AgentClass(planner.name, planner.instructions, planner.prompt, planner.options);
|
|
2778
|
+
const plannerResult = await plannerAgent.run({ verbose });
|
|
2779
|
+
await jobCheckpoint();
|
|
2780
|
+
const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
2781
|
+
printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
|
|
2782
|
+
|
|
2783
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
2784
|
+
const worktree = await createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
|
|
2785
|
+
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
2786
|
+
|
|
2787
|
+
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
2788
|
+
fs.writeFileSync(
|
|
2789
|
+
runContext.statusPath,
|
|
2790
|
+
`# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n`,
|
|
1136
2791
|
);
|
|
1137
2792
|
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
2793
|
+
const testAccepted = await runTestLoop({
|
|
2794
|
+
prompt,
|
|
2795
|
+
worktreePath: worktree.worktreePath,
|
|
2796
|
+
branch: worktree.branch,
|
|
2797
|
+
taskPath: runContext.taskPath,
|
|
2798
|
+
statusPath: runContext.statusPath,
|
|
2799
|
+
maxRounds,
|
|
2800
|
+
AgentClass,
|
|
2801
|
+
verbose,
|
|
2802
|
+
jobPatch,
|
|
2803
|
+
jobCheckpoint,
|
|
2804
|
+
});
|
|
1146
2805
|
|
|
1147
|
-
|
|
1148
|
-
if (result.status === 'skipped') continue;
|
|
2806
|
+
const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent].filter(Boolean).join('\n');
|
|
1149
2807
|
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
2808
|
+
await runCodeLoop({
|
|
2809
|
+
prompt,
|
|
2810
|
+
worktreePath: worktree.worktreePath,
|
|
2811
|
+
branch: worktree.branch,
|
|
2812
|
+
taskPath: runContext.taskPath,
|
|
2813
|
+
statusPath: runContext.statusPath,
|
|
2814
|
+
maxRounds,
|
|
2815
|
+
AgentClass,
|
|
2816
|
+
verbose,
|
|
2817
|
+
jobPatch,
|
|
2818
|
+
jobCheckpoint,
|
|
2819
|
+
acceptedVerification,
|
|
2820
|
+
});
|
|
1158
2821
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
2822
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
2823
|
+
const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
|
|
2824
|
+
const worktreeChanges = await collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
|
|
2825
|
+
printFilesChanged(worktreeChanges);
|
|
2826
|
+
const commitResult = await commitWorktreeFn({ worktreePath: worktree.worktreePath, branch: worktree.branch, message });
|
|
1162
2827
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
2828
|
+
if (commitResult.committed) {
|
|
2829
|
+
fs.appendFileSync(
|
|
2830
|
+
runContext.statusPath,
|
|
2831
|
+
`\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
|
|
2832
|
+
);
|
|
2833
|
+
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
2834
|
+
console.log(`merge: git merge ${commitResult.branch}`);
|
|
2835
|
+
} else {
|
|
2836
|
+
fs.appendFileSync(
|
|
2837
|
+
runContext.statusPath,
|
|
2838
|
+
`\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
|
|
2839
|
+
);
|
|
2840
|
+
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
2841
|
+
}
|
|
1167
2842
|
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
2843
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
2844
|
+
exitFn(0);
|
|
2845
|
+
};
|
|
2846
|
+
|
|
2847
|
+
const runAdjust = async (seqDoc) => {
|
|
2848
|
+
const doneUnits = seqDoc.units.filter((u) => u.state === 'done');
|
|
2849
|
+
const pendingUnits = seqDoc.units.filter((u) => u.state === 'pending');
|
|
2850
|
+
if (pendingUnits.length === 0) return seqDoc;
|
|
2851
|
+
|
|
2852
|
+
let feedback;
|
|
2853
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
2854
|
+
jobPatch({ phase: 'adjust', stage: 'adjust', round: attempt });
|
|
2855
|
+
const adjust = adjustAgentArgs({
|
|
2856
|
+
originalTask: prompt,
|
|
2857
|
+
doneUnits,
|
|
2858
|
+
pendingUnits,
|
|
2859
|
+
tip: seqDoc.tip,
|
|
2860
|
+
cwd: invocationCwd,
|
|
2861
|
+
maxUnits,
|
|
2862
|
+
feedback,
|
|
2863
|
+
});
|
|
2864
|
+
const adjustAgent = new AgentClass(adjust.name, adjust.instructions, adjust.prompt, adjust.options);
|
|
2865
|
+
const adjustResult = await adjustAgent.run({ verbose });
|
|
2866
|
+
await jobCheckpoint();
|
|
2867
|
+
const { content: adjustContent, summary: adjustSummary } = splitStageSummary(adjustResult.result);
|
|
2868
|
+
printStageSummary('adjust', resolveStageSummary('adjust', adjustSummary, adjustContent));
|
|
2869
|
+
|
|
2870
|
+
const parsed = parseDecomposition(adjustContent);
|
|
2871
|
+
if (!parsed) {
|
|
2872
|
+
feedback = ['adjust output was not valid JSON'];
|
|
2873
|
+
continue;
|
|
2874
|
+
}
|
|
2875
|
+
const violations = validateAdjustResult(parsed, { units: seqDoc.units, maxUnits });
|
|
2876
|
+
if (violations.length === 0) {
|
|
2877
|
+
const applied = applyAdjustResult(seqDoc, parsed);
|
|
2878
|
+
writeSeqFn(invocationCwd, jobSlug, applied);
|
|
2879
|
+
const rewriteIds = (parsed.rewrites || []).map((r) => r.id);
|
|
2880
|
+
const dropIds = parsed.drops || [];
|
|
2881
|
+
const summaryParts = [];
|
|
2882
|
+
if (rewriteIds.length) summaryParts.push(`rewrote ${rewriteIds.join(', ')}`);
|
|
2883
|
+
if (dropIds.length) summaryParts.push(`dropped ${dropIds.join(', ')}`);
|
|
2884
|
+
const summary = summaryParts.join('; ') || 'no changes';
|
|
2885
|
+
appendAdjustmentFn(invocationCwd, jobSlug, {
|
|
2886
|
+
afterUnitId: doneUnits[doneUnits.length - 1]?.id ?? null,
|
|
2887
|
+
tip: applied.tip,
|
|
2888
|
+
summary,
|
|
1175
2889
|
});
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
2890
|
+
console.log(`adjust: ${summary}`);
|
|
2891
|
+
return applied;
|
|
2892
|
+
}
|
|
2893
|
+
feedback = violations;
|
|
2894
|
+
}
|
|
2895
|
+
console.log('adjust: validation failed after repairs — keeping previous pending list');
|
|
2896
|
+
return seqDoc;
|
|
2897
|
+
};
|
|
1182
2898
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
logIntegration(`- Integrator agent errored: ${err.message}`);
|
|
1192
|
-
integratorOk = false;
|
|
1193
|
-
}
|
|
2899
|
+
const spawnUnitChild = async (unit, tip) => {
|
|
2900
|
+
const envelope = buildUnitEnvelope({
|
|
2901
|
+
id: unit.id,
|
|
2902
|
+
title: unit.title,
|
|
2903
|
+
subtask: unit.subtask,
|
|
2904
|
+
originalTask: prompt,
|
|
2905
|
+
});
|
|
2906
|
+
const unitPrompt = `${unit.subtask}\n\n${envelope}`;
|
|
1194
2907
|
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
2908
|
+
const allocated = await allocateJobFn({
|
|
2909
|
+
cwd: invocationCwd,
|
|
2910
|
+
prompt: unitPrompt,
|
|
2911
|
+
agent: options.agent,
|
|
2912
|
+
maxRounds,
|
|
2913
|
+
state: 'starting',
|
|
2914
|
+
parent: jobSlug,
|
|
2915
|
+
role: 'worker',
|
|
2916
|
+
workerId: unit.id,
|
|
2917
|
+
});
|
|
2918
|
+
const unitSlug = allocated.slug;
|
|
2919
|
+
patchUnitFn(invocationCwd, jobSlug, unit.id, {
|
|
2920
|
+
slug: unitSlug,
|
|
2921
|
+
state: 'running',
|
|
2922
|
+
});
|
|
1198
2923
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
2924
|
+
const { logPath } = jobPaths(invocationCwd, unitSlug);
|
|
2925
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
2926
|
+
const childArgs = [
|
|
2927
|
+
__filename, unitPrompt,
|
|
2928
|
+
'--agent', options.agent,
|
|
2929
|
+
'--max-rounds', String(maxRounds),
|
|
2930
|
+
'--unit', `${jobSlug}:${unit.id}`,
|
|
2931
|
+
];
|
|
2932
|
+
const child = spawnFn(process.execPath, childArgs, {
|
|
2933
|
+
cwd: invocationCwd,
|
|
2934
|
+
env: {
|
|
2935
|
+
...process.env,
|
|
2936
|
+
ORCH_JOB_SLUG: unitSlug,
|
|
2937
|
+
ORCH_DETACHED: '1',
|
|
2938
|
+
ORCH_SEQ_DEPTH: '1',
|
|
2939
|
+
ORCH_FANOUT_DEPTH: '1',
|
|
2940
|
+
},
|
|
2941
|
+
detached: true,
|
|
2942
|
+
stdio: ['ignore', logFd, logFd],
|
|
2943
|
+
});
|
|
2944
|
+
child.unref();
|
|
2945
|
+
patchJobFn(invocationCwd, unitSlug, { pid: child.pid, state: 'running' });
|
|
2946
|
+
console.log(`[${unit.id} ${unitSlug}] running (base ${String(tip).slice(0, 7)})`);
|
|
2947
|
+
return unitSlug;
|
|
2948
|
+
};
|
|
2949
|
+
|
|
2950
|
+
const waitForUnit = async (unitId, unitSlug) => {
|
|
2951
|
+
const spawnedAt = Date.now();
|
|
2952
|
+
for (;;) {
|
|
2953
|
+
if (interrupted) return 'interrupted';
|
|
2954
|
+
await sleep(pollIntervalMs);
|
|
2955
|
+
await jobCheckpoint();
|
|
2956
|
+
|
|
2957
|
+
const seq = readSeqFn(invocationCwd, jobSlug);
|
|
2958
|
+
const unit = seq?.units?.find((u) => u.id === unitId);
|
|
2959
|
+
if (unit && (unit.state === 'done' || unit.state === 'failed' || unit.state === 'skipped')) {
|
|
2960
|
+
return unit.state;
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
const job = reconcileJobFn(invocationCwd, unitSlug, readJob(invocationCwd, unitSlug));
|
|
2964
|
+
if (job && TERMINAL_JOB_STATES.includes(job.state)) {
|
|
2965
|
+
if (unit && unit.state !== 'done' && unit.state !== 'failed') {
|
|
2966
|
+
const nextState = job.state === 'done' ? 'done' : 'failed';
|
|
2967
|
+
patchUnitFn(invocationCwd, jobSlug, unitId, {
|
|
2968
|
+
state: nextState,
|
|
2969
|
+
sha: job.sha ?? null,
|
|
2970
|
+
});
|
|
2971
|
+
return nextState;
|
|
1213
2972
|
}
|
|
2973
|
+
return job.state === 'done' ? 'done' : 'failed';
|
|
1214
2974
|
}
|
|
1215
2975
|
|
|
1216
|
-
|
|
2976
|
+
// Grace period before treating a dead pid as a crash (mirrors fan-out).
|
|
2977
|
+
if (job && !job.pid && Date.now() - spawnedAt > Math.max(pollIntervalMs * 4, 2000)) {
|
|
2978
|
+
patchUnitFn(invocationCwd, jobSlug, unitId, { state: 'failed' });
|
|
2979
|
+
return 'failed';
|
|
2980
|
+
}
|
|
1217
2981
|
}
|
|
2982
|
+
};
|
|
1218
2983
|
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
2984
|
+
try {
|
|
2985
|
+
jobPatch({ phase: 'triage', stage: 'triage', round: null });
|
|
2986
|
+
const triage = triageAgentArgs({ prompt, cwd: invocationCwd });
|
|
2987
|
+
const triageAgent = new AgentClass(triage.name, triage.instructions, triage.prompt, triage.options);
|
|
2988
|
+
const triageResult = await triageAgent.run({ verbose });
|
|
2989
|
+
await jobCheckpoint();
|
|
2990
|
+
const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
|
|
2991
|
+
printStageSummary('triage', resolveStageSummary('triage', triageSummary, triageContent));
|
|
2992
|
+
const parsed = parseTriageJson(triageContent);
|
|
2993
|
+
|
|
2994
|
+
if (parsed?.simple === true) {
|
|
2995
|
+
console.log('triage: simple — seq skipped (quick-fix)');
|
|
2996
|
+
jobPatch({ phase: 'quick-fix', stage: 'quick-fix', round: null });
|
|
2997
|
+
const quickFix = quickFixAgentArgs({ prompt, cwd: invocationCwd, fix_plan: parsed.fix_plan });
|
|
2998
|
+
const quickFixTracker = new FileTracker({ cwd: invocationCwd });
|
|
2999
|
+
const quickFixAgent = new AgentClass(
|
|
3000
|
+
quickFix.name,
|
|
3001
|
+
quickFix.instructions,
|
|
3002
|
+
quickFix.prompt,
|
|
3003
|
+
{ ...quickFix.options, fileTracker: quickFixTracker },
|
|
3004
|
+
);
|
|
3005
|
+
const quickFixResult = await quickFixAgent.run({ verbose });
|
|
3006
|
+
await jobCheckpoint();
|
|
3007
|
+
const { content: quickFixContent, summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
3008
|
+
printStageSummary('quick-fix', resolveStageSummary('quick-fix', quickFixSummary, quickFixContent), quickFixTracker.getFiles());
|
|
3009
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
3010
|
+
exitFn(0);
|
|
3011
|
+
return;
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
console.log('triage: complex — seq requested');
|
|
3015
|
+
|
|
3016
|
+
let feedback;
|
|
3017
|
+
let decision = null;
|
|
3018
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
3019
|
+
jobPatch({ phase: 'decompose', stage: 'seq-decomposer', round: attempt });
|
|
3020
|
+
const decomposer = seqDecomposerAgentArgs({
|
|
3021
|
+
prompt, cwd: invocationCwd, maxUnits, feedback,
|
|
3022
|
+
});
|
|
3023
|
+
const decomposerAgent = new AgentClass(decomposer.name, decomposer.instructions, decomposer.prompt, decomposer.options);
|
|
3024
|
+
const decomposerResult = await decomposerAgent.run({ verbose });
|
|
3025
|
+
await jobCheckpoint();
|
|
3026
|
+
const { content: decomposerContent, summary: decomposerSummary } = splitStageSummary(decomposerResult.result);
|
|
3027
|
+
printStageSummary('seq-decomposer', resolveStageSummary('seq-decomposer', decomposerSummary, decomposerContent));
|
|
3028
|
+
|
|
3029
|
+
const parsedDecomposition = parseDecomposition(decomposerContent);
|
|
3030
|
+
if (!parsedDecomposition) {
|
|
3031
|
+
feedback = ['decomposer output was not valid JSON'];
|
|
3032
|
+
if (attempt === 3) decision = { decline: true, why: 'decomposer did not return valid JSON after repair attempts' };
|
|
3033
|
+
continue;
|
|
3034
|
+
}
|
|
3035
|
+
if (parsedDecomposition.decomposable === false) {
|
|
3036
|
+
decision = { decline: true, why: parsedDecomposition.why };
|
|
3037
|
+
break;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
const violations = validateSeqDecomposition(parsedDecomposition, { maxUnits });
|
|
3041
|
+
if (violations.length === 0) {
|
|
3042
|
+
decision = { decline: false, decomposition: parsedDecomposition };
|
|
3043
|
+
break;
|
|
3044
|
+
}
|
|
3045
|
+
feedback = violations;
|
|
3046
|
+
if (attempt === 3) {
|
|
3047
|
+
decision = { decline: true, why: `decomposition still invalid after repairs: ${violations.join('; ')}` };
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
if (decision.decline) {
|
|
3052
|
+
console.log(`decomposer: declined — ${decision.why}`);
|
|
3053
|
+
console.log('falling through to the single-worktree pipeline');
|
|
3054
|
+
await runDeclinePipeline();
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
const { decomposition } = decision;
|
|
3059
|
+
const base = execFileFn('git', ['-C', invocationCwd, 'rev-parse', 'HEAD']).trim();
|
|
3060
|
+
|
|
3061
|
+
await createWorktreeFn({ cwd: invocationCwd, slug: jobSlug, base });
|
|
3062
|
+
|
|
3063
|
+
const units = decomposition.units.map((unit) => ({
|
|
3064
|
+
id: unit.id,
|
|
3065
|
+
title: unit.title,
|
|
3066
|
+
subtask: unit.subtask,
|
|
3067
|
+
state: 'pending',
|
|
3068
|
+
slug: null,
|
|
3069
|
+
sha: null,
|
|
3070
|
+
changedFiles: null,
|
|
3071
|
+
}));
|
|
3072
|
+
|
|
3073
|
+
writeSeqFn(invocationCwd, jobSlug, {
|
|
3074
|
+
version: 1,
|
|
3075
|
+
parentSlug: jobSlug,
|
|
3076
|
+
task: prompt,
|
|
3077
|
+
base,
|
|
3078
|
+
tip: base,
|
|
3079
|
+
maxUnits,
|
|
3080
|
+
units,
|
|
3081
|
+
adjustments: [],
|
|
3082
|
+
state: 'running',
|
|
3083
|
+
startedAt: new Date().toISOString(),
|
|
3084
|
+
finishedAt: null,
|
|
1234
3085
|
});
|
|
1235
3086
|
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
3087
|
+
console.log(`decomposer: ${units.length} units`);
|
|
3088
|
+
jobPatch({ phase: 'schedule', stage: null, round: null });
|
|
3089
|
+
|
|
3090
|
+
for (;;) {
|
|
3091
|
+
if (interrupted) {
|
|
3092
|
+
exitFn(exitCodeForSignal('SIGINT'));
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
await jobCheckpoint();
|
|
3097
|
+
|
|
3098
|
+
let seq = readSeqFn(invocationCwd, jobSlug);
|
|
3099
|
+
const firstPending = seq.units.find((u) => u.state === 'pending');
|
|
3100
|
+
if (!firstPending) {
|
|
3101
|
+
const failed = seq.units.some((u) => u.state === 'failed');
|
|
3102
|
+
writeSeqFn(invocationCwd, jobSlug, {
|
|
3103
|
+
...seq,
|
|
3104
|
+
state: failed ? 'failed' : 'done',
|
|
3105
|
+
finishedAt: new Date().toISOString(),
|
|
3106
|
+
});
|
|
3107
|
+
jobPatch({
|
|
3108
|
+
state: failed ? 'failed' : 'done',
|
|
3109
|
+
exitCode: failed ? 1 : 0,
|
|
3110
|
+
finishedAt: new Date().toISOString(),
|
|
3111
|
+
});
|
|
3112
|
+
if (failed) {
|
|
3113
|
+
exitFn(1);
|
|
3114
|
+
} else {
|
|
3115
|
+
console.log(`seq complete: ${seq.units.filter((u) => u.state === 'done').length}/${seq.units.length} merged`);
|
|
3116
|
+
console.log(`merge: git merge orch/${jobSlug}`);
|
|
3117
|
+
exitFn(0);
|
|
3118
|
+
}
|
|
3119
|
+
return;
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
// Re-attach to a live running unit instead of spawning a duplicate.
|
|
3123
|
+
const live = seq.units.find((u) => u.state === 'running' && u.slug);
|
|
3124
|
+
if (live) {
|
|
3125
|
+
const terminal = await waitForUnit(live.id, live.slug);
|
|
3126
|
+
if (terminal === 'interrupted') {
|
|
3127
|
+
exitFn(exitCodeForSignal('SIGINT'));
|
|
3128
|
+
return;
|
|
3129
|
+
}
|
|
3130
|
+
if (terminal !== 'done') {
|
|
3131
|
+
console.log(`[${live.id} …] ${terminal}`);
|
|
3132
|
+
const liveSlug = live.slug;
|
|
3133
|
+
console.log(`stopped: chain halted; next: orch continue ${liveSlug} "fix …"`);
|
|
3134
|
+
writeSeqFn(invocationCwd, jobSlug, {
|
|
3135
|
+
...readSeqFn(invocationCwd, jobSlug),
|
|
3136
|
+
state: 'failed',
|
|
3137
|
+
finishedAt: new Date().toISOString(),
|
|
3138
|
+
});
|
|
3139
|
+
jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
|
|
3140
|
+
exitFn(1);
|
|
3141
|
+
return;
|
|
3142
|
+
}
|
|
3143
|
+
console.log(`[${live.id} …] done — merging`);
|
|
3144
|
+
await mergeOneUnitFn({
|
|
3145
|
+
cwd: invocationCwd,
|
|
3146
|
+
parentSlug: jobSlug,
|
|
3147
|
+
unitId: live.id,
|
|
3148
|
+
unitBranch: `orch/${live.slug}`,
|
|
3149
|
+
agent: options.agent,
|
|
3150
|
+
AgentClass,
|
|
3151
|
+
maxRounds,
|
|
3152
|
+
verbose,
|
|
3153
|
+
jobSlug,
|
|
3154
|
+
jobCwd,
|
|
3155
|
+
createWorktree: createWorktreeFn,
|
|
3156
|
+
commitWorktree: commitWorktreeFn,
|
|
3157
|
+
execFile: execFileFn,
|
|
3158
|
+
exit: exitFn,
|
|
3159
|
+
});
|
|
3160
|
+
seq = readSeqFn(invocationCwd, jobSlug);
|
|
3161
|
+
await runAdjust(seq);
|
|
3162
|
+
continue;
|
|
3163
|
+
}
|
|
1243
3164
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
3165
|
+
const tip = seq.tip;
|
|
3166
|
+
const unitSlug = await spawnUnitChild(firstPending, tip);
|
|
3167
|
+
const terminal = await waitForUnit(firstPending.id, unitSlug);
|
|
3168
|
+
if (terminal === 'interrupted') {
|
|
3169
|
+
exitFn(exitCodeForSignal('SIGINT'));
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
if (terminal !== 'done') {
|
|
3173
|
+
console.log(`[${firstPending.id} …] ${terminal}`);
|
|
3174
|
+
console.log(`stopped: chain halted; next: orch continue ${unitSlug} "fix …"`);
|
|
3175
|
+
writeSeqFn(invocationCwd, jobSlug, {
|
|
3176
|
+
...readSeqFn(invocationCwd, jobSlug),
|
|
3177
|
+
state: 'failed',
|
|
3178
|
+
finishedAt: new Date().toISOString(),
|
|
3179
|
+
});
|
|
3180
|
+
jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
|
|
3181
|
+
exitFn(1);
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
1249
3184
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
3185
|
+
console.log(`[${firstPending.id} …] done — merging`);
|
|
3186
|
+
await mergeOneUnitFn({
|
|
3187
|
+
cwd: invocationCwd,
|
|
3188
|
+
parentSlug: jobSlug,
|
|
3189
|
+
unitId: firstPending.id,
|
|
3190
|
+
unitBranch: `orch/${unitSlug}`,
|
|
3191
|
+
agent: options.agent,
|
|
3192
|
+
AgentClass,
|
|
3193
|
+
maxRounds,
|
|
3194
|
+
verbose,
|
|
3195
|
+
jobSlug,
|
|
3196
|
+
jobCwd,
|
|
3197
|
+
createWorktree: createWorktreeFn,
|
|
3198
|
+
commitWorktree: commitWorktreeFn,
|
|
3199
|
+
execFile: execFileFn,
|
|
3200
|
+
exit: exitFn,
|
|
3201
|
+
});
|
|
3202
|
+
seq = readSeqFn(invocationCwd, jobSlug);
|
|
3203
|
+
await runAdjust(seq);
|
|
3204
|
+
}
|
|
1257
3205
|
} catch (err) {
|
|
1258
3206
|
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
3207
|
if (jobSlug) {
|
|
1270
3208
|
try {
|
|
1271
|
-
patchJobFn
|
|
3209
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1272
3210
|
state: 'failed',
|
|
1273
3211
|
exitCode: 1,
|
|
1274
|
-
|
|
3212
|
+
summary: '',
|
|
3213
|
+
error: err.message,
|
|
3214
|
+
task: prompt,
|
|
1275
3215
|
});
|
|
1276
3216
|
} catch {
|
|
1277
|
-
// Best-effort
|
|
3217
|
+
// Best-effort.
|
|
1278
3218
|
}
|
|
1279
3219
|
}
|
|
1280
|
-
|
|
3220
|
+
exitFn(1);
|
|
1281
3221
|
}
|
|
1282
3222
|
}
|
|
1283
3223
|
|
|
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
3224
|
/**
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
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.
|
|
3225
|
+
* Hidden `--seq-continue <parent>`: merge any done-but-unmerged units, adjust,
|
|
3226
|
+
* then continue the pending schedule loop.
|
|
1298
3227
|
*/
|
|
1299
|
-
export function
|
|
1300
|
-
const
|
|
1301
|
-
|
|
3228
|
+
export async function runSeqContinuePipeline(options = {}) {
|
|
3229
|
+
const verbose = Boolean(options.verbose);
|
|
3230
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
3231
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
3232
|
+
if (!backend) {
|
|
3233
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
3234
|
+
}
|
|
3235
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
3236
|
+
const invocationCwd = options.cwd ?? process.cwd();
|
|
3237
|
+
const parentSlug = options.parentSlug;
|
|
3238
|
+
const exitFn = options.exit ?? ((code) => process.exit(code));
|
|
3239
|
+
const pollIntervalMs = options.pollIntervalMs ?? 500;
|
|
1302
3240
|
|
|
1303
|
-
const
|
|
1304
|
-
|
|
3241
|
+
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
3242
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
3243
|
+
const spawnFn = options.spawn ?? spawn;
|
|
3244
|
+
const execFileFn = options.execFile ?? defaultExecFile;
|
|
3245
|
+
const allocateJobFn = options.allocateJob ?? allocateJob;
|
|
3246
|
+
const reconcileJobFn = options.reconcileJob ?? reconcileJob;
|
|
3247
|
+
const readSeqFn = options.readSeq ?? readSeq;
|
|
3248
|
+
const writeSeqFn = options.writeSeq ?? writeSeq;
|
|
3249
|
+
const patchUnitFn = options.patchUnit ?? patchUnit;
|
|
3250
|
+
const appendAdjustmentFn = options.appendAdjustment ?? appendAdjustment;
|
|
3251
|
+
const mergeOneUnitFn = options.mergeOneUnit ?? mergeOneUnit;
|
|
1305
3252
|
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
3253
|
+
const jobSlug = options.jobSlug ?? parentSlug ?? process.env.ORCH_JOB_SLUG;
|
|
3254
|
+
const jobCwd = options.jobCwd ?? invocationCwd;
|
|
3255
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
3256
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
3257
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
3258
|
+
|
|
3259
|
+
const jobPatch = (fields) => {
|
|
3260
|
+
if (!jobSlug) return;
|
|
3261
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
3262
|
+
};
|
|
3263
|
+
const jobCheckpoint = async () => {
|
|
3264
|
+
if (!jobSlug) return;
|
|
3265
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
3266
|
+
};
|
|
3267
|
+
|
|
3268
|
+
const seq = readSeqFn(invocationCwd, parentSlug);
|
|
3269
|
+
if (!seq) {
|
|
3270
|
+
console.error(`Error: unknown parent ${parentSlug} (no seq.json found)`);
|
|
3271
|
+
exitFn(1);
|
|
3272
|
+
return;
|
|
1312
3273
|
}
|
|
1313
|
-
}
|
|
1314
3274
|
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
3275
|
+
const maxUnits = seq.maxUnits ?? 8;
|
|
3276
|
+
const prompt = seq.task;
|
|
3277
|
+
|
|
3278
|
+
const runAdjust = async (seqDoc) => {
|
|
3279
|
+
const doneUnits = seqDoc.units.filter((u) => u.state === 'done');
|
|
3280
|
+
const pendingUnits = seqDoc.units.filter((u) => u.state === 'pending');
|
|
3281
|
+
if (pendingUnits.length === 0) return seqDoc;
|
|
3282
|
+
|
|
3283
|
+
let feedback;
|
|
3284
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
3285
|
+
const adjust = adjustAgentArgs({
|
|
3286
|
+
originalTask: prompt,
|
|
3287
|
+
doneUnits,
|
|
3288
|
+
pendingUnits,
|
|
3289
|
+
tip: seqDoc.tip,
|
|
3290
|
+
cwd: invocationCwd,
|
|
3291
|
+
maxUnits,
|
|
3292
|
+
feedback,
|
|
3293
|
+
});
|
|
3294
|
+
const adjustAgent = new AgentClass(adjust.name, adjust.instructions, adjust.prompt, adjust.options);
|
|
3295
|
+
const adjustResult = await adjustAgent.run({ verbose });
|
|
3296
|
+
await jobCheckpoint();
|
|
3297
|
+
const { content: adjustContent } = splitStageSummary(adjustResult.result);
|
|
3298
|
+
const parsed = parseDecomposition(adjustContent);
|
|
3299
|
+
if (!parsed) {
|
|
3300
|
+
feedback = ['adjust output was not valid JSON'];
|
|
3301
|
+
continue;
|
|
3302
|
+
}
|
|
3303
|
+
const violations = validateAdjustResult(parsed, { units: seqDoc.units, maxUnits });
|
|
3304
|
+
if (violations.length === 0) {
|
|
3305
|
+
const applied = applyAdjustResult(seqDoc, parsed);
|
|
3306
|
+
writeSeqFn(invocationCwd, parentSlug, applied);
|
|
3307
|
+
appendAdjustmentFn(invocationCwd, parentSlug, {
|
|
3308
|
+
afterUnitId: doneUnits[doneUnits.length - 1]?.id ?? null,
|
|
3309
|
+
tip: applied.tip,
|
|
3310
|
+
summary: 'seq-continue adjust',
|
|
3311
|
+
});
|
|
3312
|
+
return applied;
|
|
3313
|
+
}
|
|
3314
|
+
feedback = violations;
|
|
3315
|
+
}
|
|
3316
|
+
return seqDoc;
|
|
3317
|
+
};
|
|
3318
|
+
|
|
3319
|
+
const spawnUnitChild = async (unit) => {
|
|
3320
|
+
const envelope = buildUnitEnvelope({
|
|
3321
|
+
id: unit.id,
|
|
3322
|
+
title: unit.title,
|
|
3323
|
+
subtask: unit.subtask,
|
|
3324
|
+
originalTask: prompt,
|
|
3325
|
+
});
|
|
3326
|
+
const unitPrompt = `${unit.subtask}\n\n${envelope}`;
|
|
3327
|
+
const allocated = await allocateJobFn({
|
|
3328
|
+
cwd: invocationCwd,
|
|
3329
|
+
prompt: unitPrompt,
|
|
3330
|
+
agent: options.agent,
|
|
3331
|
+
maxRounds,
|
|
3332
|
+
state: 'starting',
|
|
3333
|
+
parent: parentSlug,
|
|
3334
|
+
role: 'worker',
|
|
3335
|
+
workerId: unit.id,
|
|
3336
|
+
});
|
|
3337
|
+
const unitSlug = allocated.slug;
|
|
3338
|
+
patchUnitFn(invocationCwd, parentSlug, unit.id, { slug: unitSlug, state: 'running' });
|
|
3339
|
+
const { logPath } = jobPaths(invocationCwd, unitSlug);
|
|
3340
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
3341
|
+
const childArgs = [
|
|
3342
|
+
__filename, unitPrompt,
|
|
3343
|
+
'--agent', options.agent,
|
|
3344
|
+
'--max-rounds', String(maxRounds),
|
|
3345
|
+
'--unit', `${parentSlug}:${unit.id}`,
|
|
3346
|
+
];
|
|
3347
|
+
const child = spawnFn(process.execPath, childArgs, {
|
|
3348
|
+
cwd: invocationCwd,
|
|
3349
|
+
env: {
|
|
3350
|
+
...process.env,
|
|
3351
|
+
ORCH_JOB_SLUG: unitSlug,
|
|
3352
|
+
ORCH_DETACHED: '1',
|
|
3353
|
+
ORCH_SEQ_DEPTH: '1',
|
|
3354
|
+
ORCH_FANOUT_DEPTH: '1',
|
|
3355
|
+
},
|
|
3356
|
+
detached: true,
|
|
3357
|
+
stdio: ['ignore', logFd, logFd],
|
|
3358
|
+
});
|
|
3359
|
+
child.unref();
|
|
3360
|
+
patchJobFn(invocationCwd, unitSlug, { pid: child.pid, state: 'running' });
|
|
3361
|
+
return unitSlug;
|
|
3362
|
+
};
|
|
3363
|
+
|
|
3364
|
+
const waitForUnit = async (unitId, unitSlug) => {
|
|
3365
|
+
for (;;) {
|
|
3366
|
+
await sleep(pollIntervalMs);
|
|
3367
|
+
await jobCheckpoint();
|
|
3368
|
+
const current = readSeqFn(invocationCwd, parentSlug);
|
|
3369
|
+
const unit = current?.units?.find((u) => u.id === unitId);
|
|
3370
|
+
if (unit && (unit.state === 'done' || unit.state === 'failed')) return unit.state;
|
|
3371
|
+
const job = reconcileJobFn(invocationCwd, unitSlug, readJob(invocationCwd, unitSlug));
|
|
3372
|
+
if (job && TERMINAL_JOB_STATES.includes(job.state)) {
|
|
3373
|
+
return job.state === 'done' ? 'done' : 'failed';
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
};
|
|
3377
|
+
|
|
3378
|
+
try {
|
|
3379
|
+
jobPatch({ state: 'running', phase: 'schedule', finishedAt: null, exitCode: null });
|
|
3380
|
+
writeSeqFn(invocationCwd, parentSlug, { ...seq, state: 'running', finishedAt: null });
|
|
3381
|
+
|
|
3382
|
+
// Merge any done units whose tip may not yet include them (done-but-unmerged).
|
|
3383
|
+
let current = readSeqFn(invocationCwd, parentSlug);
|
|
3384
|
+
for (const unit of current.units) {
|
|
3385
|
+
if (unit.state === 'done' && unit.slug) {
|
|
3386
|
+
// Heuristic: merge if unit sha is set and tip still looks like an earlier base,
|
|
3387
|
+
// or always attempt merge of the most recent done unit that hasn't been adjusted after.
|
|
3388
|
+
const alreadyAdjusted = (current.adjustments || []).some((a) => a.afterUnitId === unit.id);
|
|
3389
|
+
if (!alreadyAdjusted) {
|
|
3390
|
+
await mergeOneUnitFn({
|
|
3391
|
+
cwd: invocationCwd,
|
|
3392
|
+
parentSlug,
|
|
3393
|
+
unitId: unit.id,
|
|
3394
|
+
unitBranch: `orch/${unit.slug}`,
|
|
3395
|
+
agent: options.agent,
|
|
3396
|
+
AgentClass,
|
|
3397
|
+
maxRounds,
|
|
3398
|
+
verbose,
|
|
3399
|
+
jobSlug,
|
|
3400
|
+
jobCwd,
|
|
3401
|
+
createWorktree: createWorktreeFn,
|
|
3402
|
+
commitWorktree: commitWorktreeFn,
|
|
3403
|
+
execFile: execFileFn,
|
|
3404
|
+
exit: (code) => {
|
|
3405
|
+
if (code !== 0) throw new Error(`merge of ${unit.id} failed`);
|
|
3406
|
+
},
|
|
3407
|
+
});
|
|
3408
|
+
current = await runAdjust(readSeqFn(invocationCwd, parentSlug));
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
for (;;) {
|
|
3414
|
+
await jobCheckpoint();
|
|
3415
|
+
current = readSeqFn(invocationCwd, parentSlug);
|
|
3416
|
+
const pending = current.units.find((u) => u.state === 'pending');
|
|
3417
|
+
if (!pending) {
|
|
3418
|
+
writeSeqFn(invocationCwd, parentSlug, {
|
|
3419
|
+
...current,
|
|
3420
|
+
state: 'done',
|
|
3421
|
+
finishedAt: new Date().toISOString(),
|
|
3422
|
+
});
|
|
3423
|
+
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
3424
|
+
exitFn(0);
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
const unitSlug = await spawnUnitChild(pending);
|
|
3429
|
+
const terminal = await waitForUnit(pending.id, unitSlug);
|
|
3430
|
+
if (terminal !== 'done') {
|
|
3431
|
+
writeSeqFn(invocationCwd, parentSlug, {
|
|
3432
|
+
...readSeqFn(invocationCwd, parentSlug),
|
|
3433
|
+
state: 'failed',
|
|
3434
|
+
finishedAt: new Date().toISOString(),
|
|
3435
|
+
});
|
|
3436
|
+
jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
|
|
3437
|
+
exitFn(1);
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
await mergeOneUnitFn({
|
|
3442
|
+
cwd: invocationCwd,
|
|
3443
|
+
parentSlug,
|
|
3444
|
+
unitId: pending.id,
|
|
3445
|
+
unitBranch: `orch/${unitSlug}`,
|
|
3446
|
+
agent: options.agent,
|
|
3447
|
+
AgentClass,
|
|
3448
|
+
maxRounds,
|
|
3449
|
+
verbose,
|
|
3450
|
+
jobSlug,
|
|
3451
|
+
jobCwd,
|
|
3452
|
+
createWorktree: createWorktreeFn,
|
|
3453
|
+
commitWorktree: commitWorktreeFn,
|
|
3454
|
+
execFile: execFileFn,
|
|
3455
|
+
exit: exitFn,
|
|
3456
|
+
});
|
|
3457
|
+
await runAdjust(readSeqFn(invocationCwd, parentSlug));
|
|
3458
|
+
}
|
|
3459
|
+
} catch (err) {
|
|
3460
|
+
console.error(`Error: ${err.message}`);
|
|
3461
|
+
exitFn(1);
|
|
1324
3462
|
}
|
|
1325
|
-
cascadeStopFanoutChildren(cwd, parentSlug, { kill, isPidAlive: isPidAliveFn });
|
|
1326
3463
|
}
|
|
1327
3464
|
|
|
1328
3465
|
/**
|
|
@@ -1367,7 +3504,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1367
3504
|
|
|
1368
3505
|
const jobPatch = (fields) => {
|
|
1369
3506
|
if (!jobSlug) return;
|
|
1370
|
-
patchJobFn
|
|
3507
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
1371
3508
|
};
|
|
1372
3509
|
const jobCheckpoint = async () => {
|
|
1373
3510
|
if (!jobSlug) return;
|
|
@@ -1392,11 +3529,24 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1392
3529
|
}
|
|
1393
3530
|
if (jobSlug) {
|
|
1394
3531
|
try {
|
|
1395
|
-
|
|
3532
|
+
const exitCode = exitCodeForSignal(signal);
|
|
3533
|
+
const finishedAt = new Date().toISOString();
|
|
3534
|
+
patchJobFn(jobCwd, jobSlug, (current) => ({
|
|
1396
3535
|
state: 'stopped',
|
|
1397
|
-
exitCode
|
|
1398
|
-
finishedAt
|
|
1399
|
-
|
|
3536
|
+
exitCode,
|
|
3537
|
+
finishedAt,
|
|
3538
|
+
lastOutcome: buildLastOutcome({
|
|
3539
|
+
state: 'stopped',
|
|
3540
|
+
phase: current.phase,
|
|
3541
|
+
stage: current.stage,
|
|
3542
|
+
round: current.round,
|
|
3543
|
+
exitCode,
|
|
3544
|
+
finishedAt,
|
|
3545
|
+
task: prompt,
|
|
3546
|
+
summary: '',
|
|
3547
|
+
error: null,
|
|
3548
|
+
}),
|
|
3549
|
+
}));
|
|
1400
3550
|
} catch {
|
|
1401
3551
|
// Best-effort: don't let a job-state write failure block shutdown.
|
|
1402
3552
|
}
|
|
@@ -1414,7 +3564,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1414
3564
|
const triageResult = await triageAgent.run({ verbose });
|
|
1415
3565
|
await jobCheckpoint();
|
|
1416
3566
|
const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
|
|
1417
|
-
printStageSummary('triage', triageSummary);
|
|
3567
|
+
printStageSummary('triage', resolveStageSummary('triage', triageSummary, triageContent));
|
|
1418
3568
|
const parsed = parseTriageJson(triageContent);
|
|
1419
3569
|
|
|
1420
3570
|
if (parsed?.simple === true) {
|
|
@@ -1430,8 +3580,8 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1430
3580
|
);
|
|
1431
3581
|
const quickFixResult = await quickFixAgent.run({ verbose });
|
|
1432
3582
|
await jobCheckpoint();
|
|
1433
|
-
const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
1434
|
-
printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
|
|
3583
|
+
const { content: quickFixContent, summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
3584
|
+
printStageSummary('quick-fix', resolveStageSummary('quick-fix', quickFixSummary, quickFixContent), quickFixTracker.getFiles());
|
|
1435
3585
|
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
1436
3586
|
exitFn(0);
|
|
1437
3587
|
return;
|
|
@@ -1447,7 +3597,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1447
3597
|
const boundariesResult = await boundariesAgent.run({ verbose });
|
|
1448
3598
|
await jobCheckpoint();
|
|
1449
3599
|
const { content: boundariesOutput, summary: boundariesSummary } = splitStageSummary(boundariesResult.result);
|
|
1450
|
-
printStageSummary('boundaries', boundariesSummary);
|
|
3600
|
+
printStageSummary('boundaries', resolveStageSummary('boundaries', boundariesSummary, boundariesOutput));
|
|
1451
3601
|
console.log(`boundaries: ${boundariesSummary || 'done'}`);
|
|
1452
3602
|
|
|
1453
3603
|
// --- decomposer: up to two repair round-trips on validation failure ---
|
|
@@ -1462,7 +3612,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1462
3612
|
const decomposerResult = await decomposerAgent.run({ verbose });
|
|
1463
3613
|
await jobCheckpoint();
|
|
1464
3614
|
const { content: decomposerContent, summary: decomposerSummary } = splitStageSummary(decomposerResult.result);
|
|
1465
|
-
printStageSummary('decomposer', decomposerSummary);
|
|
3615
|
+
printStageSummary('decomposer', resolveStageSummary('decomposer', decomposerSummary, decomposerContent));
|
|
1466
3616
|
|
|
1467
3617
|
const parsedDecomposition = parseDecomposition(decomposerContent);
|
|
1468
3618
|
if (!parsedDecomposition) {
|
|
@@ -1498,7 +3648,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1498
3648
|
const researchResult = await researchAgent.run({ verbose });
|
|
1499
3649
|
await jobCheckpoint();
|
|
1500
3650
|
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
1501
|
-
printStageSummary('research', researchSummary);
|
|
3651
|
+
printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
|
|
1502
3652
|
|
|
1503
3653
|
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
1504
3654
|
const planner = plannerAgentArgs({
|
|
@@ -1507,8 +3657,8 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1507
3657
|
const plannerAgent = new AgentClass(planner.name, planner.instructions, planner.prompt, planner.options);
|
|
1508
3658
|
const plannerResult = await plannerAgent.run({ verbose });
|
|
1509
3659
|
await jobCheckpoint();
|
|
1510
|
-
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
1511
|
-
printStageSummary('planner', plannerSummary);
|
|
3660
|
+
const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
3661
|
+
printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
|
|
1512
3662
|
|
|
1513
3663
|
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
1514
3664
|
const worktree = createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
|
|
@@ -1942,17 +4092,31 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1942
4092
|
console.log(`retry integration after fixing it: orch --integrate ${jobSlug}`);
|
|
1943
4093
|
}
|
|
1944
4094
|
|
|
1945
|
-
|
|
4095
|
+
const finishedAt = new Date().toISOString();
|
|
4096
|
+
const summary = success
|
|
4097
|
+
? `fan-out complete: ${doneWorkers.length} worker${doneWorkers.length === 1 ? '' : 's'} integrated`
|
|
4098
|
+
: (failedWorkers.length > 0
|
|
4099
|
+
? `${failedWorkers.length} worker(s) failed`
|
|
4100
|
+
: 'fan-out failed');
|
|
4101
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
4102
|
+
state: success ? 'done' : 'failed',
|
|
4103
|
+
exitCode: success ? 0 : 1,
|
|
4104
|
+
summary,
|
|
4105
|
+
error: success ? null : summary,
|
|
4106
|
+
task: prompt,
|
|
4107
|
+
});
|
|
1946
4108
|
exitFn(success ? 0 : 1);
|
|
1947
4109
|
} catch (err) {
|
|
1948
4110
|
if (err instanceof FanoutInterrupted) return;
|
|
1949
4111
|
console.error(`Error: ${err.message}`);
|
|
1950
4112
|
if (jobSlug) {
|
|
1951
4113
|
try {
|
|
1952
|
-
patchJobFn
|
|
4114
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1953
4115
|
state: 'failed',
|
|
1954
4116
|
exitCode: 1,
|
|
1955
|
-
|
|
4117
|
+
summary: '',
|
|
4118
|
+
error: err.message,
|
|
4119
|
+
task: prompt,
|
|
1956
4120
|
});
|
|
1957
4121
|
} catch {
|
|
1958
4122
|
// Best-effort: don't let a job-state write failure mask the real error.
|
|
@@ -1969,71 +4133,137 @@ function splitParentWorker(value) {
|
|
|
1969
4133
|
return { parentSlug: value.slice(0, idx), workerId: value.slice(idx + 1) };
|
|
1970
4134
|
}
|
|
1971
4135
|
|
|
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) {
|
|
4136
|
+
/** CLI glue for `--worker`: resolves the parent fan-out/worker record, builds the worker
|
|
4137
|
+
* envelope, allocates (or reuses) the job record, then calls `runWorkerPipeline`. */
|
|
4138
|
+
async function runWorkerFromCli(options) {
|
|
4139
|
+
const cwd = process.cwd();
|
|
4140
|
+
const { parentSlug, workerId } = splitParentWorker(options.worker);
|
|
4141
|
+
if (!parentSlug || !workerId) {
|
|
4142
|
+
console.error(`Error: --worker must be in the form <parent-slug>:<worker-id>, got "${options.worker}"`);
|
|
4143
|
+
process.exit(1);
|
|
4144
|
+
return;
|
|
4145
|
+
}
|
|
4146
|
+
|
|
4147
|
+
// ORCH_FANOUT_DEPTH guards against a future --fan-out spawning nested fan-outs.
|
|
4148
|
+
process.env.ORCH_FANOUT_DEPTH = '1';
|
|
4149
|
+
|
|
4150
|
+
const fanout = readFanout(cwd, parentSlug);
|
|
4151
|
+
if (!fanout) {
|
|
4152
|
+
console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
|
|
4153
|
+
process.exit(1);
|
|
4154
|
+
return;
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4157
|
+
const worker = fanout.workers.find((w) => w.id === workerId);
|
|
4158
|
+
if (!worker) {
|
|
4159
|
+
console.error(`Error: unknown worker ${workerId} in ${parentSlug}`);
|
|
4160
|
+
process.exit(1);
|
|
4161
|
+
return;
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
const siblingTitles = fanout.workers
|
|
4165
|
+
.filter((w) => w.id !== workerId)
|
|
4166
|
+
.map((w) => w.title)
|
|
4167
|
+
.filter(Boolean);
|
|
4168
|
+
const envelope = buildWorkerEnvelope({
|
|
4169
|
+
subtask: worker.subtask,
|
|
4170
|
+
area: worker.area,
|
|
4171
|
+
scaffold: worker.scaffold,
|
|
4172
|
+
siblingTitles,
|
|
4173
|
+
});
|
|
4174
|
+
const workerPrompt = `${worker.subtask}\n\n${envelope}`;
|
|
4175
|
+
|
|
4176
|
+
let jobSlug = process.env.ORCH_JOB_SLUG;
|
|
4177
|
+
if (!jobSlug) {
|
|
4178
|
+
const alloc = allocateJob({
|
|
4179
|
+
cwd,
|
|
4180
|
+
prompt: workerPrompt,
|
|
4181
|
+
agent: options.agent,
|
|
4182
|
+
maxRounds: options.maxRounds,
|
|
4183
|
+
state: 'running',
|
|
4184
|
+
pid: process.pid,
|
|
4185
|
+
parent: parentSlug,
|
|
4186
|
+
role: 'worker',
|
|
4187
|
+
workerId,
|
|
4188
|
+
});
|
|
4189
|
+
jobSlug = alloc.slug;
|
|
4190
|
+
}
|
|
4191
|
+
setJobSlug(jobSlug);
|
|
4192
|
+
|
|
4193
|
+
await runWorkerPipeline(workerPrompt, {
|
|
4194
|
+
agent: options.agent,
|
|
4195
|
+
maxRounds: options.maxRounds,
|
|
4196
|
+
verbose: options.verbose,
|
|
4197
|
+
cwd,
|
|
4198
|
+
parentSlug,
|
|
4199
|
+
workerId,
|
|
4200
|
+
base: fanout.base,
|
|
4201
|
+
jobSlug,
|
|
4202
|
+
jobCwd: cwd,
|
|
4203
|
+
});
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4206
|
+
/** CLI glue for `--unit`: resolves the parent seq/unit record, builds the unit
|
|
4207
|
+
* envelope, allocates (or reuses) the job record, then calls `runUnitPipeline`. */
|
|
4208
|
+
async function runUnitFromCli(options) {
|
|
1975
4209
|
const cwd = process.cwd();
|
|
1976
|
-
const { parentSlug, workerId } = splitParentWorker(options.
|
|
1977
|
-
if (!parentSlug || !
|
|
1978
|
-
console.error(`Error: --
|
|
4210
|
+
const { parentSlug, workerId: unitId } = splitParentWorker(options.unit);
|
|
4211
|
+
if (!parentSlug || !unitId) {
|
|
4212
|
+
console.error(`Error: --unit must be in the form <parent-slug>:<unit-id>, got "${options.unit}"`);
|
|
1979
4213
|
process.exit(1);
|
|
1980
4214
|
return;
|
|
1981
4215
|
}
|
|
1982
4216
|
|
|
1983
|
-
|
|
4217
|
+
process.env.ORCH_SEQ_DEPTH = '1';
|
|
1984
4218
|
process.env.ORCH_FANOUT_DEPTH = '1';
|
|
1985
4219
|
|
|
1986
|
-
const
|
|
1987
|
-
if (!
|
|
1988
|
-
console.error(`Error: unknown parent ${parentSlug} (no
|
|
4220
|
+
const seq = readSeq(cwd, parentSlug);
|
|
4221
|
+
if (!seq) {
|
|
4222
|
+
console.error(`Error: unknown parent ${parentSlug} (no seq.json found)`);
|
|
1989
4223
|
process.exit(1);
|
|
1990
4224
|
return;
|
|
1991
4225
|
}
|
|
1992
4226
|
|
|
1993
|
-
const
|
|
1994
|
-
if (!
|
|
1995
|
-
console.error(`Error: unknown
|
|
4227
|
+
const unit = seq.units.find((u) => u.id === unitId);
|
|
4228
|
+
if (!unit) {
|
|
4229
|
+
console.error(`Error: unknown unit ${unitId} in ${parentSlug}`);
|
|
1996
4230
|
process.exit(1);
|
|
1997
4231
|
return;
|
|
1998
4232
|
}
|
|
1999
4233
|
|
|
2000
|
-
const
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
.
|
|
2004
|
-
|
|
2005
|
-
subtask: worker.subtask,
|
|
2006
|
-
area: worker.area,
|
|
2007
|
-
scaffold: worker.scaffold,
|
|
2008
|
-
siblingTitles,
|
|
4234
|
+
const envelope = buildUnitEnvelope({
|
|
4235
|
+
id: unit.id,
|
|
4236
|
+
title: unit.title,
|
|
4237
|
+
subtask: unit.subtask,
|
|
4238
|
+
originalTask: seq.task,
|
|
2009
4239
|
});
|
|
2010
|
-
const
|
|
4240
|
+
const unitPrompt = `${unit.subtask}\n\n${envelope}`;
|
|
2011
4241
|
|
|
2012
4242
|
let jobSlug = process.env.ORCH_JOB_SLUG;
|
|
2013
4243
|
if (!jobSlug) {
|
|
2014
4244
|
const alloc = allocateJob({
|
|
2015
4245
|
cwd,
|
|
2016
|
-
prompt:
|
|
4246
|
+
prompt: unitPrompt,
|
|
2017
4247
|
agent: options.agent,
|
|
2018
4248
|
maxRounds: options.maxRounds,
|
|
2019
4249
|
state: 'running',
|
|
2020
4250
|
pid: process.pid,
|
|
2021
4251
|
parent: parentSlug,
|
|
2022
4252
|
role: 'worker',
|
|
2023
|
-
workerId,
|
|
4253
|
+
workerId: unitId,
|
|
2024
4254
|
});
|
|
2025
4255
|
jobSlug = alloc.slug;
|
|
2026
4256
|
}
|
|
2027
4257
|
setJobSlug(jobSlug);
|
|
2028
4258
|
|
|
2029
|
-
await
|
|
4259
|
+
await runUnitPipeline(unitPrompt, {
|
|
2030
4260
|
agent: options.agent,
|
|
2031
4261
|
maxRounds: options.maxRounds,
|
|
2032
4262
|
verbose: options.verbose,
|
|
2033
4263
|
cwd,
|
|
2034
4264
|
parentSlug,
|
|
2035
|
-
|
|
2036
|
-
base:
|
|
4265
|
+
unitId,
|
|
4266
|
+
base: seq.tip,
|
|
2037
4267
|
jobSlug,
|
|
2038
4268
|
jobCwd: cwd,
|
|
2039
4269
|
});
|
|
@@ -2093,29 +4323,91 @@ function positiveIntParser(flagName) {
|
|
|
2093
4323
|
};
|
|
2094
4324
|
}
|
|
2095
4325
|
|
|
4326
|
+
/** Resolve the effective agent (CLI > local > global > cursor) or exit 1. */
|
|
4327
|
+
function resolveAgentOrExit(cliAgent, cwd = process.cwd()) {
|
|
4328
|
+
try {
|
|
4329
|
+
return resolveAgent({ cliAgent, cwd });
|
|
4330
|
+
} catch (err) {
|
|
4331
|
+
console.error(`Error: ${err.message}`);
|
|
4332
|
+
process.exit(1);
|
|
4333
|
+
return undefined;
|
|
4334
|
+
}
|
|
4335
|
+
}
|
|
4336
|
+
|
|
4337
|
+
/**
|
|
4338
|
+
* Resolve CLI `--notify` / `--no-notify` to `true` | `false` | `undefined`.
|
|
4339
|
+
* Commander stores both on `opts.notify` (negatable); detect both-via-argv
|
|
4340
|
+
* for the mutual-exclusion error.
|
|
4341
|
+
*/
|
|
4342
|
+
function cliNotifyFromOptions(options, argv = process.argv) {
|
|
4343
|
+
const hasNotify = argv.includes('--notify');
|
|
4344
|
+
const hasNoNotify = argv.includes('--no-notify');
|
|
4345
|
+
if (hasNotify && hasNoNotify) {
|
|
4346
|
+
console.error('Error: --notify and --no-notify are mutually exclusive');
|
|
4347
|
+
process.exit(1);
|
|
4348
|
+
return undefined;
|
|
4349
|
+
}
|
|
4350
|
+
if (options.notify === true) return true;
|
|
4351
|
+
if (options.notify === false) return false;
|
|
4352
|
+
return undefined;
|
|
4353
|
+
}
|
|
4354
|
+
|
|
4355
|
+
function resolveNotifyOrExit(cliNotify, cwd = process.cwd()) {
|
|
4356
|
+
try {
|
|
4357
|
+
return resolveNotify({ cliNotify, cwd });
|
|
4358
|
+
} catch (err) {
|
|
4359
|
+
console.error(`Error: ${err.message}`);
|
|
4360
|
+
process.exit(1);
|
|
4361
|
+
return undefined;
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
/** Apply process-level notify gate from CLI flags + config (skip for dry-run). */
|
|
4366
|
+
function applyNotifyEnabled(options, { cwd = process.cwd(), dryRun = false } = {}) {
|
|
4367
|
+
if (dryRun) {
|
|
4368
|
+
setNotifyEnabled(false);
|
|
4369
|
+
return false;
|
|
4370
|
+
}
|
|
4371
|
+
const cliNotify = cliNotifyFromOptions(options);
|
|
4372
|
+
const enabled = resolveNotifyOrExit(cliNotify, cwd);
|
|
4373
|
+
setNotifyEnabled(enabled);
|
|
4374
|
+
return enabled;
|
|
4375
|
+
}
|
|
4376
|
+
|
|
4377
|
+
/** Append `--notify` / `--no-notify` to child argv when the parent had an explicit flag. */
|
|
4378
|
+
function appendNotifyArgs(args, notify) {
|
|
4379
|
+
if (notify === true) args.push('--notify');
|
|
4380
|
+
else if (notify === false) args.push('--no-notify');
|
|
4381
|
+
}
|
|
4382
|
+
|
|
2096
4383
|
const program = new Command();
|
|
2097
4384
|
|
|
2098
4385
|
program
|
|
2099
4386
|
.name('orch')
|
|
2100
4387
|
.version(version)
|
|
2101
4388
|
.description('The Orchestrator: triage → research → plan → implement pipeline against a task')
|
|
2102
|
-
.argument('
|
|
4389
|
+
.argument('[task...]', 'Task description to use as the prompt (mention a file path and the agent will read it)')
|
|
2103
4390
|
.option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
|
|
2104
4391
|
.option('--dry-run', 'Check that the selected agent CLI is on PATH and exit; do not run the pipeline')
|
|
2105
4392
|
.option('--ask', 'Ask a read-only question about the codebase; print the reply and exit (skips triage and all write pipelines)')
|
|
2106
4393
|
.option('--quick', 'Skip triage, run quick-fix directly in the current working tree; create no artifacts, worktrees, or commits')
|
|
2107
4394
|
.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
4395
|
.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
|
|
4396
|
+
.option('--fan-out', 'Decompose into parallel workers, then integrate once. Cannot be combined with --ask, --quick, --dry-run, or --seq')
|
|
4397
|
+
.option('--seq', 'Decompose into ordered units; merge each, then adjust the next. Cannot be combined with --fan-out, --ask, --quick, or --dry-run')
|
|
2110
4398
|
.option('--max-workers <n>', 'Max number of parallel fan-out workers (only meaningful with --fan-out)', positiveIntParser('--max-workers'), 4)
|
|
4399
|
+
.option('--max-units <n>', 'Max number of sequential units (only meaningful with --seq)', positiveIntParser('--max-units'), 8)
|
|
2111
4400
|
.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'))
|
|
4401
|
+
.option('--notify', 'Enable desktop notification when a job reaches a terminal state (default: on)')
|
|
4402
|
+
.option('--no-notify', 'Disable desktop notifications for this run')
|
|
2112
4403
|
.addOption(
|
|
2113
|
-
new Option('--agent <agent>', 'Agent backend to run the pipeline with: "cursor" (Cursor Agent CLI), "claude" (Claude Code CLI),
|
|
2114
|
-
.choices(['cursor', 'claude', 'agn'])
|
|
2115
|
-
.default('cursor'),
|
|
4404
|
+
new Option('--agent <agent>', 'Agent backend to run the pipeline with: "cursor" (Cursor Agent CLI), "claude" (Claude Code CLI), "agn" (agn CLI), or "opencode" (OpenCode CLI). Omitting uses local then global config, else cursor')
|
|
4405
|
+
.choices(['cursor', 'claude', 'agn', 'opencode']),
|
|
2116
4406
|
)
|
|
2117
4407
|
.addOption(new Option('--worker <value>', 'internal: run a single fan-out worker "<parent-slug>:<worker-id>"').hideHelp())
|
|
4408
|
+
.addOption(new Option('--unit <value>', 'internal: run a single seq unit "<parent-slug>:<unit-id>"').hideHelp())
|
|
2118
4409
|
.addOption(new Option('--integrate <value>', 'internal: (re)run fan-out integration for "<parent-slug>"').hideHelp())
|
|
4410
|
+
.addOption(new Option('--seq-continue <value>', 'internal: resume a seq coordinator after a fixed unit "<parent-slug>"').hideHelp())
|
|
2119
4411
|
.addHelpText(
|
|
2120
4412
|
'after',
|
|
2121
4413
|
`
|
|
@@ -2123,32 +4415,139 @@ Examples:
|
|
|
2123
4415
|
$ orch "fix the typo in the README" --agent claude
|
|
2124
4416
|
$ orch "fix the bug described in task.md" --agent cursor -v
|
|
2125
4417
|
$ orch "implement the local spec" --agent agn -v
|
|
4418
|
+
$ orch "fix the typo in the README" --agent opencode
|
|
2126
4419
|
$ orch --ask "where is the CLI entrypoint?" --agent claude
|
|
2127
4420
|
$ orch --quick "fix the typo in the README" --agent claude
|
|
2128
4421
|
$ orch "noop" --dry-run --agent cursor
|
|
4422
|
+
$ orch "noop" --dry-run --agent opencode
|
|
4423
|
+
$ orch config # print effective agent
|
|
4424
|
+
$ orch config --agent claude # pin global default
|
|
4425
|
+
$ orch config --agent agn --local # pin project default
|
|
4426
|
+
$ orch config --agent opencode # pin OpenCode as default
|
|
2129
4427
|
|
|
2130
4428
|
Headless runs:
|
|
2131
4429
|
$ orch "long-running task" --detach --agent claude # start in the background, prints the run slug
|
|
2132
4430
|
$ orch list # show all tracked runs
|
|
2133
4431
|
$ orch status [slug] # show full status (defaults to most recent)
|
|
2134
4432
|
$ orch pause <slug> # request a pause at the next stage boundary
|
|
2135
|
-
$ orch resume <slug> #
|
|
4433
|
+
$ orch resume <slug> # unpause live pause, or recover failed/stopped/crashed
|
|
4434
|
+
$ orch continue <slug> "new task" # new work on a done run's worktree
|
|
4435
|
+
# (workers: same command; then re-integrate the parent)
|
|
2136
4436
|
$ orch stop <slug> # send SIGTERM to a running job
|
|
2137
4437
|
$ orch logs <slug> [-f] # print (or follow) a run's log file
|
|
2138
4438
|
|
|
2139
4439
|
Fan-out:
|
|
2140
4440
|
$ orch "implement the billing module" --fan-out --agent claude # triage, decompose, run parallel workers, integrate
|
|
2141
4441
|
$ orch "implement X" --fan-out --max-workers 6 --max-concurrency 3
|
|
4442
|
+
|
|
4443
|
+
Sequential (--seq):
|
|
4444
|
+
$ orch "implement the billing module" --seq --agent claude
|
|
4445
|
+
$ orch "implement X" --seq --max-units 6
|
|
2142
4446
|
`,
|
|
2143
4447
|
)
|
|
2144
4448
|
.action(async (task, options) => {
|
|
2145
|
-
const prompt = task.join(' ').trim();
|
|
4449
|
+
const prompt = (Array.isArray(task) ? task : []).join(' ').trim();
|
|
4450
|
+
|
|
4451
|
+
options.agent = resolveAgentOrExit(options.agent);
|
|
4452
|
+
applyNotifyEnabled(options, { dryRun: Boolean(options.dryRun) });
|
|
4453
|
+
|
|
4454
|
+
if (options.seqContinue) {
|
|
4455
|
+
const cwd = process.cwd();
|
|
4456
|
+
const parentSlug = options.seqContinue;
|
|
4457
|
+
const seq = readSeq(cwd, parentSlug);
|
|
4458
|
+
if (!seq) {
|
|
4459
|
+
console.error(`Error: unknown parent ${parentSlug} (no seq.json found)`);
|
|
4460
|
+
process.exit(1);
|
|
4461
|
+
return;
|
|
4462
|
+
}
|
|
4463
|
+
let jobSlug = process.env.ORCH_JOB_SLUG;
|
|
4464
|
+
if (!jobSlug) {
|
|
4465
|
+
jobSlug = parentSlug;
|
|
4466
|
+
setJobSlug(jobSlug);
|
|
4467
|
+
}
|
|
4468
|
+
await runSeqContinuePipeline({
|
|
4469
|
+
agent: options.agent,
|
|
4470
|
+
maxRounds: options.maxRounds,
|
|
4471
|
+
verbose: options.verbose,
|
|
4472
|
+
cwd,
|
|
4473
|
+
parentSlug,
|
|
4474
|
+
jobSlug,
|
|
4475
|
+
jobCwd: cwd,
|
|
4476
|
+
});
|
|
4477
|
+
return;
|
|
4478
|
+
}
|
|
4479
|
+
|
|
2146
4480
|
if (!prompt) {
|
|
2147
|
-
|
|
4481
|
+
// `[task...]` stays optional so hidden `--seq-continue` can omit a
|
|
4482
|
+
// positional; still mirror commander's required-arg error when argv
|
|
4483
|
+
// has no task parts at all (vs empty/whitespace → cannot be empty).
|
|
4484
|
+
const taskParts = Array.isArray(task) ? task : [];
|
|
4485
|
+
if (taskParts.length === 0) {
|
|
4486
|
+
console.error("error: missing required argument 'task'");
|
|
4487
|
+
} else {
|
|
4488
|
+
console.error('Error: task cannot be empty');
|
|
4489
|
+
}
|
|
4490
|
+
process.exit(1);
|
|
4491
|
+
return;
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4494
|
+
if (options.seq && options.fanOut) {
|
|
4495
|
+
console.error('Error: --seq cannot be combined with --fan-out');
|
|
2148
4496
|
process.exit(1);
|
|
2149
4497
|
return;
|
|
2150
4498
|
}
|
|
2151
4499
|
|
|
4500
|
+
if (options.seq) {
|
|
4501
|
+
const conflicts = ['ask', 'quick', 'dryRun']
|
|
4502
|
+
.filter((key) => options[key])
|
|
4503
|
+
.map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
|
|
4504
|
+
if (conflicts.length > 0) {
|
|
4505
|
+
console.error(`Error: --seq cannot be combined with ${conflicts.join(', ')}`);
|
|
4506
|
+
process.exit(1);
|
|
4507
|
+
return;
|
|
4508
|
+
}
|
|
4509
|
+
if (process.env.ORCH_SEQ_DEPTH || process.env.ORCH_FANOUT_DEPTH) {
|
|
4510
|
+
console.error('Error: --seq cannot be used inside a seq/fan-out child (ORCH_SEQ_DEPTH or ORCH_FANOUT_DEPTH is already set)');
|
|
4511
|
+
process.exit(1);
|
|
4512
|
+
return;
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4515
|
+
if (options.detach) {
|
|
4516
|
+
await runDetached(prompt, {
|
|
4517
|
+
...options,
|
|
4518
|
+
seq: true,
|
|
4519
|
+
maxUnits: options.maxUnits,
|
|
4520
|
+
notify: cliNotifyFromOptions(options),
|
|
4521
|
+
});
|
|
4522
|
+
return;
|
|
4523
|
+
}
|
|
4524
|
+
|
|
4525
|
+
const cwd = process.cwd();
|
|
4526
|
+
let slug = process.env.ORCH_JOB_SLUG;
|
|
4527
|
+
if (!slug) {
|
|
4528
|
+
const alloc = allocateJob({
|
|
4529
|
+
cwd,
|
|
4530
|
+
prompt,
|
|
4531
|
+
agent: options.agent,
|
|
4532
|
+
maxRounds: options.maxRounds,
|
|
4533
|
+
state: 'running',
|
|
4534
|
+
pid: process.pid,
|
|
4535
|
+
role: 'coordinator',
|
|
4536
|
+
});
|
|
4537
|
+
slug = alloc.slug;
|
|
4538
|
+
}
|
|
4539
|
+
setJobSlug(slug);
|
|
4540
|
+
|
|
4541
|
+
await runSeqPipeline(prompt, {
|
|
4542
|
+
...options,
|
|
4543
|
+
cwd,
|
|
4544
|
+
jobSlug: slug,
|
|
4545
|
+
jobCwd: cwd,
|
|
4546
|
+
maxUnits: options.maxUnits,
|
|
4547
|
+
});
|
|
4548
|
+
return;
|
|
4549
|
+
}
|
|
4550
|
+
|
|
2152
4551
|
if (options.fanOut) {
|
|
2153
4552
|
const conflicts = ['ask', 'quick', 'dryRun']
|
|
2154
4553
|
.filter((key) => options[key])
|
|
@@ -2158,8 +4557,8 @@ Fan-out:
|
|
|
2158
4557
|
process.exit(1);
|
|
2159
4558
|
return;
|
|
2160
4559
|
}
|
|
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)');
|
|
4560
|
+
if (process.env.ORCH_FANOUT_DEPTH || process.env.ORCH_SEQ_DEPTH) {
|
|
4561
|
+
console.error('Error: --fan-out cannot be used inside a fan-out/seq child (ORCH_FANOUT_DEPTH or ORCH_SEQ_DEPTH is already set)');
|
|
2163
4562
|
process.exit(1);
|
|
2164
4563
|
return;
|
|
2165
4564
|
}
|
|
@@ -2187,8 +4586,8 @@ Fan-out:
|
|
|
2187
4586
|
return;
|
|
2188
4587
|
}
|
|
2189
4588
|
|
|
2190
|
-
if (options.worker || options.integrate) {
|
|
2191
|
-
const flagName = options.worker ? '--worker' : '--integrate';
|
|
4589
|
+
if (options.worker || options.integrate || options.unit) {
|
|
4590
|
+
const flagName = options.worker ? '--worker' : options.unit ? '--unit' : '--integrate';
|
|
2192
4591
|
const conflicts = ['ask', 'quick', 'detach', 'dryRun']
|
|
2193
4592
|
.filter((key) => options[key])
|
|
2194
4593
|
.map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
|
|
@@ -2200,6 +4599,8 @@ Fan-out:
|
|
|
2200
4599
|
|
|
2201
4600
|
if (options.worker) {
|
|
2202
4601
|
await runWorkerFromCli(options);
|
|
4602
|
+
} else if (options.unit) {
|
|
4603
|
+
await runUnitFromCli(options);
|
|
2203
4604
|
} else {
|
|
2204
4605
|
await runIntegrateFromCli(options);
|
|
2205
4606
|
}
|
|
@@ -2215,7 +4616,10 @@ Fan-out:
|
|
|
2215
4616
|
process.exit(1);
|
|
2216
4617
|
return;
|
|
2217
4618
|
}
|
|
2218
|
-
await runDetached(prompt,
|
|
4619
|
+
await runDetached(prompt, {
|
|
4620
|
+
...options,
|
|
4621
|
+
notify: cliNotifyFromOptions(options),
|
|
4622
|
+
});
|
|
2219
4623
|
return;
|
|
2220
4624
|
}
|
|
2221
4625
|
|
|
@@ -2235,10 +4639,205 @@ Fan-out:
|
|
|
2235
4639
|
await runPipeline(prompt, options);
|
|
2236
4640
|
});
|
|
2237
4641
|
|
|
4642
|
+
program
|
|
4643
|
+
.command('continue')
|
|
4644
|
+
.description(
|
|
4645
|
+
'Start new complex work on a done run\'s existing worktree (not crash recovery — use orch resume for failed/stopped/crashed). Workers: continue the worker slug, then re-integrate the parent',
|
|
4646
|
+
)
|
|
4647
|
+
.argument('<slug>', 'Existing run slug under .orch/')
|
|
4648
|
+
.argument('<task...>', 'New task prompt for this continue iteration')
|
|
4649
|
+
.option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
|
|
4650
|
+
.option('--dry-run', 'Validate eligibility and agent PATH; do not reopen the job or run agents')
|
|
4651
|
+
.option('--ask', 'Rejected: continue does not support --ask')
|
|
4652
|
+
.option('--quick', 'Rejected: continue does not support --quick')
|
|
4653
|
+
.option('--detach', 'Run the continue in the background under the same slug')
|
|
4654
|
+
.option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop', positiveIntParser('--max-rounds'), 5)
|
|
4655
|
+
.option('--notify', 'Enable desktop notification when the continue job reaches a terminal state')
|
|
4656
|
+
.option('--no-notify', 'Disable desktop notifications for this continue')
|
|
4657
|
+
.addOption(
|
|
4658
|
+
new Option('--agent <agent>', 'Agent backend: "cursor", "claude", "agn", or "opencode". Omitting uses local then global config, else cursor')
|
|
4659
|
+
.choices(['cursor', 'claude', 'agn', 'opencode']),
|
|
4660
|
+
)
|
|
4661
|
+
.action(async (slug, taskParts, options, command) => {
|
|
4662
|
+
// Parent program also defines --ask/--quick/--dry-run/--agent/--detach/
|
|
4663
|
+
// --max-rounds; when those flags appear after the continue arguments,
|
|
4664
|
+
// Commander attaches them to the parent. Merge via optsWithGlobals.
|
|
4665
|
+
const opts = typeof command.optsWithGlobals === 'function'
|
|
4666
|
+
? command.optsWithGlobals()
|
|
4667
|
+
: { ...program.opts(), ...options };
|
|
4668
|
+
const prompt = taskParts.join(' ').trim();
|
|
4669
|
+
const cwd = process.cwd();
|
|
4670
|
+
|
|
4671
|
+
opts.agent = resolveAgentOrExit(opts.agent, cwd);
|
|
4672
|
+
applyNotifyEnabled(opts, { cwd, dryRun: Boolean(opts.dryRun) });
|
|
4673
|
+
|
|
4674
|
+
let record;
|
|
4675
|
+
try {
|
|
4676
|
+
record = validateContinue(cwd, slug, {
|
|
4677
|
+
task: prompt,
|
|
4678
|
+
ask: opts.ask,
|
|
4679
|
+
quick: opts.quick,
|
|
4680
|
+
});
|
|
4681
|
+
} catch (err) {
|
|
4682
|
+
console.error(`Error: ${err.message}`);
|
|
4683
|
+
process.exit(1);
|
|
4684
|
+
return;
|
|
4685
|
+
}
|
|
4686
|
+
|
|
4687
|
+
if (opts.detach) {
|
|
4688
|
+
await runContinueDetached(slug, prompt, {
|
|
4689
|
+
agent: opts.agent,
|
|
4690
|
+
maxRounds: opts.maxRounds,
|
|
4691
|
+
verbose: opts.verbose,
|
|
4692
|
+
cwd,
|
|
4693
|
+
notify: cliNotifyFromOptions(opts),
|
|
4694
|
+
});
|
|
4695
|
+
return;
|
|
4696
|
+
}
|
|
4697
|
+
|
|
4698
|
+
if (opts.dryRun) {
|
|
4699
|
+
const backend = AGENT_BACKENDS[opts.agent];
|
|
4700
|
+
if (!isBinaryOnPath(backend.binary)) {
|
|
4701
|
+
console.error(binaryMissingHint(opts.agent));
|
|
4702
|
+
process.exit(1);
|
|
4703
|
+
return;
|
|
4704
|
+
}
|
|
4705
|
+
console.log(`dry-run: continue ${slug} ok`);
|
|
4706
|
+
return;
|
|
4707
|
+
}
|
|
4708
|
+
|
|
4709
|
+
const alreadyReopened = Boolean(process.env.ORCH_JOB_SLUG);
|
|
4710
|
+
let priorOutcome;
|
|
4711
|
+
let continuation;
|
|
4712
|
+
let worktreePath = record.worktree;
|
|
4713
|
+
let branch = record.branch;
|
|
4714
|
+
let role = record.role;
|
|
4715
|
+
let parentSlug = record.parent;
|
|
4716
|
+
let workerId = record.workerId;
|
|
4717
|
+
|
|
4718
|
+
if (!alreadyReopened) {
|
|
4719
|
+
priorOutcome = snapshotPriorOutcome(cwd, slug, record);
|
|
4720
|
+
const updated = reopenJob(cwd, slug, {
|
|
4721
|
+
task: prompt,
|
|
4722
|
+
agent: opts.agent,
|
|
4723
|
+
maxRounds: opts.maxRounds,
|
|
4724
|
+
pid: process.pid,
|
|
4725
|
+
prior: priorOutcome,
|
|
4726
|
+
});
|
|
4727
|
+
continuation = updated.continuation;
|
|
4728
|
+
setJobSlug(slug);
|
|
4729
|
+
} else {
|
|
4730
|
+
const live = readJob(cwd, slug) ?? record;
|
|
4731
|
+
continuation = live.continuation ?? 2;
|
|
4732
|
+
const entries = Array.isArray(live.continuations) ? live.continuations : [];
|
|
4733
|
+
const last = entries[entries.length - 1];
|
|
4734
|
+
priorOutcome = last?.prior ?? snapshotPriorOutcome(cwd, slug, live);
|
|
4735
|
+
worktreePath = live.worktree ?? worktreePath;
|
|
4736
|
+
branch = live.branch ?? branch;
|
|
4737
|
+
role = live.role ?? role;
|
|
4738
|
+
parentSlug = live.parent ?? parentSlug;
|
|
4739
|
+
workerId = live.workerId ?? workerId;
|
|
4740
|
+
setJobSlug(slug);
|
|
4741
|
+
}
|
|
4742
|
+
|
|
4743
|
+
// PATH check after reopen (foreground) so empty-PATH tests can observe the bump.
|
|
4744
|
+
const backend = AGENT_BACKENDS[opts.agent];
|
|
4745
|
+
ensureBinaryOnPath(backend.binary, opts.agent);
|
|
4746
|
+
|
|
4747
|
+
await runContinuePipeline(prompt, {
|
|
4748
|
+
agent: opts.agent,
|
|
4749
|
+
maxRounds: opts.maxRounds,
|
|
4750
|
+
verbose: opts.verbose,
|
|
4751
|
+
cwd,
|
|
4752
|
+
slug,
|
|
4753
|
+
worktreePath,
|
|
4754
|
+
branch,
|
|
4755
|
+
role,
|
|
4756
|
+
parentSlug,
|
|
4757
|
+
workerId,
|
|
4758
|
+
priorOutcome,
|
|
4759
|
+
continuation,
|
|
4760
|
+
jobSlug: slug,
|
|
4761
|
+
jobCwd: cwd,
|
|
4762
|
+
});
|
|
4763
|
+
});
|
|
4764
|
+
|
|
4765
|
+
program
|
|
4766
|
+
.command('config')
|
|
4767
|
+
.description('Print or set default agent / notify (global ~/.orch/config or local .orch/config)')
|
|
4768
|
+
.addOption(
|
|
4769
|
+
new Option('--agent <agent>', 'Set the default agent backend')
|
|
4770
|
+
.choices(['cursor', 'claude', 'agn', 'opencode']),
|
|
4771
|
+
)
|
|
4772
|
+
.option('--notify', 'Set notify: true in the target config')
|
|
4773
|
+
.option('--no-notify', 'Set notify: false in the target config')
|
|
4774
|
+
.option('--global', 'Write the global config (~/.orch/config); default when setting a value')
|
|
4775
|
+
.option('--local', 'Write the project-local config (.orch/config)')
|
|
4776
|
+
.action((options, command) => {
|
|
4777
|
+
// Parent also defines --agent/--notify/--no-notify; flags after `config`
|
|
4778
|
+
// may land on the parent. Merge so either placement works.
|
|
4779
|
+
const opts = typeof command.optsWithGlobals === 'function'
|
|
4780
|
+
? command.optsWithGlobals()
|
|
4781
|
+
: { ...program.opts(), ...options };
|
|
4782
|
+
const cwd = process.cwd();
|
|
4783
|
+
|
|
4784
|
+
if (opts.global && opts.local) {
|
|
4785
|
+
console.error('Error: --global and --local are mutually exclusive');
|
|
4786
|
+
process.exit(1);
|
|
4787
|
+
return;
|
|
4788
|
+
}
|
|
4789
|
+
|
|
4790
|
+
const hasNotify = process.argv.includes('--notify');
|
|
4791
|
+
const hasNoNotify = process.argv.includes('--no-notify');
|
|
4792
|
+
if (hasNotify && hasNoNotify) {
|
|
4793
|
+
console.error('Error: --notify and --no-notify are mutually exclusive');
|
|
4794
|
+
process.exit(1);
|
|
4795
|
+
return;
|
|
4796
|
+
}
|
|
4797
|
+
|
|
4798
|
+
const settingNotify = hasNotify || hasNoNotify;
|
|
4799
|
+
const settingAgent = Boolean(opts.agent);
|
|
4800
|
+
const writing = settingAgent || settingNotify;
|
|
4801
|
+
|
|
4802
|
+
if ((opts.global || opts.local) && !writing) {
|
|
4803
|
+
console.error('Error: --global/--local require --agent, --notify, or --no-notify (omit flags to print config)');
|
|
4804
|
+
process.exit(1);
|
|
4805
|
+
return;
|
|
4806
|
+
}
|
|
4807
|
+
|
|
4808
|
+
if (writing) {
|
|
4809
|
+
const targetPath = opts.local
|
|
4810
|
+
? localConfigPath(cwd)
|
|
4811
|
+
: globalConfigPath();
|
|
4812
|
+
const patch = {};
|
|
4813
|
+
if (settingAgent) patch.agent = opts.agent;
|
|
4814
|
+
if (hasNotify) patch.notify = true;
|
|
4815
|
+
if (hasNoNotify) patch.notify = false;
|
|
4816
|
+
try {
|
|
4817
|
+
writeConfig(targetPath, patch);
|
|
4818
|
+
} catch (err) {
|
|
4819
|
+
console.error(`Error: ${err.message}`);
|
|
4820
|
+
process.exit(1);
|
|
4821
|
+
return;
|
|
4822
|
+
}
|
|
4823
|
+
console.log(`wrote ${targetPath}`);
|
|
4824
|
+
process.stdout.write(`${JSON.stringify(patch, null, 2)}\n`);
|
|
4825
|
+
return;
|
|
4826
|
+
}
|
|
4827
|
+
|
|
4828
|
+
try {
|
|
4829
|
+
printConfig({ cwd });
|
|
4830
|
+
} catch (err) {
|
|
4831
|
+
console.error(`Error: ${err.message}`);
|
|
4832
|
+
process.exit(1);
|
|
4833
|
+
}
|
|
4834
|
+
});
|
|
4835
|
+
|
|
2238
4836
|
program
|
|
2239
4837
|
.command('list')
|
|
2240
4838
|
.description('List all runs (active and finished) tracked under .orch/ in this directory')
|
|
2241
4839
|
.action(() => {
|
|
4840
|
+
applyNotifyEnabled({});
|
|
2242
4841
|
const jobs = listJobs(process.cwd());
|
|
2243
4842
|
if (jobs.length === 0) {
|
|
2244
4843
|
console.log('no runs');
|
|
@@ -2253,6 +4852,7 @@ program
|
|
|
2253
4852
|
.description('Show full status for a run')
|
|
2254
4853
|
.action((slug) => {
|
|
2255
4854
|
const cwd = process.cwd();
|
|
4855
|
+
applyNotifyEnabled({}, { cwd });
|
|
2256
4856
|
let record;
|
|
2257
4857
|
if (slug) {
|
|
2258
4858
|
record = readJob(cwd, slug);
|
|
@@ -2299,23 +4899,162 @@ program
|
|
|
2299
4899
|
program
|
|
2300
4900
|
.command('resume')
|
|
2301
4901
|
.argument('<slug>', 'Run slug to resume')
|
|
2302
|
-
.description(
|
|
2303
|
-
|
|
4902
|
+
.description(
|
|
4903
|
+
'Unpause a live paused/pausing job, or recover a failed/stopped/crashed complex job at its unfinished stage',
|
|
4904
|
+
)
|
|
4905
|
+
.option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
|
|
4906
|
+
.option('--dry-run', 'Validate eligibility only; do not unpause, reopen, or run agents')
|
|
4907
|
+
.option('--ask', 'Rejected: resume does not support --ask')
|
|
4908
|
+
.option('--quick', 'Rejected: resume does not support --quick')
|
|
4909
|
+
.option('--detach', 'Run failure resume in the background under the same slug')
|
|
4910
|
+
.option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop', positiveIntParser('--max-rounds'), 5)
|
|
4911
|
+
.addOption(
|
|
4912
|
+
new Option('--agent <agent>', 'Agent backend: "cursor", "claude", "agn", or "opencode". Omitting uses local then global config, else cursor')
|
|
4913
|
+
.choices(['cursor', 'claude', 'agn', 'opencode']),
|
|
4914
|
+
)
|
|
4915
|
+
.action(async (slug, options, command) => {
|
|
4916
|
+
const opts = typeof command.optsWithGlobals === 'function'
|
|
4917
|
+
? command.optsWithGlobals()
|
|
4918
|
+
: { ...program.opts(), ...options };
|
|
2304
4919
|
const cwd = process.cwd();
|
|
4920
|
+
opts.agent = resolveAgentOrExit(opts.agent, cwd);
|
|
4921
|
+
applyNotifyEnabled(opts, { cwd, dryRun: Boolean(opts.dryRun) });
|
|
4922
|
+
|
|
4923
|
+
let validated;
|
|
2305
4924
|
try {
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
console.log(`resumed ${slug}`);
|
|
2311
|
-
} else {
|
|
2312
|
-
requestResume(cwd, slug);
|
|
2313
|
-
console.log(`resumed ${slug}`);
|
|
2314
|
-
}
|
|
4925
|
+
validated = validateResume(cwd, slug, {
|
|
4926
|
+
ask: opts.ask,
|
|
4927
|
+
quick: opts.quick,
|
|
4928
|
+
});
|
|
2315
4929
|
} catch (err) {
|
|
2316
4930
|
console.error(`Error: ${err.message}`);
|
|
2317
4931
|
process.exit(1);
|
|
4932
|
+
return;
|
|
4933
|
+
}
|
|
4934
|
+
|
|
4935
|
+
const { mode, record } = validated;
|
|
4936
|
+
|
|
4937
|
+
if (opts.dryRun) {
|
|
4938
|
+
if (mode === 'failure') {
|
|
4939
|
+
const backend = AGENT_BACKENDS[opts.agent];
|
|
4940
|
+
if (!isBinaryOnPath(backend.binary)) {
|
|
4941
|
+
console.error(binaryMissingHint(opts.agent));
|
|
4942
|
+
process.exit(1);
|
|
4943
|
+
return;
|
|
4944
|
+
}
|
|
4945
|
+
}
|
|
4946
|
+
console.log(`dry-run: resume ${slug} ok (${mode})`);
|
|
4947
|
+
return;
|
|
4948
|
+
}
|
|
4949
|
+
|
|
4950
|
+
if (mode === 'unpause') {
|
|
4951
|
+
try {
|
|
4952
|
+
if (isCascadeParent(cwd, record)) {
|
|
4953
|
+
cascadeResume(cwd, slug);
|
|
4954
|
+
console.log(`resumed ${slug}`);
|
|
4955
|
+
} else {
|
|
4956
|
+
requestResume(cwd, slug);
|
|
4957
|
+
console.log(`resumed ${slug}`);
|
|
4958
|
+
}
|
|
4959
|
+
} catch (err) {
|
|
4960
|
+
console.error(`Error: ${err.message}`);
|
|
4961
|
+
process.exit(1);
|
|
4962
|
+
}
|
|
4963
|
+
return;
|
|
4964
|
+
}
|
|
4965
|
+
|
|
4966
|
+
if (mode === 'noop') {
|
|
4967
|
+
console.log(`resumed ${slug}`);
|
|
4968
|
+
return;
|
|
4969
|
+
}
|
|
4970
|
+
|
|
4971
|
+
// mode === 'failure'
|
|
4972
|
+
if (opts.detach) {
|
|
4973
|
+
await runResumeDetached(slug, {
|
|
4974
|
+
agent: opts.agent,
|
|
4975
|
+
maxRounds: opts.maxRounds,
|
|
4976
|
+
verbose: opts.verbose,
|
|
4977
|
+
cwd,
|
|
4978
|
+
notify: cliNotifyFromOptions(opts),
|
|
4979
|
+
});
|
|
4980
|
+
return;
|
|
4981
|
+
}
|
|
4982
|
+
|
|
4983
|
+
const alreadyReopened = Boolean(process.env.ORCH_JOB_SLUG);
|
|
4984
|
+
let priorOutcome;
|
|
4985
|
+
let worktreePath = record.worktree;
|
|
4986
|
+
let branch = record.branch;
|
|
4987
|
+
let role = record.role;
|
|
4988
|
+
let parentSlug = record.parent;
|
|
4989
|
+
let workerId = record.workerId;
|
|
4990
|
+
let recoverBrief = '';
|
|
4991
|
+
|
|
4992
|
+
if (!alreadyReopened) {
|
|
4993
|
+
priorOutcome = snapshotPriorOutcome(cwd, slug, record);
|
|
4994
|
+
reopenForResume(cwd, slug, {
|
|
4995
|
+
agent: opts.agent,
|
|
4996
|
+
maxRounds: opts.maxRounds,
|
|
4997
|
+
pid: process.pid,
|
|
4998
|
+
prior: priorOutcome,
|
|
4999
|
+
});
|
|
5000
|
+
setJobSlug(slug);
|
|
5001
|
+
const recovered = runRecover(cwd, slug, {
|
|
5002
|
+
prior: priorOutcome,
|
|
5003
|
+
worktreePath,
|
|
5004
|
+
});
|
|
5005
|
+
console.log(`orch: [recover] ${recovered.oneLiner}`);
|
|
5006
|
+
recoverBrief = recovered.brief;
|
|
5007
|
+
} else {
|
|
5008
|
+
const live = readJob(cwd, slug) ?? record;
|
|
5009
|
+
const entries = Array.isArray(live.resumes) ? live.resumes : [];
|
|
5010
|
+
const last = entries[entries.length - 1];
|
|
5011
|
+
priorOutcome = last?.prior ?? snapshotPriorOutcome(cwd, slug, live);
|
|
5012
|
+
worktreePath = live.worktree ?? worktreePath;
|
|
5013
|
+
branch = live.branch ?? branch;
|
|
5014
|
+
role = live.role ?? role;
|
|
5015
|
+
parentSlug = live.parent ?? parentSlug;
|
|
5016
|
+
workerId = live.workerId ?? workerId;
|
|
5017
|
+
setJobSlug(slug);
|
|
5018
|
+
const recoverPath = path.join(jobPaths(cwd, slug).dir, 'recover.md');
|
|
5019
|
+
if (!fs.existsSync(recoverPath)) {
|
|
5020
|
+
const recovered = runRecover(cwd, slug, {
|
|
5021
|
+
prior: priorOutcome,
|
|
5022
|
+
worktreePath,
|
|
5023
|
+
});
|
|
5024
|
+
console.log(`orch: [recover] ${recovered.oneLiner}`);
|
|
5025
|
+
recoverBrief = recovered.brief;
|
|
5026
|
+
} else {
|
|
5027
|
+
recoverBrief = fs.readFileSync(recoverPath, 'utf8');
|
|
5028
|
+
const orient = recoverBrief.match(/^- Orientation: (.+)$/m)
|
|
5029
|
+
|| recoverBrief.match(/^([^\n]+)$/m);
|
|
5030
|
+
console.log(`orch: [recover] ${orient?.[1] ?? 'resuming from recover.md'}`);
|
|
5031
|
+
}
|
|
5032
|
+
}
|
|
5033
|
+
|
|
5034
|
+
const backend = AGENT_BACKENDS[opts.agent];
|
|
5035
|
+
if (!isBinaryOnPath(backend.binary)) {
|
|
5036
|
+
console.error(binaryMissingHint(opts.agent));
|
|
5037
|
+
process.exit(1);
|
|
5038
|
+
return;
|
|
2318
5039
|
}
|
|
5040
|
+
|
|
5041
|
+
console.log(`resumed ${slug} (pid ${process.pid})`);
|
|
5042
|
+
await runResumePipeline({
|
|
5043
|
+
agent: opts.agent,
|
|
5044
|
+
maxRounds: opts.maxRounds,
|
|
5045
|
+
verbose: opts.verbose,
|
|
5046
|
+
cwd,
|
|
5047
|
+
slug,
|
|
5048
|
+
jobSlug: slug,
|
|
5049
|
+
priorOutcome,
|
|
5050
|
+
recoverBrief,
|
|
5051
|
+
worktreePath,
|
|
5052
|
+
branch,
|
|
5053
|
+
role,
|
|
5054
|
+
parentSlug,
|
|
5055
|
+
workerId,
|
|
5056
|
+
prompt: priorOutcome?.task ?? record.task,
|
|
5057
|
+
});
|
|
2319
5058
|
});
|
|
2320
5059
|
|
|
2321
5060
|
program
|
|
@@ -2324,6 +5063,7 @@ program
|
|
|
2324
5063
|
.description('Send SIGTERM to a running job (or reconcile a dead one to crashed)')
|
|
2325
5064
|
.action((slug) => {
|
|
2326
5065
|
const cwd = process.cwd();
|
|
5066
|
+
applyNotifyEnabled({}, { cwd });
|
|
2327
5067
|
try {
|
|
2328
5068
|
const record = readJob(cwd, slug);
|
|
2329
5069
|
if (!record) throw new Error(`stopJob: unknown job ${slug}`);
|
|
@@ -2352,7 +5092,7 @@ const jobsCmd = program
|
|
|
2352
5092
|
|
|
2353
5093
|
jobsCmd
|
|
2354
5094
|
.command('clean')
|
|
2355
|
-
.description('Delete all orch jobs from
|
|
5095
|
+
.description('Delete all orch jobs from .orch/ (refuses while live jobs are running; orch stop <slug> first)')
|
|
2356
5096
|
.action(async () => {
|
|
2357
5097
|
const cwd = process.cwd();
|
|
2358
5098
|
const orchDir = path.join(cwd, '.orch');
|
|
@@ -2361,6 +5101,17 @@ jobsCmd
|
|
|
2361
5101
|
return;
|
|
2362
5102
|
}
|
|
2363
5103
|
|
|
5104
|
+
// Refuse before prompting so we never ask to wipe dirs we will not delete.
|
|
5105
|
+
const live = liveSlugsBlockingClean(cwd);
|
|
5106
|
+
if (live.length > 0) {
|
|
5107
|
+
console.error(
|
|
5108
|
+
`Error: cannot clean while live jobs exist: ${live.join(', ')}. ` +
|
|
5109
|
+
`Stop them first with: orch stop <slug>`,
|
|
5110
|
+
);
|
|
5111
|
+
process.exit(1);
|
|
5112
|
+
return;
|
|
5113
|
+
}
|
|
5114
|
+
|
|
2364
5115
|
const rl = readline.createInterface({ input, output });
|
|
2365
5116
|
let answer;
|
|
2366
5117
|
try {
|
|
@@ -2374,8 +5125,13 @@ jobsCmd
|
|
|
2374
5125
|
return;
|
|
2375
5126
|
}
|
|
2376
5127
|
|
|
2377
|
-
|
|
2378
|
-
|
|
5128
|
+
try {
|
|
5129
|
+
const removed = cleanJobs(cwd);
|
|
5130
|
+
console.log(`deleted ${removed.length} job${removed.length === 1 ? '' : 's'} from .orch/`);
|
|
5131
|
+
} catch (err) {
|
|
5132
|
+
console.error(`Error: ${err.message}`);
|
|
5133
|
+
process.exit(1);
|
|
5134
|
+
}
|
|
2379
5135
|
});
|
|
2380
5136
|
|
|
2381
5137
|
program
|