@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/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
- for (let round = 1; round <= maxRounds; round++) {
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(roundLabel('test-critic', round, maxRounds), testCriticSummary);
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
- for (let round = 1; round <= maxRounds; round++) {
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(roundLabel('test-runner', round, maxRounds), testRunnerSummary);
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(jobCwd, jobSlug, fields);
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 continue ${jobSlug ?? '<slug>'} "fix the failure and finish"`);
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(jobCwd, jobSlug, fields);
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
- if (commitResult.committed) {
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
- patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
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
- * The `--worker <parent>:<workerId>` driver: skips triage and runs researchplanner
1214
- * worktree (from the fan-out's recorded `base`) → test loop → code loop (writer-first)
1215
- * commit, exactly like `runPipeline` minus triage. `prompt` is the worker's subtask text
1216
- * with the envelope already appended by the CLI wiring. On success, patches the parent's
1217
- * `fanout.json.workers[]` entry to `state:'done'` with `sha`/`changedFiles`; on failure,
1218
- * patches it (and this job's own `run.json`) to `state:'failed'` before exiting non-zero.
1332
+ * Failure-resume pipeline: recover re-enter unfinished stageremaining phases.
1333
+ * Distinct from `runContinuePipeline` (new-task continue). See `.spec/resume.md`.
1219
1334
  */
1220
- export async function runWorkerPipeline(prompt, options = {}) {
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 { parentSlug, workerId, base } = options;
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(jobCwd, jobSlug, fields);
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(jobSlug ? { cwd, slug: jobSlug } : { cwd });
1395
+ const runContext = createRunContextFn({ cwd, slug: jobSlug });
1396
+ fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
1260
1397
 
1261
- jobPatch({ phase: 'research', stage: 'research', round: null });
1262
- const research = researchAgentArgs({ prompt, cwd, researchPath: runContext.researchPath });
1263
- const researchAgent = new AgentClass(
1264
- research.name,
1265
- research.instructions,
1266
- research.prompt,
1267
- research.options,
1268
- );
1269
- const researchResult = await researchAgent.run({ verbose });
1270
- await jobCheckpoint();
1271
- const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
1272
- printStageSummary('research', researchSummary);
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
- jobPatch({ phase: 'plan', stage: 'planner', round: null });
1275
- const planner = plannerAgentArgs({
1276
- prompt,
1277
- cwd,
1278
- researchPath: runContext.researchPath,
1279
- taskPath: runContext.taskPath,
1280
- researchOutput: researchContent,
1281
- });
1282
- const plannerAgent = new AgentClass(
1283
- planner.name,
1284
- planner.instructions,
1285
- planner.prompt,
1286
- planner.options,
1287
- );
1288
- const plannerResult = await plannerAgent.run({ verbose });
1289
- await jobCheckpoint();
1290
- const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
1291
- printStageSummary('planner', plannerSummary);
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
- jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
1294
- const worktree = createWorktreeFn({ cwd, slug: runContext.slug, base });
1295
- jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
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
- fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
1298
- fs.writeFileSync(
1299
- runContext.statusPath,
1300
- `# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n- Parent: \`${parentSlug}\`\n- Worker: \`${workerId}\`\n`,
1301
- );
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
- const testAccepted = await runTestLoop({
1304
- prompt,
1305
- worktreePath: worktree.worktreePath,
1306
- branch: worktree.branch,
1307
- taskPath: runContext.taskPath,
1308
- statusPath: runContext.statusPath,
1309
- maxRounds,
1310
- AgentClass,
1311
- verbose,
1312
- jobPatch,
1313
- jobCheckpoint,
1314
- });
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
- const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
1317
- .filter(Boolean)
1318
- .join('\n');
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
- const codeAccepted = await runCodeLoop({
1321
- prompt,
1322
- worktreePath: worktree.worktreePath,
1323
- branch: worktree.branch,
1324
- taskPath: runContext.taskPath,
1325
- statusPath: runContext.statusPath,
1326
- maxRounds,
1327
- AgentClass,
1328
- verbose,
1329
- jobPatch,
1330
- jobCheckpoint,
1331
- acceptedVerification,
1332
- });
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
- jobPatch({ phase: 'commit', stage: 'commit', round: null });
1335
- const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
1336
- const worktreeChanges = collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
1337
- printFilesChanged(worktreeChanges);
1338
- const commitResult = commitWorktreeFn({
1339
- worktreePath: worktree.worktreePath,
1340
- branch: worktree.branch,
1341
- message,
1342
- });
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
- if (commitResult.committed) {
1345
- fs.appendFileSync(
1346
- runContext.statusPath,
1347
- `\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
1348
- );
1349
- console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
1350
- } else {
1351
- fs.appendFileSync(
1352
- runContext.statusPath,
1353
- `\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
1354
- );
1355
- console.log(`commit: no changes on ${commitResult.branch}`);
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
- let changedFiles = [];
1359
- try {
1360
- changedFiles = recordChangedFilesFn({
1361
- repoRoot: worktree.repoRoot,
1362
- base,
1363
- branch: worktree.branch,
1364
- execFile: execFileFn,
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
- try {
1385
- patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
1386
- } catch {
1387
- // Best-effort: don't let a fanout-state write failure mask the real error.
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: don't let a job-state write failure mask the real error.
1670
+ // Best-effort.
1400
1671
  }
1401
1672
  }
1402
- console.error(`next: orch continue ${jobSlug ?? '<slug>'} "fix the failure and finish"`);
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
- * The `--integrate <parent>` driver: reuses (or creates) the integration worktree keyed
1410
- * by the parent slug, merges `fanout.integration.candidates` in order (repairing conflicts
1411
- * via the `integrator` agent, one conflict at a time), then runs a runner-first verify
1412
- * loop and commits on green. Never invokes triage/research/planner/test-writer/test-critic.
1413
- * Appends every step to `.orch/<job-slug>/integration.md` as it happens.
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 runIntegratePipeline(options = {}) {
1416
- const verbose = Boolean(options.verbose);
1417
- const maxRounds = options.maxRounds ?? 5;
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 readFanoutFn = options.readFanout ?? readFanout;
1429
- const patchIntegrationFn = options.patchIntegration ?? patchIntegration;
1430
- const mergeBranchesFn = options.mergeBranches ?? mergeBranches;
1431
- const abortMergeFn = options.abortMerge ?? abortMerge;
1432
- const conflictedFilesFn = options.conflictedFiles ?? conflictedFiles;
1433
- const hasConflictMarkersFn = options.hasConflictMarkers ?? hasConflictMarkers;
1434
- const execFileFn = options.execFile ?? defaultExecFile;
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(jobCwd, jobSlug, fields);
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
- fs.mkdirSync(integrationDir, { recursive: true });
1475
- fs.appendFileSync(integrationMdPath, `# Integration: ${parentSlug}\n\n`);
1476
-
1477
- jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
1478
-
1479
- const reuseWorktreePath = `${cwd}-${parentSlug}`;
1480
- const expectedBranch = `orch/${parentSlug}`;
1481
- let worktree = null;
1482
-
1483
- if (fs.existsSync(reuseWorktreePath)) {
1484
- const currentBranch = execFileFn('git', ['-C', reuseWorktreePath, 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
1485
- if (currentBranch === expectedBranch) {
1486
- worktree = { repoRoot: cwd, worktreePath: reuseWorktreePath, branch: expectedBranch };
1487
- }
1488
- }
1806
+ const runContext = createRunContextFn(jobSlug ? { cwd, slug: jobSlug } : { cwd });
1489
1807
 
1490
- const reused = Boolean(worktree);
1491
- if (!worktree) {
1492
- worktree = createWorktreeFn({ cwd, slug: parentSlug, base: fanout.base });
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
- logIntegration(
1496
- reused
1497
- ? `- Reused existing worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`
1498
- : `- Created worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`,
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
- let remaining = fanout.integration.candidates.filter(
1508
- (branch) => !merged.includes(branch) && !skipped.includes(branch),
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
- while (remaining.length > 0) {
1512
- const results = mergeBranchesFn({
1513
- cwd: worktree.worktreePath,
1514
- candidates: remaining,
1515
- merged,
1516
- overlappingFiles: fanout.integration.overlappingFiles,
1517
- execFile: execFileFn,
1518
- });
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
- for (const result of results) {
1521
- if (result.status === 'skipped') continue;
1863
+ const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
1864
+ .filter(Boolean)
1865
+ .join('\n');
1522
1866
 
1523
- if (result.status === 'merged') {
1524
- merged.push(result.branch);
1525
- patchIntegrationFn(cwd, parentSlug, (current) => ({
1526
- merged: [...current.merged, result.branch],
1527
- }));
1528
- logIntegration(`- Merged \`${result.branch}\` cleanly.`);
1529
- continue;
1530
- }
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
- // status === 'conflict'
1533
- logIntegration(`- Conflict merging \`${result.branch}\`; entering repair.`);
1534
- patchIntegrationFn(cwd, parentSlug, { state: 'repairing' });
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
- const conflicted = conflictedFilesFn({ cwd: worktree.worktreePath, execFile: execFileFn });
1537
- const involvedWorkers = fanout.workers
1538
- .filter((worker) => worker.branch === result.branch)
1539
- .map(({ id, title, subtask, area }) => ({ id, title, subtask, area }));
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
- jobPatch({ phase: 'integrate', stage: 'integrator', round: null });
1542
- const integratorArgs = integratorAgentArgs({
1543
- prompt: `Resolve the merge conflict from combining \`${result.branch}\` into the integration branch for "${fanout.task}".`,
1544
- cwd: worktree.worktreePath,
1545
- conflictedFiles: conflicted,
1546
- mergeOutput: result.output,
1547
- involvedWorkers,
1548
- });
1549
- const integratorAgent = new AgentClass(
1550
- 'integrator',
1551
- integratorArgs.instructions,
1552
- integratorArgs.prompt,
1553
- integratorArgs.options,
1554
- );
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
- let integratorOk = false;
1557
- try {
1558
- const integratorOut = await integratorAgent.run({ verbose });
1559
- await jobCheckpoint();
1560
- const { summary: integratorSummary } = splitStageSummary(integratorOut.result);
1561
- printStageSummary('integrator', integratorSummary);
1562
- integratorOk = Boolean(integratorOut.ok);
1563
- } catch (err) {
1564
- logIntegration(`- Integrator agent errored: ${err.message}`);
1565
- integratorOk = false;
1566
- }
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
- const stillConflicted = integratorOk
1569
- ? hasConflictMarkersFn({ cwd: worktree.worktreePath, execFile: execFileFn })
1570
- : true;
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
- if (!stillConflicted) {
1573
- execFileFn('git', ['-C', worktree.worktreePath, 'commit']);
1574
- merged.push(result.branch);
1575
- patchIntegrationFn(cwd, parentSlug, (current) => ({
1576
- merged: [...current.merged, result.branch],
1577
- }));
1578
- logIntegration(`- Integrator resolved conflicts in \`${result.branch}\`; merge completed.`);
1579
- } else {
1580
- abortMergeFn({ cwd: worktree.worktreePath, execFile: execFileFn });
1581
- skipped.push(result.branch);
1582
- patchIntegrationFn(cwd, parentSlug, (current) => ({
1583
- skipped: [...(current.skipped ?? []), result.branch],
1584
- }));
1585
- logIntegration(`- Conflicts in \`${result.branch}\` remained unresolved; aborted merge and skipped.`);
1586
- }
1587
- }
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
- remaining = remaining.slice(results.length);
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: fanout.task,
2063
+ prompt,
1595
2064
  worktreePath: worktree.worktreePath,
1596
2065
  branch: worktree.branch,
1597
- taskPath: path.join(integrationDir, 'task.md'),
1598
- statusPath: integrationMdPath,
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: ${parentSlug} ${fanout.task.split('\n')[0]}`;
1611
- const commitResult = commitWorktreeFn({
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: `orch/${parentSlug}`,
2082
+ branch: worktree.branch,
1614
2083
  message,
1615
2084
  });
1616
2085
 
1617
- logIntegration(
1618
- commitResult.committed
1619
- ? `- Committed \`${commitResult.sha}\` on \`${commitResult.branch}\`.`
1620
- : `- No changes to commit on \`${commitResult.branch}\`.`,
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
- patchIntegrationFn(cwd, parentSlug, {
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
- merged,
1627
- skipped,
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: fanout.task,
2125
+ task: prompt,
1635
2126
  });
1636
2127
  } catch (err) {
1637
2128
  console.error(`Error: ${err.message}`);
1638
2129
  try {
1639
- logIntegration(`- Error: ${err.message}`);
1640
- } catch {
1641
- // Best-effort: don't let a log write failure mask the real error.
1642
- }
1643
- try {
1644
- patchIntegrationFn(cwd, parentSlug, { state: 'failed', merged, skipped });
2130
+ patchUnitFn(cwd, parentSlug, unitId, { state: 'failed' });
1645
2131
  } catch {
1646
- // Best-effort: don't let a fanout-state write failure mask the real error.
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: fanout?.task,
2141
+ task: prompt,
1656
2142
  });
1657
2143
  } catch {
1658
- // Best-effort: don't let a job-state write failure mask the real error.
2144
+ // Best-effort.
1659
2145
  }
1660
2146
  }
1661
- process.exit(1);
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
- * SIGINT/SIGHUP/SIGTERM cascade: reads `fanout.json`, resolves every worker's and the
1675
- * integration session's own pid via its `run.json`, filters through `isPidAlive`, and
1676
- * SIGTERMs each live one. Never touches worktrees. Composes with (does not replace)
1677
- * `lib/agent.js`'s existing `shutdown()` — the coordinator's own signal handling calls
1678
- * both this and the normal in-process agent-CLI reaping.
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 cascadeStopFanoutChildren(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
1681
- const fanout = readFanout(cwd, parentSlug);
1682
- if (!fanout) return;
1683
-
1684
- const slugs = fanout.workers.filter((worker) => worker.slug).map((worker) => worker.slug);
1685
- if (fanout.integration?.slug) slugs.push(fanout.integration.slug);
1686
-
1687
- for (const slug of slugs) {
1688
- const record = readJob(cwd, slug);
1689
- if (!record) continue;
1690
- if (isPidAliveFn(record.pid)) {
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 `cascadeStopFanoutChildren`). The coordinator signal
1699
- * handler keeps calling the child-only helper so it does not re-signal itself.
1700
- */
1701
- export function cascadeStop(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
1702
- const parent = readJob(cwd, parentSlug);
1703
- if (parent && isPidAliveFn(parent.pid)) {
1704
- kill(parent.pid, 'SIGTERM');
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(jobCwd, jobSlug, fields);
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('<task...>', 'Task description to use as the prompt (mention a file path and the agent will read it)')
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 the task into parallel workers coordinated by this process (see README Fan-out section). Cannot be combined with --ask, --quick, or --dry-run')
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), or "agn" (agn CLI). Omitting uses local then global config, else cursor')
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 a paused/pausing run
2557
- $ orch continue <slug> "new task" # new work on a finished run's worktree
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
- console.error('Error: task cannot be empty');
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.agent = resolveAgentOrExit(options.agent);
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, options);
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 finished run\'s existing worktree (not the same as resume, which only unpauses). Workers: continue the worker slug, then re-integrate the parent',
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 "agn". Omitting uses local then global config, else cursor')
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 the default agent (global ~/.orch/config or local .orch/config)')
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('--global', 'Write the global config (~/.orch/config); default when --agent is set')
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` may land on the
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
- if ((opts.global || opts.local) && !opts.agent) {
2806
- console.error('Error: --global/--local require --agent (omit flags to print config)');
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 (opts.agent) {
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, { agent: opts.agent });
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({ agent: opts.agent }, null, 2)}\n`);
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('Resume a paused (or pausing) job')
2900
- .action((slug) => {
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
- const record = readJob(cwd, slug);
2904
- if (!record) throw new Error(`requestResume: unknown job ${slug}`);
2905
- if (isCascadeParent(cwd, record)) {
2906
- cascadeResume(cwd, slug);
2907
- console.log(`resumed ${slug}`);
2908
- } else {
2909
- requestResume(cwd, slug);
2910
- console.log(`resumed ${slug}`);
2911
- }
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 the .orch folder')
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
- const removed = cleanJobs(cwd);
2975
- console.log(`deleted ${removed.length} job${removed.length === 1 ? '' : 's'} from .orch/`);
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