@welluable/orch 1.3.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 +94 -18
- 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 +150 -12
- package/lib/config.js +92 -17
- package/lib/continue.js +25 -7
- package/lib/failure-log.js +72 -0
- package/lib/file-tracker.js +68 -12
- package/lib/jobs.js +131 -6
- 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 +2571 -412
- 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,6 +33,7 @@ import {
|
|
|
32
33
|
cascadeResume,
|
|
33
34
|
stopJob,
|
|
34
35
|
cleanJobs,
|
|
36
|
+
liveSlugsBlockingClean,
|
|
35
37
|
isPidAlive,
|
|
36
38
|
reopenJob,
|
|
37
39
|
buildLastOutcome,
|
|
@@ -41,6 +43,11 @@ import {
|
|
|
41
43
|
snapshotPriorOutcome,
|
|
42
44
|
buildPriorOutcomeText,
|
|
43
45
|
} from './lib/continue.js';
|
|
46
|
+
import {
|
|
47
|
+
validateResume,
|
|
48
|
+
reopenForResume,
|
|
49
|
+
runRecover,
|
|
50
|
+
} from './lib/resume.js';
|
|
44
51
|
import { askAgentArgs } from './agents/ask.js';
|
|
45
52
|
import { triageAgentArgs } from './agents/triage.js';
|
|
46
53
|
import { quickFixAgentArgs } from './agents/quick-fix.js';
|
|
@@ -53,6 +60,8 @@ import { testRunnerAgentArgs } from './agents/test-runner.js';
|
|
|
53
60
|
import { integratorAgentArgs } from './agents/integrator.js';
|
|
54
61
|
import { boundariesAgentArgs } from './agents/boundaries.js';
|
|
55
62
|
import { decomposerAgentArgs } from './agents/decomposer.js';
|
|
63
|
+
import { seqDecomposerAgentArgs } from './agents/seq-decomposer.js';
|
|
64
|
+
import { adjustAgentArgs } from './agents/adjust.js';
|
|
56
65
|
import {
|
|
57
66
|
readFanout,
|
|
58
67
|
writeFanout,
|
|
@@ -67,6 +76,17 @@ import {
|
|
|
67
76
|
detectOverlaps,
|
|
68
77
|
ensureScaffoldSubtask,
|
|
69
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';
|
|
70
90
|
import { parseDecomposition } from './lib/parse-decomposition.js';
|
|
71
91
|
import {
|
|
72
92
|
mergeBranches,
|
|
@@ -76,11 +96,13 @@ import {
|
|
|
76
96
|
} from './lib/integrate.js';
|
|
77
97
|
import {
|
|
78
98
|
resolveAgent,
|
|
99
|
+
resolveNotify,
|
|
79
100
|
writeConfig,
|
|
80
101
|
printConfig,
|
|
81
102
|
globalConfigPath,
|
|
82
103
|
localConfigPath,
|
|
83
104
|
} from './lib/config.js';
|
|
105
|
+
import { setNotifyEnabled } from './lib/notify.js';
|
|
84
106
|
|
|
85
107
|
const __filename = fileURLToPath(import.meta.url);
|
|
86
108
|
const __dirname = path.dirname(__filename);
|
|
@@ -105,6 +127,12 @@ const AGENT_BACKENDS = {
|
|
|
105
127
|
binary: 'agn',
|
|
106
128
|
missingHint: 'agn not found; run npm install -g @welluable/agn-cli or use --agent cursor',
|
|
107
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
|
+
},
|
|
108
136
|
};
|
|
109
137
|
|
|
110
138
|
function isBinaryOnPath(binary) {
|
|
@@ -187,9 +215,9 @@ export function formatJobsTable(jobs) {
|
|
|
187
215
|
}
|
|
188
216
|
|
|
189
217
|
const rows = ordered.map(({ job, indent }) => [
|
|
190
|
-
`${indent}${job.slug}`,
|
|
218
|
+
`${indent}${job.slug ?? '-'}`,
|
|
191
219
|
displayJobRole(job.role),
|
|
192
|
-
job.state,
|
|
220
|
+
job.state ?? '-',
|
|
193
221
|
job.phase ?? '-',
|
|
194
222
|
job.agent ?? '-',
|
|
195
223
|
formatRelativeTime(job.startedAt),
|
|
@@ -206,6 +234,21 @@ function lastNonEmptyLine(content) {
|
|
|
206
234
|
return lines[lines.length - 1];
|
|
207
235
|
}
|
|
208
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
|
+
|
|
209
252
|
export function formatStatus(cwd, record) {
|
|
210
253
|
const lines = [
|
|
211
254
|
`slug: ${record.slug}`,
|
|
@@ -243,6 +286,9 @@ export function formatStatus(cwd, record) {
|
|
|
243
286
|
if (last) lines.push(`status: ${last}`);
|
|
244
287
|
}
|
|
245
288
|
|
|
289
|
+
const nextHint = nextHintForRecord(record);
|
|
290
|
+
if (nextHint) lines.push(`next: ${nextHint}`);
|
|
291
|
+
|
|
246
292
|
// Child view: parent line only — do not expand siblings.
|
|
247
293
|
// Read children from disk without reconcile so status reflects recorded
|
|
248
294
|
// state/phase/branch (listJobs would rewrite dead-pid live states to crashed).
|
|
@@ -303,8 +349,24 @@ function defaultExecFile(command, args, options = {}) {
|
|
|
303
349
|
|
|
304
350
|
/** Patch a job to a terminal state and write a matching `lastOutcome` in the same write. */
|
|
305
351
|
function patchTerminalJob(patchJobFn, jobCwd, jobSlug, { state, exitCode, summary = '', error = null, task }) {
|
|
306
|
-
if (!jobSlug) return;
|
|
352
|
+
if (!jobSlug) return null;
|
|
307
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
|
+
}
|
|
308
370
|
patchJobFn(jobCwd, jobSlug, (current) => ({
|
|
309
371
|
state,
|
|
310
372
|
exitCode,
|
|
@@ -318,9 +380,23 @@ function patchTerminalJob(patchJobFn, jobCwd, jobSlug, { state, exitCode, summar
|
|
|
318
380
|
finishedAt,
|
|
319
381
|
task: task ?? current.task,
|
|
320
382
|
summary,
|
|
321
|
-
error,
|
|
383
|
+
error: outcomeError,
|
|
322
384
|
}),
|
|
323
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);
|
|
324
400
|
}
|
|
325
401
|
|
|
326
402
|
/** The test-writer ⇄ test-critic loop shared by `runPipeline` and `runWorkerPipeline`. */
|
|
@@ -335,13 +411,17 @@ async function runTestLoop({
|
|
|
335
411
|
verbose,
|
|
336
412
|
jobPatch,
|
|
337
413
|
jobCheckpoint,
|
|
414
|
+
startAt = null,
|
|
338
415
|
}) {
|
|
339
416
|
let testAccepted = null;
|
|
340
417
|
let criticFeedback = null;
|
|
341
418
|
let testRound = 0;
|
|
342
419
|
let testSummary = '';
|
|
343
420
|
|
|
344
|
-
|
|
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++) {
|
|
345
425
|
testRound = round;
|
|
346
426
|
|
|
347
427
|
jobPatch({ phase: 'test-loop', stage: 'test-writer', round });
|
|
@@ -367,7 +447,7 @@ async function runTestLoop({
|
|
|
367
447
|
const { content: testWriterContent, summary: testWriterSummary } = splitStageSummary(testOut.result);
|
|
368
448
|
printStageSummary(
|
|
369
449
|
roundLabel('test-writer', round, maxRounds),
|
|
370
|
-
testWriterSummary,
|
|
450
|
+
resolveStageSummary(roundLabel('test-writer', round, maxRounds), testWriterSummary, testWriterContent),
|
|
371
451
|
testWriterTracker.getFiles(),
|
|
372
452
|
);
|
|
373
453
|
if (!testOut.ok) {
|
|
@@ -400,7 +480,10 @@ async function runTestLoop({
|
|
|
400
480
|
const criticOut = await testCritic.run({ verbose });
|
|
401
481
|
await jobCheckpoint();
|
|
402
482
|
const { content: testCriticContent, summary: testCriticSummary } = splitStageSummary(criticOut.result);
|
|
403
|
-
printStageSummary(
|
|
483
|
+
printStageSummary(
|
|
484
|
+
roundLabel('test-critic', round, maxRounds),
|
|
485
|
+
resolveStageSummary(roundLabel('test-critic', round, maxRounds), testCriticSummary, testCriticContent),
|
|
486
|
+
);
|
|
404
487
|
if (!criticOut.ok) {
|
|
405
488
|
appendLoopStatus(statusPath, 'Test loop', {
|
|
406
489
|
round: testRound,
|
|
@@ -454,6 +537,7 @@ async function runCodeLoop({
|
|
|
454
537
|
acceptedVerification,
|
|
455
538
|
runnerFirst = false,
|
|
456
539
|
loopTitle = 'Code loop',
|
|
540
|
+
startAt = null,
|
|
457
541
|
}) {
|
|
458
542
|
let codeAccepted = null;
|
|
459
543
|
let runnerFeedback = null;
|
|
@@ -461,9 +545,14 @@ async function runCodeLoop({
|
|
|
461
545
|
let codeSummary = '';
|
|
462
546
|
let codeWriterContent = null;
|
|
463
547
|
|
|
464
|
-
|
|
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++) {
|
|
465
553
|
codeRound = round;
|
|
466
|
-
const skipWriter = runnerFirst && round === 1;
|
|
554
|
+
const skipWriter = (runnerFirst && round === 1) || (resumeSkipWriter && round === startRound);
|
|
555
|
+
if (resumeSkipWriter && round === startRound) resumeSkipWriter = false;
|
|
467
556
|
|
|
468
557
|
if (!skipWriter) {
|
|
469
558
|
jobPatch({ phase: 'code-loop', stage: 'code-writer', round });
|
|
@@ -492,7 +581,7 @@ async function runCodeLoop({
|
|
|
492
581
|
codeWriterContent = content;
|
|
493
582
|
printStageSummary(
|
|
494
583
|
roundLabel('code-writer', round, maxRounds),
|
|
495
|
-
summary,
|
|
584
|
+
resolveStageSummary(roundLabel('code-writer', round, maxRounds), summary, content),
|
|
496
585
|
codeWriterTracker.getFiles(),
|
|
497
586
|
);
|
|
498
587
|
if (!codeOut.ok) {
|
|
@@ -525,7 +614,10 @@ async function runCodeLoop({
|
|
|
525
614
|
const runnerOut = await testRunner.run({ verbose });
|
|
526
615
|
await jobCheckpoint();
|
|
527
616
|
const { content: testRunnerContent, summary: testRunnerSummary } = splitStageSummary(runnerOut.result);
|
|
528
|
-
printStageSummary(
|
|
617
|
+
printStageSummary(
|
|
618
|
+
roundLabel('test-runner', round, maxRounds),
|
|
619
|
+
resolveStageSummary(roundLabel('test-runner', round, maxRounds), testRunnerSummary, testRunnerContent),
|
|
620
|
+
);
|
|
529
621
|
if (!runnerOut.ok) {
|
|
530
622
|
appendLoopStatus(statusPath, loopTitle, {
|
|
531
623
|
round: codeRound,
|
|
@@ -582,7 +674,7 @@ export async function runPipeline(prompt, options) {
|
|
|
582
674
|
|
|
583
675
|
const jobPatch = (fields) => {
|
|
584
676
|
if (!jobSlug) return;
|
|
585
|
-
patchJobFn
|
|
677
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
586
678
|
};
|
|
587
679
|
const jobCheckpoint = async () => {
|
|
588
680
|
if (!jobSlug) return;
|
|
@@ -621,7 +713,7 @@ export async function runPipeline(prompt, options) {
|
|
|
621
713
|
return;
|
|
622
714
|
}
|
|
623
715
|
const { content, summary } = splitStageSummary(askResult.result);
|
|
624
|
-
printStageSummary('ask', summary);
|
|
716
|
+
printStageSummary('ask', resolveStageSummary('ask', summary, content));
|
|
625
717
|
console.log(content);
|
|
626
718
|
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
627
719
|
} catch (err) {
|
|
@@ -651,8 +743,8 @@ export async function runPipeline(prompt, options) {
|
|
|
651
743
|
process.exit(1);
|
|
652
744
|
return;
|
|
653
745
|
}
|
|
654
|
-
const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
655
|
-
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());
|
|
656
748
|
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
657
749
|
} catch (err) {
|
|
658
750
|
console.error(`Error: ${err.message}`);
|
|
@@ -676,7 +768,7 @@ export async function runPipeline(prompt, options) {
|
|
|
676
768
|
const triageResult = await triageAgent.run({ verbose });
|
|
677
769
|
await jobCheckpoint();
|
|
678
770
|
const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
|
|
679
|
-
printStageSummary('triage', triageSummary);
|
|
771
|
+
printStageSummary('triage', resolveStageSummary('triage', triageSummary, triageContent));
|
|
680
772
|
const parsed = parseTriageJson(triageContent);
|
|
681
773
|
|
|
682
774
|
if (parsed?.simple === true) {
|
|
@@ -696,8 +788,8 @@ export async function runPipeline(prompt, options) {
|
|
|
696
788
|
|
|
697
789
|
const quickFixResult = await quickFixAgent.run({ verbose });
|
|
698
790
|
await jobCheckpoint();
|
|
699
|
-
const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
700
|
-
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());
|
|
701
793
|
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
702
794
|
return;
|
|
703
795
|
}
|
|
@@ -723,7 +815,7 @@ export async function runPipeline(prompt, options) {
|
|
|
723
815
|
const result = await researchAgent.run({ verbose });
|
|
724
816
|
await jobCheckpoint();
|
|
725
817
|
const { content: researchContent, summary: researchSummary } = splitStageSummary(result.result);
|
|
726
|
-
printStageSummary('research', researchSummary);
|
|
818
|
+
printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
|
|
727
819
|
|
|
728
820
|
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
729
821
|
const planner = plannerAgentArgs({
|
|
@@ -742,8 +834,8 @@ export async function runPipeline(prompt, options) {
|
|
|
742
834
|
|
|
743
835
|
const plannerResult = await plannerAgent.run({ verbose });
|
|
744
836
|
await jobCheckpoint();
|
|
745
|
-
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
746
|
-
printStageSummary('planner', plannerSummary);
|
|
837
|
+
const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
838
|
+
printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
|
|
747
839
|
|
|
748
840
|
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
749
841
|
const worktree = createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
|
|
@@ -841,7 +933,7 @@ export async function runPipeline(prompt, options) {
|
|
|
841
933
|
// Best-effort: don't let a job-state write failure mask the real error.
|
|
842
934
|
}
|
|
843
935
|
}
|
|
844
|
-
console.error(`next: orch
|
|
936
|
+
console.error(`next: orch resume ${jobSlug ?? '<slug>'}`);
|
|
845
937
|
process.exit(1);
|
|
846
938
|
}
|
|
847
939
|
}
|
|
@@ -859,6 +951,9 @@ export async function runDetached(prompt, options = {}) {
|
|
|
859
951
|
maxRounds = 5,
|
|
860
952
|
verbose,
|
|
861
953
|
cwd = process.cwd(),
|
|
954
|
+
seq = false,
|
|
955
|
+
maxUnits = 8,
|
|
956
|
+
notify,
|
|
862
957
|
createRunContext: createRunContextFn = createRunContext,
|
|
863
958
|
spawn: spawnFn = spawn,
|
|
864
959
|
exit = (code) => process.exit(code),
|
|
@@ -882,6 +977,7 @@ export async function runDetached(prompt, options = {}) {
|
|
|
882
977
|
maxRounds,
|
|
883
978
|
state: 'starting',
|
|
884
979
|
createRunContext: createRunContextFn,
|
|
980
|
+
role: seq ? 'coordinator' : null,
|
|
885
981
|
});
|
|
886
982
|
const { logPath } = jobPaths(cwd, slug);
|
|
887
983
|
|
|
@@ -889,6 +985,10 @@ export async function runDetached(prompt, options = {}) {
|
|
|
889
985
|
|
|
890
986
|
const childArgs = [__filename, prompt, '--agent', agent, '--max-rounds', String(maxRounds)];
|
|
891
987
|
if (verbose) childArgs.push('--verbose');
|
|
988
|
+
if (seq) {
|
|
989
|
+
childArgs.push('--seq', '--max-units', String(maxUnits));
|
|
990
|
+
}
|
|
991
|
+
appendNotifyArgs(childArgs, notify);
|
|
892
992
|
|
|
893
993
|
const child = spawnFn(process.execPath, childArgs, {
|
|
894
994
|
cwd,
|
|
@@ -920,11 +1020,6 @@ export async function runContinuePipeline(prompt, options = {}) {
|
|
|
920
1020
|
const cwd = options.cwd ?? process.cwd();
|
|
921
1021
|
const {
|
|
922
1022
|
slug,
|
|
923
|
-
worktreePath,
|
|
924
|
-
branch,
|
|
925
|
-
role,
|
|
926
|
-
parentSlug,
|
|
927
|
-
workerId,
|
|
928
1023
|
priorOutcome,
|
|
929
1024
|
continuation,
|
|
930
1025
|
} = options;
|
|
@@ -942,9 +1037,16 @@ export async function runContinuePipeline(prompt, options = {}) {
|
|
|
942
1037
|
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
943
1038
|
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
944
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
|
+
|
|
945
1047
|
const jobPatch = (fields) => {
|
|
946
1048
|
if (!jobSlug) return;
|
|
947
|
-
patchJobFn
|
|
1049
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
948
1050
|
};
|
|
949
1051
|
const jobCheckpoint = async () => {
|
|
950
1052
|
if (!jobSlug) return;
|
|
@@ -1002,7 +1104,7 @@ export async function runContinuePipeline(prompt, options = {}) {
|
|
|
1002
1104
|
const researchResult = await researchAgent.run({ verbose });
|
|
1003
1105
|
await jobCheckpoint();
|
|
1004
1106
|
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
1005
|
-
printStageSummary('research', researchSummary);
|
|
1107
|
+
printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
|
|
1006
1108
|
|
|
1007
1109
|
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
1008
1110
|
const planner = plannerAgentArgs({
|
|
@@ -1020,8 +1122,8 @@ export async function runContinuePipeline(prompt, options = {}) {
|
|
|
1020
1122
|
);
|
|
1021
1123
|
const plannerResult = await plannerAgent.run({ verbose });
|
|
1022
1124
|
await jobCheckpoint();
|
|
1023
|
-
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
1024
|
-
printStageSummary('planner', plannerSummary);
|
|
1125
|
+
const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
1126
|
+
printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
|
|
1025
1127
|
|
|
1026
1128
|
const testAccepted = await runTestLoop({
|
|
1027
1129
|
prompt,
|
|
@@ -1056,9 +1158,9 @@ export async function runContinuePipeline(prompt, options = {}) {
|
|
|
1056
1158
|
|
|
1057
1159
|
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
1058
1160
|
const message = `orch: ${slug} (continue ${continuation}): ${prompt.split('\n')[0]}`;
|
|
1059
|
-
const worktreeChanges = collectWorktreeChangesFn({ worktreePath });
|
|
1161
|
+
const worktreeChanges = await collectWorktreeChangesFn({ worktreePath });
|
|
1060
1162
|
printFilesChanged(worktreeChanges);
|
|
1061
|
-
const commitResult = commitWorktreeFn({
|
|
1163
|
+
const commitResult = await commitWorktreeFn({
|
|
1062
1164
|
worktreePath,
|
|
1063
1165
|
branch,
|
|
1064
1166
|
message,
|
|
@@ -1091,7 +1193,17 @@ export async function runContinuePipeline(prompt, options = {}) {
|
|
|
1091
1193
|
} catch {
|
|
1092
1194
|
// Best-effort.
|
|
1093
1195
|
}
|
|
1094
|
-
|
|
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) {
|
|
1095
1207
|
patchWorkerFn(cwd, parentSlug, workerId, {
|
|
1096
1208
|
state: 'done',
|
|
1097
1209
|
sha: commitResult.sha,
|
|
@@ -1112,7 +1224,13 @@ export async function runContinuePipeline(prompt, options = {}) {
|
|
|
1112
1224
|
console.error(`Error: ${err.message}`);
|
|
1113
1225
|
if (role === 'worker' && parentSlug && workerId) {
|
|
1114
1226
|
try {
|
|
1115
|
-
|
|
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
|
+
}
|
|
1116
1234
|
} catch {
|
|
1117
1235
|
// Best-effort.
|
|
1118
1236
|
}
|
|
@@ -1188,6 +1306,7 @@ export async function runContinueDetached(slug, prompt, options = {}) {
|
|
|
1188
1306
|
String(maxRounds),
|
|
1189
1307
|
];
|
|
1190
1308
|
if (verbose) childArgs.push('--verbose');
|
|
1309
|
+
appendNotifyArgs(childArgs, options.notify);
|
|
1191
1310
|
|
|
1192
1311
|
const child = spawnFn(process.execPath, childArgs, {
|
|
1193
1312
|
cwd,
|
|
@@ -1210,14 +1329,10 @@ export async function runContinueDetached(slug, prompt, options = {}) {
|
|
|
1210
1329
|
}
|
|
1211
1330
|
|
|
1212
1331
|
/**
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
1215
|
-
* commit, exactly like `runPipeline` minus triage. `prompt` is the worker's subtask text
|
|
1216
|
-
* with the envelope already appended by the CLI wiring. On success, patches the parent's
|
|
1217
|
-
* `fanout.json.workers[]` entry to `state:'done'` with `sha`/`changedFiles`; on failure,
|
|
1218
|
-
* patches it (and this job's own `run.json`) to `state:'failed'` before exiting non-zero.
|
|
1332
|
+
* Failure-resume pipeline: recover → re-enter unfinished stage → remaining phases.
|
|
1333
|
+
* Distinct from `runContinuePipeline` (new-task continue). See `.spec/resume.md`.
|
|
1219
1334
|
*/
|
|
1220
|
-
export async function
|
|
1335
|
+
export async function runResumePipeline(options = {}) {
|
|
1221
1336
|
const verbose = Boolean(options.verbose);
|
|
1222
1337
|
const maxRounds = options.maxRounds ?? 5;
|
|
1223
1338
|
const backend = AGENT_BACKENDS[options.agent];
|
|
@@ -1226,7 +1341,11 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
1226
1341
|
}
|
|
1227
1342
|
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
1228
1343
|
const cwd = options.cwd ?? process.cwd();
|
|
1229
|
-
const {
|
|
1344
|
+
const {
|
|
1345
|
+
slug,
|
|
1346
|
+
priorOutcome,
|
|
1347
|
+
recoverBrief = '',
|
|
1348
|
+
} = options;
|
|
1230
1349
|
|
|
1231
1350
|
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
1232
1351
|
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
@@ -1236,15 +1355,23 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
1236
1355
|
const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
|
|
1237
1356
|
const execFileFn = options.execFile;
|
|
1238
1357
|
|
|
1239
|
-
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
1358
|
+
const jobSlug = options.jobSlug ?? slug ?? process.env.ORCH_JOB_SLUG;
|
|
1240
1359
|
const jobCwd = options.jobCwd ?? cwd;
|
|
1241
1360
|
const patchJobFn = options.patchJob ?? patchJob;
|
|
1242
1361
|
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
1243
1362
|
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
1244
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
|
+
|
|
1245
1372
|
const jobPatch = (fields) => {
|
|
1246
1373
|
if (!jobSlug) return;
|
|
1247
|
-
patchJobFn
|
|
1374
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
1248
1375
|
};
|
|
1249
1376
|
const jobCheckpoint = async () => {
|
|
1250
1377
|
if (!jobSlug) return;
|
|
@@ -1255,136 +1382,280 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
1255
1382
|
ensureBinaryOnPath(backend.binary, options.agent);
|
|
1256
1383
|
}
|
|
1257
1384
|
|
|
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;
|
|
1389
|
+
|
|
1390
|
+
const withRecover = (basePrompt) => (
|
|
1391
|
+
recoverBrief ? `${recoverBrief}\n\n${basePrompt}` : basePrompt
|
|
1392
|
+
);
|
|
1393
|
+
|
|
1258
1394
|
try {
|
|
1259
|
-
const runContext = createRunContextFn(
|
|
1395
|
+
const runContext = createRunContextFn({ cwd, slug: jobSlug });
|
|
1396
|
+
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
1260
1397
|
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
+
}
|
|
1273
1442
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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));
|
|
1466
|
+
}
|
|
1292
1467
|
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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
|
+
}
|
|
1479
|
+
}
|
|
1296
1480
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
);
|
|
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;
|
|
1302
1485
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
+
}
|
|
1315
1511
|
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
+
});
|
|
1319
1531
|
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
jobCheckpoint,
|
|
1331
|
-
acceptedVerification,
|
|
1332
|
-
});
|
|
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
|
+
});
|
|
1333
1542
|
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
+
}
|
|
1343
1558
|
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
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;
|
|
1356
1600
|
}
|
|
1357
1601
|
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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,
|
|
1365
1643
|
});
|
|
1366
|
-
} catch {
|
|
1367
|
-
// Best-effort: changedFiles is informational only, never masks a successful commit.
|
|
1368
1644
|
}
|
|
1369
|
-
|
|
1370
|
-
patchWorkerFn(cwd, parentSlug, workerId, {
|
|
1371
|
-
state: 'done',
|
|
1372
|
-
sha: commitResult.sha,
|
|
1373
|
-
changedFiles,
|
|
1374
|
-
});
|
|
1375
|
-
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1376
|
-
state: 'done',
|
|
1377
|
-
exitCode: 0,
|
|
1378
|
-
summary: codeAccepted?.verdict?.summary ?? '',
|
|
1379
|
-
error: null,
|
|
1380
|
-
task: prompt,
|
|
1381
|
-
});
|
|
1382
1645
|
} catch (err) {
|
|
1383
1646
|
console.error(`Error: ${err.message}`);
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
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
|
+
}
|
|
1388
1659
|
}
|
|
1389
1660
|
if (jobSlug) {
|
|
1390
1661
|
try {
|
|
@@ -1396,42 +1667,121 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
1396
1667
|
task: prompt,
|
|
1397
1668
|
});
|
|
1398
1669
|
} catch {
|
|
1399
|
-
// Best-effort
|
|
1670
|
+
// Best-effort.
|
|
1400
1671
|
}
|
|
1401
1672
|
}
|
|
1402
|
-
console.error(`next: orch
|
|
1403
|
-
if (parentSlug) console.error(` orch --integrate ${parentSlug}`);
|
|
1673
|
+
console.error(`next: orch resume ${jobSlug ?? '<slug>'}`);
|
|
1404
1674
|
process.exit(1);
|
|
1405
1675
|
}
|
|
1406
1676
|
}
|
|
1407
1677
|
|
|
1408
1678
|
/**
|
|
1409
|
-
*
|
|
1410
|
-
*
|
|
1411
|
-
* via the `integrator` agent, one conflict at a time), then runs a runner-first verify
|
|
1412
|
-
* loop and commits on green. Never invokes triage/research/planner/test-writer/test-critic.
|
|
1413
|
-
* Appends every step to `.orch/<job-slug>/integration.md` as it happens.
|
|
1679
|
+
* Detach-parent path for failure `orch resume`: spawn background child with
|
|
1680
|
+
* ORCH_JOB_SLUG, reopen with child pid, print resumed, exit.
|
|
1414
1681
|
*/
|
|
1415
|
-
export async function
|
|
1416
|
-
const
|
|
1417
|
-
|
|
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;
|
|
1418
1770
|
const backend = AGENT_BACKENDS[options.agent];
|
|
1419
1771
|
if (!backend) {
|
|
1420
1772
|
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
1421
1773
|
}
|
|
1422
1774
|
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
1423
1775
|
const cwd = options.cwd ?? process.cwd();
|
|
1424
|
-
const { parentSlug } = options;
|
|
1776
|
+
const { parentSlug, workerId, base } = options;
|
|
1425
1777
|
|
|
1778
|
+
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
1426
1779
|
const createWorktreeFn = options.createWorktree ?? createWorktree;
|
|
1427
1780
|
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
1428
|
-
const
|
|
1429
|
-
const
|
|
1430
|
-
const
|
|
1431
|
-
const
|
|
1432
|
-
const conflictedFilesFn = options.conflictedFiles ?? conflictedFiles;
|
|
1433
|
-
const hasConflictMarkersFn = options.hasConflictMarkers ?? hasConflictMarkers;
|
|
1434
|
-
const execFileFn = options.execFile ?? defaultExecFile;
|
|
1781
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
1782
|
+
const patchWorkerFn = options.patchWorker ?? patchWorker;
|
|
1783
|
+
const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
|
|
1784
|
+
const execFileFn = options.execFile;
|
|
1435
1785
|
|
|
1436
1786
|
const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
|
|
1437
1787
|
const jobCwd = options.jobCwd ?? cwd;
|
|
@@ -1441,7 +1791,7 @@ export async function runIntegratePipeline(options = {}) {
|
|
|
1441
1791
|
|
|
1442
1792
|
const jobPatch = (fields) => {
|
|
1443
1793
|
if (!jobSlug) return;
|
|
1444
|
-
patchJobFn
|
|
1794
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
1445
1795
|
};
|
|
1446
1796
|
const jobCheckpoint = async () => {
|
|
1447
1797
|
if (!jobSlug) return;
|
|
@@ -1452,198 +1802,334 @@ export async function runIntegratePipeline(options = {}) {
|
|
|
1452
1802
|
ensureBinaryOnPath(backend.binary, options.agent);
|
|
1453
1803
|
}
|
|
1454
1804
|
|
|
1455
|
-
const fanout = readFanoutFn(cwd, parentSlug);
|
|
1456
|
-
if (!fanout) {
|
|
1457
|
-
console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
|
|
1458
|
-
process.exit(1);
|
|
1459
|
-
return;
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
const integrationSlug = jobSlug ?? parentSlug;
|
|
1463
|
-
const integrationDir = path.join(jobCwd, '.orch', integrationSlug);
|
|
1464
|
-
const integrationMdPath = path.join(integrationDir, 'integration.md');
|
|
1465
|
-
const logIntegration = (line) => {
|
|
1466
|
-
fs.mkdirSync(integrationDir, { recursive: true });
|
|
1467
|
-
fs.appendFileSync(integrationMdPath, `${line}\n`);
|
|
1468
|
-
};
|
|
1469
|
-
|
|
1470
|
-
let merged = [...fanout.integration.merged];
|
|
1471
|
-
let skipped = [...(fanout.integration.skipped ?? [])];
|
|
1472
|
-
|
|
1473
1805
|
try {
|
|
1474
|
-
|
|
1475
|
-
fs.appendFileSync(integrationMdPath, `# Integration: ${parentSlug}\n\n`);
|
|
1476
|
-
|
|
1477
|
-
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
1478
|
-
|
|
1479
|
-
const reuseWorktreePath = `${cwd}-${parentSlug}`;
|
|
1480
|
-
const expectedBranch = `orch/${parentSlug}`;
|
|
1481
|
-
let worktree = null;
|
|
1482
|
-
|
|
1483
|
-
if (fs.existsSync(reuseWorktreePath)) {
|
|
1484
|
-
const currentBranch = execFileFn('git', ['-C', reuseWorktreePath, 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
1485
|
-
if (currentBranch === expectedBranch) {
|
|
1486
|
-
worktree = { repoRoot: cwd, worktreePath: reuseWorktreePath, branch: expectedBranch };
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1806
|
+
const runContext = createRunContextFn(jobSlug ? { cwd, slug: jobSlug } : { cwd });
|
|
1489
1807
|
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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));
|
|
1494
1820
|
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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,
|
|
1499
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));
|
|
1500
1839
|
|
|
1840
|
+
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
1841
|
+
const worktree = createWorktreeFn({ cwd, slug: runContext.slug, base });
|
|
1501
1842
|
jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
|
|
1502
|
-
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
1503
|
-
worktree: current.worktree ?? worktree.worktreePath,
|
|
1504
|
-
branch: current.branch ?? worktree.branch,
|
|
1505
|
-
}));
|
|
1506
1843
|
|
|
1507
|
-
|
|
1508
|
-
|
|
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`,
|
|
1509
1848
|
);
|
|
1510
1849
|
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
+
});
|
|
1519
1862
|
|
|
1520
|
-
|
|
1521
|
-
|
|
1863
|
+
const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
|
|
1864
|
+
.filter(Boolean)
|
|
1865
|
+
.join('\n');
|
|
1522
1866
|
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
+
});
|
|
1531
1880
|
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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
|
+
});
|
|
1535
1890
|
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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
|
+
}
|
|
1540
1904
|
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
integratorArgs.prompt,
|
|
1553
|
-
integratorArgs.options,
|
|
1554
|
-
);
|
|
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
|
+
}
|
|
1555
1916
|
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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
|
+
}
|
|
1567
1954
|
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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));
|
|
1571
1971
|
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
} else {
|
|
1580
|
-
abortMergeFn({ cwd: worktree.worktreePath, execFile: execFileFn });
|
|
1581
|
-
skipped.push(result.branch);
|
|
1582
|
-
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
1583
|
-
skipped: [...(current.skipped ?? []), result.branch],
|
|
1584
|
-
}));
|
|
1585
|
-
logIntegration(`- Conflicts in \`${result.branch}\` remained unresolved; aborted merge and skipped.`);
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
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;
|
|
1588
1979
|
|
|
1589
|
-
|
|
1590
|
-
|
|
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');
|
|
1591
2061
|
|
|
1592
|
-
// --- runner-first verify loop: test-runner first, code-writer only on failure ---
|
|
1593
2062
|
const codeAccepted = await runCodeLoop({
|
|
1594
|
-
prompt
|
|
2063
|
+
prompt,
|
|
1595
2064
|
worktreePath: worktree.worktreePath,
|
|
1596
2065
|
branch: worktree.branch,
|
|
1597
|
-
taskPath:
|
|
1598
|
-
statusPath:
|
|
2066
|
+
taskPath: runContext.taskPath,
|
|
2067
|
+
statusPath: runContext.statusPath,
|
|
1599
2068
|
maxRounds,
|
|
1600
2069
|
AgentClass,
|
|
1601
2070
|
verbose,
|
|
1602
2071
|
jobPatch,
|
|
1603
2072
|
jobCheckpoint,
|
|
1604
|
-
acceptedVerification
|
|
1605
|
-
runnerFirst: true,
|
|
1606
|
-
loopTitle: 'Verify loop',
|
|
2073
|
+
acceptedVerification,
|
|
1607
2074
|
});
|
|
1608
2075
|
|
|
1609
2076
|
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
1610
|
-
const message = `orch: ${
|
|
1611
|
-
const
|
|
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({
|
|
1612
2081
|
worktreePath: worktree.worktreePath,
|
|
1613
|
-
branch:
|
|
2082
|
+
branch: worktree.branch,
|
|
1614
2083
|
message,
|
|
1615
2084
|
});
|
|
1616
2085
|
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
+
}
|
|
1622
2101
|
|
|
1623
|
-
|
|
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, {
|
|
1624
2115
|
state: 'done',
|
|
1625
2116
|
sha: commitResult.sha,
|
|
1626
|
-
|
|
1627
|
-
|
|
2117
|
+
changedFiles,
|
|
2118
|
+
slug: runContext.slug,
|
|
1628
2119
|
});
|
|
1629
2120
|
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1630
2121
|
state: 'done',
|
|
1631
2122
|
exitCode: 0,
|
|
1632
2123
|
summary: codeAccepted?.verdict?.summary ?? '',
|
|
1633
2124
|
error: null,
|
|
1634
|
-
task:
|
|
2125
|
+
task: prompt,
|
|
1635
2126
|
});
|
|
1636
2127
|
} catch (err) {
|
|
1637
2128
|
console.error(`Error: ${err.message}`);
|
|
1638
2129
|
try {
|
|
1639
|
-
|
|
1640
|
-
} catch {
|
|
1641
|
-
// Best-effort: don't let a log write failure mask the real error.
|
|
1642
|
-
}
|
|
1643
|
-
try {
|
|
1644
|
-
patchIntegrationFn(cwd, parentSlug, { state: 'failed', merged, skipped });
|
|
2130
|
+
patchUnitFn(cwd, parentSlug, unitId, { state: 'failed' });
|
|
1645
2131
|
} catch {
|
|
1646
|
-
// Best-effort
|
|
2132
|
+
// Best-effort.
|
|
1647
2133
|
}
|
|
1648
2134
|
if (jobSlug) {
|
|
1649
2135
|
try {
|
|
@@ -1652,58 +2138,1328 @@ export async function runIntegratePipeline(options = {}) {
|
|
|
1652
2138
|
exitCode: 1,
|
|
1653
2139
|
summary: '',
|
|
1654
2140
|
error: err.message,
|
|
1655
|
-
task:
|
|
2141
|
+
task: prompt,
|
|
1656
2142
|
});
|
|
1657
2143
|
} catch {
|
|
1658
|
-
// Best-effort
|
|
2144
|
+
// Best-effort.
|
|
1659
2145
|
}
|
|
1660
2146
|
}
|
|
1661
|
-
|
|
2147
|
+
console.error(`next: orch resume ${jobSlug ?? '<slug>'}`);
|
|
2148
|
+
if (parentSlug) console.error(` orch --seq-continue ${parentSlug}`);
|
|
2149
|
+
exitFn(1);
|
|
1662
2150
|
}
|
|
1663
2151
|
}
|
|
1664
2152
|
|
|
1665
|
-
function sleep(ms) {
|
|
1666
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
/** Sentinel thrown by the coordinator's scheduling loop when a SIGINT/SIGTERM/SIGHUP
|
|
1670
|
-
* cascade fires mid-flight, so the pending awaits unwind without a second exit call. */
|
|
1671
|
-
class FanoutInterrupted extends Error {}
|
|
1672
|
-
|
|
1673
2153
|
/**
|
|
1674
|
-
*
|
|
1675
|
-
*
|
|
1676
|
-
*
|
|
1677
|
-
* `lib/agent.js`'s existing `shutdown()` — the coordinator's own signal handling calls
|
|
1678
|
-
* both this and the normal in-process agent-CLI reaping.
|
|
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.
|
|
1679
2157
|
*/
|
|
1680
|
-
export function
|
|
1681
|
-
const
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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 });
|
|
2442
|
+
patchIntegrationFn(cwd, parentSlug, (current) => ({
|
|
2443
|
+
worktree: current.worktree ?? worktree.worktreePath,
|
|
2444
|
+
branch: current.branch ?? worktree.branch,
|
|
2445
|
+
}));
|
|
2446
|
+
|
|
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)) {
|
|
1691
2650
|
kill(record.pid, 'SIGTERM');
|
|
1692
2651
|
}
|
|
1693
2652
|
}
|
|
1694
2653
|
}
|
|
1695
2654
|
|
|
1696
|
-
/**
|
|
1697
|
-
* CLI / management cascade stop: SIGTERM the parent pid if alive, then every
|
|
1698
|
-
* live child pid (via
|
|
1699
|
-
* handler keeps calling the child-only
|
|
1700
|
-
*/
|
|
1701
|
-
export function cascadeStop(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
|
|
1702
|
-
const parent = readJob(cwd, parentSlug);
|
|
1703
|
-
if (parent && isPidAliveFn(parent.pid)) {
|
|
1704
|
-
kill(parent.pid, 'SIGTERM');
|
|
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`,
|
|
2791
|
+
);
|
|
2792
|
+
|
|
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
|
+
});
|
|
2805
|
+
|
|
2806
|
+
const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent].filter(Boolean).join('\n');
|
|
2807
|
+
|
|
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
|
+
});
|
|
2821
|
+
|
|
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 });
|
|
2827
|
+
|
|
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
|
+
}
|
|
2842
|
+
|
|
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,
|
|
2889
|
+
});
|
|
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
|
+
};
|
|
2898
|
+
|
|
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}`;
|
|
2907
|
+
|
|
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
|
+
});
|
|
2923
|
+
|
|
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;
|
|
2972
|
+
}
|
|
2973
|
+
return job.state === 'done' ? 'done' : 'failed';
|
|
2974
|
+
}
|
|
2975
|
+
|
|
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
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
};
|
|
2983
|
+
|
|
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,
|
|
3085
|
+
});
|
|
3086
|
+
|
|
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
|
+
}
|
|
3164
|
+
|
|
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
|
+
}
|
|
3184
|
+
|
|
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
|
+
}
|
|
3205
|
+
} catch (err) {
|
|
3206
|
+
console.error(`Error: ${err.message}`);
|
|
3207
|
+
if (jobSlug) {
|
|
3208
|
+
try {
|
|
3209
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
3210
|
+
state: 'failed',
|
|
3211
|
+
exitCode: 1,
|
|
3212
|
+
summary: '',
|
|
3213
|
+
error: err.message,
|
|
3214
|
+
task: prompt,
|
|
3215
|
+
});
|
|
3216
|
+
} catch {
|
|
3217
|
+
// Best-effort.
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
exitFn(1);
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
/**
|
|
3225
|
+
* Hidden `--seq-continue <parent>`: merge any done-but-unmerged units, adjust,
|
|
3226
|
+
* then continue the pending schedule loop.
|
|
3227
|
+
*/
|
|
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;
|
|
3240
|
+
|
|
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;
|
|
3252
|
+
|
|
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;
|
|
3273
|
+
}
|
|
3274
|
+
|
|
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);
|
|
1705
3462
|
}
|
|
1706
|
-
cascadeStopFanoutChildren(cwd, parentSlug, { kill, isPidAlive: isPidAliveFn });
|
|
1707
3463
|
}
|
|
1708
3464
|
|
|
1709
3465
|
/**
|
|
@@ -1748,7 +3504,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1748
3504
|
|
|
1749
3505
|
const jobPatch = (fields) => {
|
|
1750
3506
|
if (!jobSlug) return;
|
|
1751
|
-
patchJobFn
|
|
3507
|
+
return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
|
|
1752
3508
|
};
|
|
1753
3509
|
const jobCheckpoint = async () => {
|
|
1754
3510
|
if (!jobSlug) return;
|
|
@@ -1808,7 +3564,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1808
3564
|
const triageResult = await triageAgent.run({ verbose });
|
|
1809
3565
|
await jobCheckpoint();
|
|
1810
3566
|
const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
|
|
1811
|
-
printStageSummary('triage', triageSummary);
|
|
3567
|
+
printStageSummary('triage', resolveStageSummary('triage', triageSummary, triageContent));
|
|
1812
3568
|
const parsed = parseTriageJson(triageContent);
|
|
1813
3569
|
|
|
1814
3570
|
if (parsed?.simple === true) {
|
|
@@ -1824,8 +3580,8 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1824
3580
|
);
|
|
1825
3581
|
const quickFixResult = await quickFixAgent.run({ verbose });
|
|
1826
3582
|
await jobCheckpoint();
|
|
1827
|
-
const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
|
|
1828
|
-
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());
|
|
1829
3585
|
jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
|
|
1830
3586
|
exitFn(0);
|
|
1831
3587
|
return;
|
|
@@ -1841,7 +3597,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1841
3597
|
const boundariesResult = await boundariesAgent.run({ verbose });
|
|
1842
3598
|
await jobCheckpoint();
|
|
1843
3599
|
const { content: boundariesOutput, summary: boundariesSummary } = splitStageSummary(boundariesResult.result);
|
|
1844
|
-
printStageSummary('boundaries', boundariesSummary);
|
|
3600
|
+
printStageSummary('boundaries', resolveStageSummary('boundaries', boundariesSummary, boundariesOutput));
|
|
1845
3601
|
console.log(`boundaries: ${boundariesSummary || 'done'}`);
|
|
1846
3602
|
|
|
1847
3603
|
// --- decomposer: up to two repair round-trips on validation failure ---
|
|
@@ -1856,7 +3612,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1856
3612
|
const decomposerResult = await decomposerAgent.run({ verbose });
|
|
1857
3613
|
await jobCheckpoint();
|
|
1858
3614
|
const { content: decomposerContent, summary: decomposerSummary } = splitStageSummary(decomposerResult.result);
|
|
1859
|
-
printStageSummary('decomposer', decomposerSummary);
|
|
3615
|
+
printStageSummary('decomposer', resolveStageSummary('decomposer', decomposerSummary, decomposerContent));
|
|
1860
3616
|
|
|
1861
3617
|
const parsedDecomposition = parseDecomposition(decomposerContent);
|
|
1862
3618
|
if (!parsedDecomposition) {
|
|
@@ -1892,7 +3648,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1892
3648
|
const researchResult = await researchAgent.run({ verbose });
|
|
1893
3649
|
await jobCheckpoint();
|
|
1894
3650
|
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
1895
|
-
printStageSummary('research', researchSummary);
|
|
3651
|
+
printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
|
|
1896
3652
|
|
|
1897
3653
|
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
1898
3654
|
const planner = plannerAgentArgs({
|
|
@@ -1901,8 +3657,8 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1901
3657
|
const plannerAgent = new AgentClass(planner.name, planner.instructions, planner.prompt, planner.options);
|
|
1902
3658
|
const plannerResult = await plannerAgent.run({ verbose });
|
|
1903
3659
|
await jobCheckpoint();
|
|
1904
|
-
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
1905
|
-
printStageSummary('planner', plannerSummary);
|
|
3660
|
+
const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
3661
|
+
printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
|
|
1906
3662
|
|
|
1907
3663
|
jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
|
|
1908
3664
|
const worktree = createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
|
|
@@ -2447,6 +4203,72 @@ async function runWorkerFromCli(options) {
|
|
|
2447
4203
|
});
|
|
2448
4204
|
}
|
|
2449
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) {
|
|
4209
|
+
const cwd = process.cwd();
|
|
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}"`);
|
|
4213
|
+
process.exit(1);
|
|
4214
|
+
return;
|
|
4215
|
+
}
|
|
4216
|
+
|
|
4217
|
+
process.env.ORCH_SEQ_DEPTH = '1';
|
|
4218
|
+
process.env.ORCH_FANOUT_DEPTH = '1';
|
|
4219
|
+
|
|
4220
|
+
const seq = readSeq(cwd, parentSlug);
|
|
4221
|
+
if (!seq) {
|
|
4222
|
+
console.error(`Error: unknown parent ${parentSlug} (no seq.json found)`);
|
|
4223
|
+
process.exit(1);
|
|
4224
|
+
return;
|
|
4225
|
+
}
|
|
4226
|
+
|
|
4227
|
+
const unit = seq.units.find((u) => u.id === unitId);
|
|
4228
|
+
if (!unit) {
|
|
4229
|
+
console.error(`Error: unknown unit ${unitId} in ${parentSlug}`);
|
|
4230
|
+
process.exit(1);
|
|
4231
|
+
return;
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
const envelope = buildUnitEnvelope({
|
|
4235
|
+
id: unit.id,
|
|
4236
|
+
title: unit.title,
|
|
4237
|
+
subtask: unit.subtask,
|
|
4238
|
+
originalTask: seq.task,
|
|
4239
|
+
});
|
|
4240
|
+
const unitPrompt = `${unit.subtask}\n\n${envelope}`;
|
|
4241
|
+
|
|
4242
|
+
let jobSlug = process.env.ORCH_JOB_SLUG;
|
|
4243
|
+
if (!jobSlug) {
|
|
4244
|
+
const alloc = allocateJob({
|
|
4245
|
+
cwd,
|
|
4246
|
+
prompt: unitPrompt,
|
|
4247
|
+
agent: options.agent,
|
|
4248
|
+
maxRounds: options.maxRounds,
|
|
4249
|
+
state: 'running',
|
|
4250
|
+
pid: process.pid,
|
|
4251
|
+
parent: parentSlug,
|
|
4252
|
+
role: 'worker',
|
|
4253
|
+
workerId: unitId,
|
|
4254
|
+
});
|
|
4255
|
+
jobSlug = alloc.slug;
|
|
4256
|
+
}
|
|
4257
|
+
setJobSlug(jobSlug);
|
|
4258
|
+
|
|
4259
|
+
await runUnitPipeline(unitPrompt, {
|
|
4260
|
+
agent: options.agent,
|
|
4261
|
+
maxRounds: options.maxRounds,
|
|
4262
|
+
verbose: options.verbose,
|
|
4263
|
+
cwd,
|
|
4264
|
+
parentSlug,
|
|
4265
|
+
unitId,
|
|
4266
|
+
base: seq.tip,
|
|
4267
|
+
jobSlug,
|
|
4268
|
+
jobCwd: cwd,
|
|
4269
|
+
});
|
|
4270
|
+
}
|
|
4271
|
+
|
|
2450
4272
|
/** CLI glue for `--integrate`: resolves the parent fan-out, allocates (or reuses) the
|
|
2451
4273
|
* integration job record, then calls `runIntegratePipeline`. */
|
|
2452
4274
|
async function runIntegrateFromCli(options) {
|
|
@@ -2512,28 +4334,80 @@ function resolveAgentOrExit(cliAgent, cwd = process.cwd()) {
|
|
|
2512
4334
|
}
|
|
2513
4335
|
}
|
|
2514
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
|
+
|
|
2515
4383
|
const program = new Command();
|
|
2516
4384
|
|
|
2517
4385
|
program
|
|
2518
4386
|
.name('orch')
|
|
2519
4387
|
.version(version)
|
|
2520
4388
|
.description('The Orchestrator: triage → research → plan → implement pipeline against a task')
|
|
2521
|
-
.argument('
|
|
4389
|
+
.argument('[task...]', 'Task description to use as the prompt (mention a file path and the agent will read it)')
|
|
2522
4390
|
.option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
|
|
2523
4391
|
.option('--dry-run', 'Check that the selected agent CLI is on PATH and exit; do not run the pipeline')
|
|
2524
4392
|
.option('--ask', 'Ask a read-only question about the codebase; print the reply and exit (skips triage and all write pipelines)')
|
|
2525
4393
|
.option('--quick', 'Skip triage, run quick-fix directly in the current working tree; create no artifacts, worktrees, or commits')
|
|
2526
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')
|
|
2527
4395
|
.option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop (ignored with --ask and --quick)', positiveIntParser('--max-rounds'), 5)
|
|
2528
|
-
.option('--fan-out', 'Decompose
|
|
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')
|
|
2529
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)
|
|
2530
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')
|
|
2531
4403
|
.addOption(
|
|
2532
|
-
new Option('--agent <agent>', 'Agent backend to run the pipeline with: "cursor" (Cursor Agent CLI), "claude" (Claude Code CLI),
|
|
2533
|
-
.choices(['cursor', 'claude', 'agn']),
|
|
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']),
|
|
2534
4406
|
)
|
|
2535
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())
|
|
2536
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())
|
|
2537
4411
|
.addHelpText(
|
|
2538
4412
|
'after',
|
|
2539
4413
|
`
|
|
@@ -2541,20 +4415,23 @@ Examples:
|
|
|
2541
4415
|
$ orch "fix the typo in the README" --agent claude
|
|
2542
4416
|
$ orch "fix the bug described in task.md" --agent cursor -v
|
|
2543
4417
|
$ orch "implement the local spec" --agent agn -v
|
|
4418
|
+
$ orch "fix the typo in the README" --agent opencode
|
|
2544
4419
|
$ orch --ask "where is the CLI entrypoint?" --agent claude
|
|
2545
4420
|
$ orch --quick "fix the typo in the README" --agent claude
|
|
2546
4421
|
$ orch "noop" --dry-run --agent cursor
|
|
4422
|
+
$ orch "noop" --dry-run --agent opencode
|
|
2547
4423
|
$ orch config # print effective agent
|
|
2548
4424
|
$ orch config --agent claude # pin global default
|
|
2549
4425
|
$ orch config --agent agn --local # pin project default
|
|
4426
|
+
$ orch config --agent opencode # pin OpenCode as default
|
|
2550
4427
|
|
|
2551
4428
|
Headless runs:
|
|
2552
4429
|
$ orch "long-running task" --detach --agent claude # start in the background, prints the run slug
|
|
2553
4430
|
$ orch list # show all tracked runs
|
|
2554
4431
|
$ orch status [slug] # show full status (defaults to most recent)
|
|
2555
4432
|
$ orch pause <slug> # request a pause at the next stage boundary
|
|
2556
|
-
$ orch resume <slug> # unpause
|
|
2557
|
-
$ orch continue <slug> "new task" # new work on a
|
|
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
|
|
2558
4435
|
# (workers: same command; then re-integrate the parent)
|
|
2559
4436
|
$ orch stop <slug> # send SIGTERM to a running job
|
|
2560
4437
|
$ orch logs <slug> [-f] # print (or follow) a run's log file
|
|
@@ -2562,17 +4439,114 @@ Headless runs:
|
|
|
2562
4439
|
Fan-out:
|
|
2563
4440
|
$ orch "implement the billing module" --fan-out --agent claude # triage, decompose, run parallel workers, integrate
|
|
2564
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
|
|
2565
4446
|
`,
|
|
2566
4447
|
)
|
|
2567
4448
|
.action(async (task, options) => {
|
|
2568
|
-
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
|
+
|
|
2569
4480
|
if (!prompt) {
|
|
2570
|
-
|
|
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
|
+
}
|
|
2571
4490
|
process.exit(1);
|
|
2572
4491
|
return;
|
|
2573
4492
|
}
|
|
2574
4493
|
|
|
2575
|
-
options.
|
|
4494
|
+
if (options.seq && options.fanOut) {
|
|
4495
|
+
console.error('Error: --seq cannot be combined with --fan-out');
|
|
4496
|
+
process.exit(1);
|
|
4497
|
+
return;
|
|
4498
|
+
}
|
|
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
|
+
}
|
|
2576
4550
|
|
|
2577
4551
|
if (options.fanOut) {
|
|
2578
4552
|
const conflicts = ['ask', 'quick', 'dryRun']
|
|
@@ -2583,8 +4557,8 @@ Fan-out:
|
|
|
2583
4557
|
process.exit(1);
|
|
2584
4558
|
return;
|
|
2585
4559
|
}
|
|
2586
|
-
if (process.env.ORCH_FANOUT_DEPTH) {
|
|
2587
|
-
console.error('Error: --fan-out cannot be used inside a fan-out child (ORCH_FANOUT_DEPTH is already set)');
|
|
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)');
|
|
2588
4562
|
process.exit(1);
|
|
2589
4563
|
return;
|
|
2590
4564
|
}
|
|
@@ -2612,8 +4586,8 @@ Fan-out:
|
|
|
2612
4586
|
return;
|
|
2613
4587
|
}
|
|
2614
4588
|
|
|
2615
|
-
if (options.worker || options.integrate) {
|
|
2616
|
-
const flagName = options.worker ? '--worker' : '--integrate';
|
|
4589
|
+
if (options.worker || options.integrate || options.unit) {
|
|
4590
|
+
const flagName = options.worker ? '--worker' : options.unit ? '--unit' : '--integrate';
|
|
2617
4591
|
const conflicts = ['ask', 'quick', 'detach', 'dryRun']
|
|
2618
4592
|
.filter((key) => options[key])
|
|
2619
4593
|
.map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
|
|
@@ -2625,6 +4599,8 @@ Fan-out:
|
|
|
2625
4599
|
|
|
2626
4600
|
if (options.worker) {
|
|
2627
4601
|
await runWorkerFromCli(options);
|
|
4602
|
+
} else if (options.unit) {
|
|
4603
|
+
await runUnitFromCli(options);
|
|
2628
4604
|
} else {
|
|
2629
4605
|
await runIntegrateFromCli(options);
|
|
2630
4606
|
}
|
|
@@ -2640,7 +4616,10 @@ Fan-out:
|
|
|
2640
4616
|
process.exit(1);
|
|
2641
4617
|
return;
|
|
2642
4618
|
}
|
|
2643
|
-
await runDetached(prompt,
|
|
4619
|
+
await runDetached(prompt, {
|
|
4620
|
+
...options,
|
|
4621
|
+
notify: cliNotifyFromOptions(options),
|
|
4622
|
+
});
|
|
2644
4623
|
return;
|
|
2645
4624
|
}
|
|
2646
4625
|
|
|
@@ -2663,7 +4642,7 @@ Fan-out:
|
|
|
2663
4642
|
program
|
|
2664
4643
|
.command('continue')
|
|
2665
4644
|
.description(
|
|
2666
|
-
'Start new complex work on a
|
|
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',
|
|
2667
4646
|
)
|
|
2668
4647
|
.argument('<slug>', 'Existing run slug under .orch/')
|
|
2669
4648
|
.argument('<task...>', 'New task prompt for this continue iteration')
|
|
@@ -2673,9 +4652,11 @@ program
|
|
|
2673
4652
|
.option('--quick', 'Rejected: continue does not support --quick')
|
|
2674
4653
|
.option('--detach', 'Run the continue in the background under the same slug')
|
|
2675
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')
|
|
2676
4657
|
.addOption(
|
|
2677
|
-
new Option('--agent <agent>', 'Agent backend: "cursor", "claude", or "
|
|
2678
|
-
.choices(['cursor', 'claude', 'agn']),
|
|
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']),
|
|
2679
4660
|
)
|
|
2680
4661
|
.action(async (slug, taskParts, options, command) => {
|
|
2681
4662
|
// Parent program also defines --ask/--quick/--dry-run/--agent/--detach/
|
|
@@ -2688,6 +4669,7 @@ program
|
|
|
2688
4669
|
const cwd = process.cwd();
|
|
2689
4670
|
|
|
2690
4671
|
opts.agent = resolveAgentOrExit(opts.agent, cwd);
|
|
4672
|
+
applyNotifyEnabled(opts, { cwd, dryRun: Boolean(opts.dryRun) });
|
|
2691
4673
|
|
|
2692
4674
|
let record;
|
|
2693
4675
|
try {
|
|
@@ -2708,6 +4690,7 @@ program
|
|
|
2708
4690
|
maxRounds: opts.maxRounds,
|
|
2709
4691
|
verbose: opts.verbose,
|
|
2710
4692
|
cwd,
|
|
4693
|
+
notify: cliNotifyFromOptions(opts),
|
|
2711
4694
|
});
|
|
2712
4695
|
return;
|
|
2713
4696
|
}
|
|
@@ -2781,16 +4764,18 @@ program
|
|
|
2781
4764
|
|
|
2782
4765
|
program
|
|
2783
4766
|
.command('config')
|
|
2784
|
-
.description('Print or set
|
|
4767
|
+
.description('Print or set default agent / notify (global ~/.orch/config or local .orch/config)')
|
|
2785
4768
|
.addOption(
|
|
2786
4769
|
new Option('--agent <agent>', 'Set the default agent backend')
|
|
2787
|
-
.choices(['cursor', 'claude', 'agn']),
|
|
4770
|
+
.choices(['cursor', 'claude', 'agn', 'opencode']),
|
|
2788
4771
|
)
|
|
2789
|
-
.option('--
|
|
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')
|
|
2790
4775
|
.option('--local', 'Write the project-local config (.orch/config)')
|
|
2791
4776
|
.action((options, command) => {
|
|
2792
|
-
// Parent also defines --agent; flags after `config`
|
|
2793
|
-
// parent. Merge so either placement works.
|
|
4777
|
+
// Parent also defines --agent/--notify/--no-notify; flags after `config`
|
|
4778
|
+
// may land on the parent. Merge so either placement works.
|
|
2794
4779
|
const opts = typeof command.optsWithGlobals === 'function'
|
|
2795
4780
|
? command.optsWithGlobals()
|
|
2796
4781
|
: { ...program.opts(), ...options };
|
|
@@ -2802,25 +4787,41 @@ program
|
|
|
2802
4787
|
return;
|
|
2803
4788
|
}
|
|
2804
4789
|
|
|
2805
|
-
|
|
2806
|
-
|
|
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)');
|
|
2807
4804
|
process.exit(1);
|
|
2808
4805
|
return;
|
|
2809
4806
|
}
|
|
2810
4807
|
|
|
2811
|
-
if (
|
|
4808
|
+
if (writing) {
|
|
2812
4809
|
const targetPath = opts.local
|
|
2813
4810
|
? localConfigPath(cwd)
|
|
2814
4811
|
: globalConfigPath();
|
|
4812
|
+
const patch = {};
|
|
4813
|
+
if (settingAgent) patch.agent = opts.agent;
|
|
4814
|
+
if (hasNotify) patch.notify = true;
|
|
4815
|
+
if (hasNoNotify) patch.notify = false;
|
|
2815
4816
|
try {
|
|
2816
|
-
writeConfig(targetPath,
|
|
4817
|
+
writeConfig(targetPath, patch);
|
|
2817
4818
|
} catch (err) {
|
|
2818
4819
|
console.error(`Error: ${err.message}`);
|
|
2819
4820
|
process.exit(1);
|
|
2820
4821
|
return;
|
|
2821
4822
|
}
|
|
2822
4823
|
console.log(`wrote ${targetPath}`);
|
|
2823
|
-
process.stdout.write(`${JSON.stringify(
|
|
4824
|
+
process.stdout.write(`${JSON.stringify(patch, null, 2)}\n`);
|
|
2824
4825
|
return;
|
|
2825
4826
|
}
|
|
2826
4827
|
|
|
@@ -2836,6 +4837,7 @@ program
|
|
|
2836
4837
|
.command('list')
|
|
2837
4838
|
.description('List all runs (active and finished) tracked under .orch/ in this directory')
|
|
2838
4839
|
.action(() => {
|
|
4840
|
+
applyNotifyEnabled({});
|
|
2839
4841
|
const jobs = listJobs(process.cwd());
|
|
2840
4842
|
if (jobs.length === 0) {
|
|
2841
4843
|
console.log('no runs');
|
|
@@ -2850,6 +4852,7 @@ program
|
|
|
2850
4852
|
.description('Show full status for a run')
|
|
2851
4853
|
.action((slug) => {
|
|
2852
4854
|
const cwd = process.cwd();
|
|
4855
|
+
applyNotifyEnabled({}, { cwd });
|
|
2853
4856
|
let record;
|
|
2854
4857
|
if (slug) {
|
|
2855
4858
|
record = readJob(cwd, slug);
|
|
@@ -2896,23 +4899,162 @@ program
|
|
|
2896
4899
|
program
|
|
2897
4900
|
.command('resume')
|
|
2898
4901
|
.argument('<slug>', 'Run slug to resume')
|
|
2899
|
-
.description(
|
|
2900
|
-
|
|
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 };
|
|
2901
4919
|
const cwd = process.cwd();
|
|
4920
|
+
opts.agent = resolveAgentOrExit(opts.agent, cwd);
|
|
4921
|
+
applyNotifyEnabled(opts, { cwd, dryRun: Boolean(opts.dryRun) });
|
|
4922
|
+
|
|
4923
|
+
let validated;
|
|
2902
4924
|
try {
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
console.log(`resumed ${slug}`);
|
|
2908
|
-
} else {
|
|
2909
|
-
requestResume(cwd, slug);
|
|
2910
|
-
console.log(`resumed ${slug}`);
|
|
2911
|
-
}
|
|
4925
|
+
validated = validateResume(cwd, slug, {
|
|
4926
|
+
ask: opts.ask,
|
|
4927
|
+
quick: opts.quick,
|
|
4928
|
+
});
|
|
2912
4929
|
} catch (err) {
|
|
2913
4930
|
console.error(`Error: ${err.message}`);
|
|
2914
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;
|
|
2915
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
|
+
});
|
|
2916
5058
|
});
|
|
2917
5059
|
|
|
2918
5060
|
program
|
|
@@ -2921,6 +5063,7 @@ program
|
|
|
2921
5063
|
.description('Send SIGTERM to a running job (or reconcile a dead one to crashed)')
|
|
2922
5064
|
.action((slug) => {
|
|
2923
5065
|
const cwd = process.cwd();
|
|
5066
|
+
applyNotifyEnabled({}, { cwd });
|
|
2924
5067
|
try {
|
|
2925
5068
|
const record = readJob(cwd, slug);
|
|
2926
5069
|
if (!record) throw new Error(`stopJob: unknown job ${slug}`);
|
|
@@ -2949,7 +5092,7 @@ const jobsCmd = program
|
|
|
2949
5092
|
|
|
2950
5093
|
jobsCmd
|
|
2951
5094
|
.command('clean')
|
|
2952
|
-
.description('Delete all orch jobs from
|
|
5095
|
+
.description('Delete all orch jobs from .orch/ (refuses while live jobs are running; orch stop <slug> first)')
|
|
2953
5096
|
.action(async () => {
|
|
2954
5097
|
const cwd = process.cwd();
|
|
2955
5098
|
const orchDir = path.join(cwd, '.orch');
|
|
@@ -2958,6 +5101,17 @@ jobsCmd
|
|
|
2958
5101
|
return;
|
|
2959
5102
|
}
|
|
2960
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
|
+
|
|
2961
5115
|
const rl = readline.createInterface({ input, output });
|
|
2962
5116
|
let answer;
|
|
2963
5117
|
try {
|
|
@@ -2971,8 +5125,13 @@ jobsCmd
|
|
|
2971
5125
|
return;
|
|
2972
5126
|
}
|
|
2973
5127
|
|
|
2974
|
-
|
|
2975
|
-
|
|
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
|
+
}
|
|
2976
5135
|
});
|
|
2977
5136
|
|
|
2978
5137
|
program
|