taskplane 0.1.17 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,17 +7,153 @@ import { join } from "path";
7
7
 
8
8
  import { runDiscovery } from "./discovery.ts";
9
9
  import { executeOrchBatch } from "./engine.ts";
10
- import { execLog, executeWave, pollUntilTaskComplete, spawnLaneSession, tmuxHasSession } from "./execution.ts";
10
+ import { computeTransitiveDependents, execLog, executeWave, pollUntilTaskComplete, spawnLaneSession, tmuxHasSession } from "./execution.ts";
11
11
  import type { MonitorUpdateCallback } from "./execution.ts";
12
12
  import { runGit } from "./git.ts";
13
- import { mergeWave } from "./merge.ts";
14
- import { ORCH_MESSAGES } from "./messages.ts";
13
+ import { mergeWaveByRepo } from "./merge.ts";
14
+ import { computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
15
+ import { resolveOperatorId } from "./naming.ts";
15
16
  import { deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
16
17
  import { StateFileError } from "./types.ts";
17
- import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
18
- import { buildDependencyGraph } from "./waves.ts";
18
+ import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, PersistedLaneRecord, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
19
+ import { buildDependencyGraph, resolveRepoRoot } from "./waves.ts";
19
20
  import { deleteBranchBestEffort, forceCleanupWorktree, listWorktrees, removeAllWorktrees, removeWorktree, safeResetWorktree } from "./worktree.ts";
20
21
 
22
+ // ── Resume Repo Helpers ──────────────────────────────────────────────
23
+
24
+ /**
25
+ * Collect unique repo roots from persisted lane records.
26
+ *
27
+ * In repo mode (no repoId on lanes), returns `[defaultRepoRoot]`.
28
+ * In workspace mode, returns one entry per unique repoId, resolved
29
+ * via `resolveRepoRoot()`. Includes the default root as a fallback
30
+ * for lanes with no repoId.
31
+ *
32
+ * Used by inter-wave worktree reset and terminal cleanup to operate
33
+ * on worktrees across all repos in the batch.
34
+ *
35
+ * @param persistedState - Loaded batch state with lane records
36
+ * @param defaultRepoRoot - Default/main repo root (cwd)
37
+ * @param workspaceConfig - Workspace configuration (null in repo mode)
38
+ * @returns Array of unique absolute repo root paths
39
+ */
40
+ export function collectRepoRoots(
41
+ persistedState: PersistedBatchState,
42
+ defaultRepoRoot: string,
43
+ workspaceConfig?: WorkspaceConfig | null,
44
+ ): string[] {
45
+ const roots = new Set<string>();
46
+
47
+ for (const lane of persistedState.lanes) {
48
+ const root = resolveRepoRoot(lane.repoId, defaultRepoRoot, workspaceConfig);
49
+ roots.add(root);
50
+ }
51
+
52
+ // Always include the default repo root (covers repo mode and any
53
+ // lanes without repoId)
54
+ roots.add(defaultRepoRoot);
55
+
56
+ return [...roots];
57
+ }
58
+
59
+ /**
60
+ * Reconstruct AllocatedLane[] from persisted lane records.
61
+ *
62
+ * Used during resume to preserve lane metadata (worktreePath, branch, repoId)
63
+ * across persistence checkpoints. Without this, the first resume checkpoint
64
+ * would serialize empty lanes, losing all lane context.
65
+ *
66
+ * When `persistedTasks` is provided, repo attribution fields (repoId,
67
+ * resolvedRepoId, taskFolder) are carried forward onto the reconstructed
68
+ * ParsedTask stubs. This ensures `serializeBatchState()` can emit repo
69
+ * fields for tasks not in `discovery.pending` (e.g., completed/failed tasks
70
+ * that have been archived).
71
+ *
72
+ * @param persistedLanes - Persisted lane records
73
+ * @param persistedTasks - Optional persisted task records for repo field carry-forward
74
+ * @returns Reconstructed AllocatedLane array with repo attribution preserved
75
+ */
76
+ export function reconstructAllocatedLanes(
77
+ persistedLanes: PersistedLaneRecord[],
78
+ persistedTasks?: PersistedBatchState["tasks"],
79
+ ): AllocatedLane[] {
80
+ // Build task lookup for repo field carry-forward
81
+ const taskLookup = new Map<string, PersistedBatchState["tasks"][0]>();
82
+ if (persistedTasks) {
83
+ for (const t of persistedTasks) {
84
+ taskLookup.set(t.taskId, t);
85
+ }
86
+ }
87
+
88
+ return persistedLanes.map((lr) => ({
89
+ laneNumber: lr.laneNumber,
90
+ laneId: lr.laneId,
91
+ tmuxSessionName: lr.tmuxSessionName,
92
+ worktreePath: lr.worktreePath,
93
+ branch: lr.branch,
94
+ tasks: lr.taskIds.map((taskId) => {
95
+ const persistedTask = taskLookup.get(taskId);
96
+ // Build a minimal ParsedTask stub that carries repo attribution
97
+ // from the persisted record. This ensures serializeBatchState()
98
+ // can emit repoId/resolvedRepoId for tasks not in discovery.
99
+ const taskStub: Partial<ParsedTask> = {};
100
+ if (persistedTask?.repoId !== undefined) {
101
+ taskStub.promptRepoId = persistedTask.repoId;
102
+ }
103
+ if (persistedTask?.resolvedRepoId !== undefined) {
104
+ taskStub.resolvedRepoId = persistedTask.resolvedRepoId;
105
+ }
106
+ if (persistedTask?.taskFolder) {
107
+ taskStub.taskFolder = persistedTask.taskFolder;
108
+ }
109
+ return {
110
+ taskId,
111
+ order: 0,
112
+ task: (Object.keys(taskStub).length > 0 ? taskStub : null) as unknown as ParsedTask,
113
+ estimatedMinutes: 0,
114
+ };
115
+ }),
116
+ strategy: "round-robin" as const,
117
+ estimatedLoad: 0,
118
+ estimatedMinutes: 0,
119
+ ...(lr.repoId !== undefined ? { repoId: lr.repoId } : {}),
120
+ }));
121
+ }
122
+
123
+ /**
124
+ * Collect unique repo roots from a combination of sources.
125
+ *
126
+ * Unlike `collectRepoRoots()` which only reads from persistedState.lanes,
127
+ * this variant merges repo roots from multiple lane sources. This is
128
+ * important during resumed execution where new waves may allocate lanes
129
+ * in repos not present in the original persisted state.
130
+ *
131
+ * @param laneSources - Array of lane arrays to collect repo roots from
132
+ * @param defaultRepoRoot - Default/main repo root (cwd)
133
+ * @param workspaceConfig - Workspace configuration (null in repo mode)
134
+ * @returns Array of unique absolute repo root paths
135
+ */
136
+ export function collectAllRepoRoots(
137
+ laneSources: Array<{ repoId?: string }[]>,
138
+ defaultRepoRoot: string,
139
+ workspaceConfig?: WorkspaceConfig | null,
140
+ ): string[] {
141
+ const roots = new Set<string>();
142
+
143
+ for (const lanes of laneSources) {
144
+ for (const lane of lanes) {
145
+ const root = resolveRepoRoot(lane.repoId, defaultRepoRoot, workspaceConfig);
146
+ roots.add(root);
147
+ }
148
+ }
149
+
150
+ // Always include the default repo root (covers repo mode and any
151
+ // lanes without repoId)
152
+ roots.add(defaultRepoRoot);
153
+
154
+ return [...roots];
155
+ }
156
+
21
157
  // ── Resume Pure Functions ────────────────────────────────────────────
22
158
 
23
159
  /**
@@ -200,7 +336,23 @@ export function reconcileTaskStates(
200
336
  };
201
337
  }
202
338
 
203
- // Precedence 5: Dead session + not terminal + no .DONE + no worktree → failed
339
+ // Precedence 5: Never-started task (pending + no session assigned) remain pending
340
+ // These are future-wave tasks that were never allocated to a lane.
341
+ // They should be re-queued for execution, not failed.
342
+ if (task.status === "pending" && !task.sessionName) {
343
+ return {
344
+ taskId: task.taskId,
345
+ persistedStatus: task.status,
346
+ liveStatus: "pending" as LaneTaskStatus,
347
+ sessionAlive: false,
348
+ doneFileFound: false,
349
+ worktreeExists: false,
350
+ action: "pending" as const,
351
+ };
352
+ }
353
+
354
+ // Precedence 6: Dead session + not terminal + no .DONE + no worktree → failed
355
+ // (Task was allocated and started but crashed without completing)
204
356
  return {
205
357
  taskId: task.taskId,
206
358
  persistedStatus: task.status,
@@ -245,13 +397,16 @@ export function computeResumePoint(
245
397
  for (const task of reconciledTasks) {
246
398
  switch (task.action) {
247
399
  case "mark-complete":
400
+ completedTaskIds.push(task.taskId);
401
+ break;
248
402
  case "skip":
249
403
  if (task.liveStatus === "succeeded" || task.persistedStatus === "succeeded") {
250
404
  completedTaskIds.push(task.taskId);
251
405
  } else if (task.liveStatus === "failed" || task.liveStatus === "stalled" || task.persistedStatus === "failed" || task.persistedStatus === "stalled") {
252
406
  failedTaskIds.push(task.taskId);
253
407
  }
254
- // skipped tasks from original run don't count as completed or failed
408
+ // persistedStatus === "skipped" terminal but neither completed nor failed.
409
+ // Not re-queued. Counted separately via batchState.skippedTasks (carried from persisted state).
255
410
  break;
256
411
  case "reconnect":
257
412
  reconnectTaskIds.push(task.taskId);
@@ -262,6 +417,11 @@ export function computeResumePoint(
262
417
  case "mark-failed":
263
418
  failedTaskIds.push(task.taskId);
264
419
  break;
420
+ case "pending":
421
+ // Never-started tasks remain pending for execution — not failed.
422
+ // These are future-wave tasks that were never allocated to a lane.
423
+ pendingTaskIds.push(task.taskId);
424
+ break;
265
425
  }
266
426
  }
267
427
 
@@ -273,18 +433,17 @@ export function computeResumePoint(
273
433
  const allDone = waveTasks.every((taskId) => {
274
434
  const reconciled = reconciledMap.get(taskId);
275
435
  if (!reconciled) return false;
276
- // A task is "done" for wave-skip purposes if it completed or failed terminally
277
- return (
278
- reconciled.action === "mark-complete" ||
279
- (reconciled.action === "skip" && (
280
- reconciled.liveStatus === "succeeded" ||
281
- reconciled.liveStatus === "failed" ||
282
- reconciled.liveStatus === "stalled" ||
283
- reconciled.persistedStatus === "succeeded" ||
284
- reconciled.persistedStatus === "failed" ||
285
- reconciled.persistedStatus === "stalled"
286
- ))
287
- );
436
+ // A task is "done" for wave-skip purposes if it's terminal:
437
+ // mark-complete, mark-failed, or skip with any terminal status
438
+ // (succeeded, failed, stalled, skipped)
439
+ if (reconciled.action === "mark-complete" || reconciled.action === "mark-failed") {
440
+ return true;
441
+ }
442
+ if (reconciled.action === "skip") {
443
+ const s = reconciled.liveStatus ?? reconciled.persistedStatus;
444
+ return s === "succeeded" || s === "failed" || s === "stalled" || s === "skipped";
445
+ }
446
+ return false;
288
447
  });
289
448
 
290
449
  if (!allDone) {
@@ -314,6 +473,10 @@ export function computeResumePoint(
314
473
  // Skipped tasks that were pending need execution
315
474
  actualPendingTaskIds.push(taskId);
316
475
  }
476
+ if (reconciled.action === "pending") {
477
+ // Never-started tasks from future waves need execution
478
+ actualPendingTaskIds.push(taskId);
479
+ }
317
480
  }
318
481
  }
319
482
 
@@ -444,6 +607,7 @@ export async function resumeOrchBatch(
444
607
  batchState.phase = "executing";
445
608
  batchState.batchId = persistedState.batchId;
446
609
  batchState.baseBranch = persistedState.baseBranch || "";
610
+ batchState.mode = persistedState.mode;
447
611
  batchState.startedAt = persistedState.startedAt;
448
612
  batchState.pauseSignal = { paused: false };
449
613
  batchState.totalWaves = persistedState.totalWaves;
@@ -453,6 +617,33 @@ export async function resumeOrchBatch(
453
617
  batchState.skippedTasks = persistedState.skippedTasks;
454
618
  batchState.blockedTasks = persistedState.blockedTasks;
455
619
  batchState.blockedTaskIds = new Set(persistedState.blockedTaskIds);
620
+ // Track persisted blocked IDs separately to avoid double-counting in wave loop.
621
+ // Engine.ts counts blocked tasks per-wave when a wave is entered. If the prior
622
+ // run paused before reaching a wave, tasks blocked for that wave are in
623
+ // `blockedTaskIds` but NOT yet counted in `blockedTasks`. On resume, the
624
+ // per-wave counting loop excludes `persistedBlockedTaskIds`, so those tasks
625
+ // would never be counted. Fix: count persisted blocked tasks in future waves
626
+ // (waves >= resumeWaveIndex) that were not yet counted.
627
+ const persistedBlockedTaskIds = new Set(persistedState.blockedTaskIds);
628
+
629
+ // Count persisted-blocked tasks in unvisited waves (wave >= resumeWaveIndex).
630
+ // These were added to blockedTaskIds in the prior run but their wave was never
631
+ // entered, so they were never counted in blockedTasks.
632
+ if (persistedBlockedTaskIds.size > 0) {
633
+ let uncountedBlocked = 0;
634
+ for (let wi = resumePoint.resumeWaveIndex; wi < persistedState.wavePlan.length; wi++) {
635
+ for (const taskId of persistedState.wavePlan[wi]) {
636
+ if (persistedBlockedTaskIds.has(taskId)) {
637
+ uncountedBlocked++;
638
+ }
639
+ }
640
+ }
641
+ if (uncountedBlocked > 0) {
642
+ batchState.blockedTasks += uncountedBlocked;
643
+ execLog("resume", persistedState.batchId, `blocked counter fix: ${uncountedBlocked} persisted-blocked task(s) in unvisited waves added to blockedTasks`);
644
+ }
645
+ }
646
+
456
647
  batchState.errors = [...persistedState.errors];
457
648
  batchState.endedAt = null;
458
649
  batchState.currentWaveIndex = resumePoint.resumeWaveIndex;
@@ -507,10 +698,15 @@ export async function resumeOrchBatch(
507
698
  strategy: "round-robin",
508
699
  estimatedLoad: 0,
509
700
  estimatedMinutes: 0,
701
+ ...(laneRecord.repoId !== undefined ? { repoId: laneRecord.repoId } : {}),
510
702
  };
511
703
 
704
+ // Resolve per-lane repo root for workspace mode (v1/repo mode: falls back to repoRoot)
705
+ const laneRepoRoot = resolveRepoRoot(laneRecord.repoId, repoRoot, workspaceConfig);
706
+
512
707
  execLog("resume", task.taskId, "reconnecting to alive session", {
513
708
  session: laneRecord.tmuxSessionName,
709
+ repoId: laneRecord.repoId ?? "(default)",
514
710
  });
515
711
 
516
712
  // Poll until task completes
@@ -519,7 +715,7 @@ export async function resumeOrchBatch(
519
715
  lane,
520
716
  allocatedTask,
521
717
  orchConfig,
522
- repoRoot,
718
+ laneRepoRoot,
523
719
  batchState.pauseSignal,
524
720
  );
525
721
 
@@ -586,20 +782,25 @@ export async function resumeOrchBatch(
586
782
  strategy: "round-robin",
587
783
  estimatedLoad: 0,
588
784
  estimatedMinutes: 0,
785
+ ...(laneRecord.repoId !== undefined ? { repoId: laneRecord.repoId } : {}),
589
786
  };
590
787
 
788
+ // Resolve per-lane repo root for workspace mode (v1/repo mode: falls back to repoRoot)
789
+ const reExecRepoRoot = resolveRepoRoot(laneRecord.repoId, repoRoot, workspaceConfig);
790
+
591
791
  execLog("resume", task.taskId, "re-executing interrupted task in existing worktree", {
592
792
  session: laneRecord.tmuxSessionName,
593
793
  worktree: laneRecord.worktreePath,
794
+ repoId: laneRecord.repoId ?? "(default)",
594
795
  });
595
796
 
596
797
  try {
597
- spawnLaneSession(lane, allocatedTask, orchConfig, repoRoot);
798
+ spawnLaneSession(lane, allocatedTask, orchConfig, reExecRepoRoot);
598
799
  const pollResult = await pollUntilTaskComplete(
599
800
  lane,
600
801
  allocatedTask,
601
802
  orchConfig,
602
- repoRoot,
803
+ reExecRepoRoot,
603
804
  batchState.pauseSignal,
604
805
  );
605
806
 
@@ -645,7 +846,7 @@ export async function resumeOrchBatch(
645
846
  "info",
646
847
  );
647
848
 
648
- // Build synthetic WaveExecutionResult for mergeWave()
849
+ // Build synthetic WaveExecutionResult for mergeWaveByRepo()
649
850
  const syntheticLaneResults: LaneExecutionResult[] = reExecAllocatedLanes.map(lane => ({
650
851
  laneNumber: lane.laneNumber,
651
852
  laneId: lane.laneId,
@@ -663,8 +864,16 @@ export async function resumeOrchBatch(
663
864
  endTime: Date.now(),
664
865
  }));
665
866
 
867
+ // Use waveIndex -1 as a sentinel for "pre-wave-loop re-exec merge".
868
+ // mergeWaveByRepo expects 1-indexed waveIndex; persistence normalizes
869
+ // to 0-based via `mr.waveIndex - 1`. By passing -1 here:
870
+ // - mergeWaveByRepo logs it as "W-1" (harmless)
871
+ // - persistence normalizes to `Math.max(0, -1 - 1)` = 0 (valid)
872
+ // - semantically distinguishes re-exec merges from wave 1 merges
873
+ const RE_EXEC_WAVE_INDEX = -1;
874
+
666
875
  const syntheticWaveResult: WaveExecutionResult = {
667
- waveIndex: 0,
876
+ waveIndex: RE_EXEC_WAVE_INDEX,
668
877
  startedAt: Date.now(),
669
878
  endedAt: Date.now(),
670
879
  laneResults: syntheticLaneResults,
@@ -680,14 +889,15 @@ export async function resumeOrchBatch(
680
889
  allocatedLanes: reExecAllocatedLanes,
681
890
  };
682
891
 
683
- const reExecMergeResult = mergeWave(
892
+ const reExecMergeResult = mergeWaveByRepo(
684
893
  reExecAllocatedLanes,
685
894
  syntheticWaveResult,
686
- 0,
895
+ RE_EXEC_WAVE_INDEX,
687
896
  orchConfig,
688
897
  repoRoot,
689
898
  batchState.batchId,
690
899
  batchState.baseBranch,
900
+ workspaceConfig,
691
901
  );
692
902
 
693
903
  if (reExecMergeResult.status === "succeeded") {
@@ -696,11 +906,11 @@ export async function resumeOrchBatch(
696
906
  "info",
697
907
  );
698
908
 
699
- // Clean up merged branches
700
- const targetBranch = batchState.baseBranch;
909
+ // Clean up merged branches (resolve per-lane repo root for workspace mode)
701
910
  for (const lr of reExecMergeResult.laneResults) {
702
911
  if (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") {
703
- deleteBranchBestEffort(lr.sourceBranch, repoRoot);
912
+ const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig);
913
+ deleteBranchBestEffort(lr.sourceBranch, laneRepoRoot);
704
914
  }
705
915
  }
706
916
  } else {
@@ -718,7 +928,21 @@ export async function resumeOrchBatch(
718
928
  // Track state for persistence
719
929
  const wavePlan = persistedState.wavePlan;
720
930
  const allTaskOutcomes: LaneTaskOutcome[] = [];
721
- let latestAllocatedLanes: AllocatedLane[] = [];
931
+
932
+ // Initialize latestAllocatedLanes from persisted lane records so that
933
+ // early persistence calls (before the first resumed wave) retain lane
934
+ // records with repo attribution (laneNumber, laneId, branch, repoId).
935
+ // Without this, the `resume-reconciliation` checkpoint would serialize
936
+ // empty lanes[], losing all lane context until a new wave allocates.
937
+ let latestAllocatedLanes: AllocatedLane[] = reconstructAllocatedLanes(persistedState.lanes, persistedState.tasks);
938
+
939
+ // Track all repo roots encountered during execution (persisted + newly allocated).
940
+ // Used by inter-wave reset and terminal cleanup to cover repos introduced
941
+ // after resume starts (not present in persisted lanes).
942
+ // Initialized from collectRepoRoots() helper for parity with other callers.
943
+ const encounteredRepoRoots = new Set(
944
+ collectRepoRoots(persistedState, repoRoot, workspaceConfig),
945
+ );
722
946
 
723
947
  // Build outcomes from reconciled tasks
724
948
  for (const task of reconciledTasks) {
@@ -748,6 +972,23 @@ export async function resumeOrchBatch(
748
972
  });
749
973
  }
750
974
 
975
+ // ── 9b. Seed blocked dependents from reconciled failures ─────
976
+ // Under skip-dependents policy, failures discovered during reconciliation
977
+ // (mark-failed) or resolved during reconnect/re-execute must propagate
978
+ // to their transitive dependents BEFORE the wave loop begins.
979
+ if (orchConfig.failure.on_task_failure === "skip-dependents" && failedTaskSet.size > 0) {
980
+ const reconciledBlocked = computeTransitiveDependents(failedTaskSet, depGraph);
981
+ for (const taskId of reconciledBlocked) {
982
+ batchState.blockedTaskIds.add(taskId);
983
+ }
984
+ if (reconciledBlocked.size > 0) {
985
+ execLog("resume", batchState.batchId, `skip-dependents: ${reconciledBlocked.size} task(s) blocked from reconciled failures`, {
986
+ blocked: [...reconciledBlocked].sort().join(","),
987
+ sources: [...failedTaskSet].sort().join(","),
988
+ });
989
+ }
990
+ }
991
+
751
992
  persistRuntimeState("resume-reconciliation", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery ?? null, repoRoot);
752
993
 
753
994
  // ── 10. Continue wave execution ──────────────────────────────
@@ -778,8 +1019,12 @@ export async function resumeOrchBatch(
778
1019
  // Also filter tasks where discovery doesn't have them as pending
779
1020
  waveTasks = waveTasks.filter(taskId => discovery.pending.has(taskId));
780
1021
 
1022
+ // Count only newly blocked tasks (not already persisted) to avoid double-counting.
1023
+ // persistedState.blockedTaskIds were already counted in persistedState.blockedTasks
1024
+ // which initialized batchState.blockedTasks.
781
1025
  const blockedInWave = persistedState.wavePlan[waveIdx].filter(
782
- taskId => batchState.blockedTaskIds.has(taskId),
1026
+ taskId => batchState.blockedTaskIds.has(taskId) &&
1027
+ !persistedBlockedTaskIds.has(taskId),
783
1028
  );
784
1029
  if (blockedInWave.length > 0) {
785
1030
  batchState.blockedTasks += blockedInWave.length;
@@ -818,10 +1063,15 @@ export async function resumeOrchBatch(
818
1063
  (lanes) => {
819
1064
  latestAllocatedLanes = lanes;
820
1065
  batchState.currentLanes = lanes;
1066
+ // Track repos from newly allocated lanes for cleanup coverage
1067
+ for (const lane of lanes) {
1068
+ encounteredRepoRoots.add(resolveRepoRoot(lane.repoId, repoRoot, workspaceConfig));
1069
+ }
821
1070
  if (seedPendingOutcomesForAllocatedLanes(lanes, allTaskOutcomes)) {
822
1071
  persistRuntimeState("wave-lanes-allocated", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, repoRoot);
823
1072
  }
824
1073
  },
1074
+ workspaceConfig,
825
1075
  );
826
1076
 
827
1077
  batchState.waveResults.push(waveResult);
@@ -916,7 +1166,7 @@ export async function resumeOrchBatch(
916
1166
  persistRuntimeState("merge-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, repoRoot);
917
1167
  onNotify(ORCH_MESSAGES.orchMergeStart(waveIdx + 1, mergeableLaneCount), "info");
918
1168
 
919
- mergeResult = mergeWave(
1169
+ mergeResult = mergeWaveByRepo(
920
1170
  waveResult.allocatedLanes,
921
1171
  waveResult,
922
1172
  waveIdx + 1,
@@ -924,6 +1174,7 @@ export async function resumeOrchBatch(
924
1174
  repoRoot,
925
1175
  batchState.batchId,
926
1176
  batchState.baseBranch,
1177
+ workspaceConfig,
927
1178
  );
928
1179
  batchState.mergeResults.push(mergeResult);
929
1180
 
@@ -961,6 +1212,14 @@ export async function resumeOrchBatch(
961
1212
  ORCH_MESSAGES.orchMergeFailed(waveIdx + 1, mergeResult.failedLane ?? 0, mergeResult.failureReason || "unknown"),
962
1213
  "error",
963
1214
  );
1215
+
1216
+ // Emit repo-divergence summary when partial is caused by cross-repo outcome differences
1217
+ if (mergeResult.status === "partial") {
1218
+ const repoSummary = formatRepoMergeSummary(mergeResult);
1219
+ if (repoSummary) {
1220
+ onNotify(repoSummary, "warning");
1221
+ }
1222
+ }
964
1223
  }
965
1224
 
966
1225
  batchState.phase = "executing";
@@ -988,48 +1247,28 @@ export async function resumeOrchBatch(
988
1247
  onNotify(ORCH_MESSAGES.orchMergeSkipped(waveIdx + 1), "info");
989
1248
  }
990
1249
 
991
- // Handle merge failure
1250
+ // Handle merge failure — shared helper guarantees parity with engine.ts (TP-005 Step 2)
992
1251
  if (mergeResult && (mergeResult.status === "failed" || mergeResult.status === "partial")) {
993
- const mergeFailurePolicy = orchConfig.failure.on_merge_failure;
1252
+ const policyResult = computeMergeFailurePolicy(mergeResult, waveIdx, orchConfig);
994
1253
 
995
- if (mergeFailurePolicy === "pause") {
996
- batchState.phase = "paused";
997
- batchState.errors.push(
998
- `Merge failed at wave ${waveIdx + 1}: ${mergeResult.failureReason || "unknown"}. ` +
999
- `Batch paused. Resolve conflicts and use /orch-resume to continue.`,
1000
- );
1001
- persistRuntimeState("merge-failure-pause", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, repoRoot);
1002
- onNotify(
1003
- `⏸️ Batch paused due to merge failure at wave ${waveIdx + 1}. ` +
1004
- `Resolve conflicts and resume.`,
1005
- "error",
1006
- );
1007
- preserveWorktreesForResume = true;
1008
- break;
1009
- } else {
1010
- batchState.phase = "stopped";
1011
- batchState.errors.push(
1012
- `Merge failed at wave ${waveIdx + 1}: ${mergeResult.failureReason || "unknown"}. ` +
1013
- `Batch aborted by on_merge_failure policy.`,
1014
- );
1015
- persistRuntimeState("merge-failure-abort", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, repoRoot);
1016
- onNotify(
1017
- `⛔ Batch aborted due to merge failure at wave ${waveIdx + 1}.`,
1018
- "error",
1019
- );
1020
- preserveWorktreesForResume = true;
1021
- break;
1022
- }
1254
+ execLog("batch", batchState.batchId, `merge failure — applying ${policyResult.policy} policy`, policyResult.logDetails);
1255
+
1256
+ batchState.phase = policyResult.targetPhase;
1257
+ batchState.errors.push(policyResult.errorMessage);
1258
+ persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, repoRoot);
1259
+ onNotify(policyResult.notifyMessage, policyResult.notifyLevel);
1260
+ preserveWorktreesForResume = true;
1261
+ break;
1023
1262
  }
1024
1263
 
1025
1264
  // Post-merge: reset worktrees for next wave
1026
1265
  if (mergeResult && mergeResult.status === "succeeded") {
1027
- const targetBranch = batchState.baseBranch;
1028
1266
  for (const lr of mergeResult.laneResults) {
1029
1267
  if (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") {
1030
- const ancestorCheck = runGit(["merge-base", "--is-ancestor", lr.sourceBranch, targetBranch], repoRoot);
1268
+ const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig);
1269
+ const ancestorCheck = runGit(["merge-base", "--is-ancestor", lr.sourceBranch, lr.targetBranch], laneRepoRoot);
1031
1270
  if (ancestorCheck.ok) {
1032
- deleteBranchBestEffort(lr.sourceBranch, repoRoot);
1271
+ deleteBranchBestEffort(lr.sourceBranch, laneRepoRoot);
1033
1272
  }
1034
1273
  }
1035
1274
  }
@@ -1037,16 +1276,23 @@ export async function resumeOrchBatch(
1037
1276
 
1038
1277
  if (waveIdx < persistedState.wavePlan.length - 1 && !batchState.pauseSignal.paused) {
1039
1278
  const wtPrefix = orchConfig.orchestrator.worktree_prefix;
1040
- const existingWorktrees = listWorktrees(wtPrefix, repoRoot);
1041
- if (existingWorktrees.length > 0) {
1042
- const targetBranch = batchState.baseBranch;
1043
- for (const wt of existingWorktrees) {
1044
- const resetResult = safeResetWorktree(wt, targetBranch, repoRoot);
1045
- if (!resetResult.success) {
1046
- try {
1047
- removeWorktree(wt, repoRoot);
1048
- } catch {
1049
- forceCleanupWorktree(wt, repoRoot, batchState.batchId);
1279
+ const resetOpId = resolveOperatorId(orchConfig);
1280
+
1281
+ // Use encounteredRepoRoots which includes both persisted lanes
1282
+ // AND newly allocated lanes from resumed waves, ensuring repos
1283
+ // introduced after resume starts are covered.
1284
+ for (const perRepoRoot of encounteredRepoRoots) {
1285
+ const existingWorktrees = listWorktrees(wtPrefix, perRepoRoot, resetOpId);
1286
+ if (existingWorktrees.length > 0) {
1287
+ const targetBranch = batchState.baseBranch;
1288
+ for (const wt of existingWorktrees) {
1289
+ const resetResult = safeResetWorktree(wt, targetBranch, perRepoRoot);
1290
+ if (!resetResult.success) {
1291
+ try {
1292
+ removeWorktree(wt, perRepoRoot);
1293
+ } catch {
1294
+ forceCleanupWorktree(wt, perRepoRoot, batchState.batchId);
1295
+ }
1050
1296
  }
1051
1297
  }
1052
1298
  }
@@ -1057,8 +1303,15 @@ export async function resumeOrchBatch(
1057
1303
  // ── 11. Cleanup and terminal state ───────────────────────────
1058
1304
  if (!preserveWorktreesForResume) {
1059
1305
  const wtPrefix = orchConfig.orchestrator.worktree_prefix;
1306
+ const cleanupOpId = resolveOperatorId(orchConfig);
1060
1307
  const targetBranch = batchState.baseBranch;
1061
- removeAllWorktrees(wtPrefix, repoRoot, targetBranch);
1308
+
1309
+ // Use encounteredRepoRoots which includes both persisted lanes
1310
+ // AND newly allocated lanes from resumed waves, ensuring repos
1311
+ // introduced after resume starts are cleaned up.
1312
+ for (const perRepoRoot of encounteredRepoRoots) {
1313
+ removeAllWorktrees(wtPrefix, perRepoRoot, cleanupOpId, targetBranch);
1314
+ }
1062
1315
  }
1063
1316
 
1064
1317
  batchState.endedAt = Date.now();