taskplane 0.5.12 → 0.6.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.
@@ -2,7 +2,7 @@
2
2
  * Main batch execution engine
3
3
  * @module orch/engine
4
4
  */
5
- import { readFileSync, readdirSync, unlinkSync } from "fs";
5
+ import { existsSync, readdirSync, readFileSync, unlinkSync } from "fs";
6
6
  import { join, resolve } from "path";
7
7
 
8
8
  import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
@@ -10,14 +10,16 @@ import { execLog, executeWave, tmuxKillSession } from "./execution.ts";
10
10
  import type { MonitorUpdateCallback } from "./execution.ts";
11
11
  import { getCurrentBranch, runGit } from "./git.ts";
12
12
  import { attemptAutoIntegration, mergeWaveByRepo } from "./merge.ts";
13
- import { computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
13
+ import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
14
+ import type { CleanupGateRepoFailure } from "./messages.ts";
15
+ import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
14
16
  import { resolveOperatorId } from "./naming.ts";
15
- import { deleteBatchState, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
17
+ import { applyPartialProgressToOutcomes, deleteBatchState, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
16
18
  import { listOrchSessions } from "./sessions.ts";
17
- import { FATAL_DISCOVERY_CODES, generateBatchId } from "./types.ts";
19
+ import { defaultResilienceState, FATAL_DISCOVERY_CODES, generateBatchId } from "./types.ts";
18
20
  import type { AllocatedLane, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, TaskRunnerConfig, TokenCounts, WorkspaceConfig } from "./types.ts";
19
- import { buildDependencyGraph, computeWaves, resolveRepoRoot, validateGraph } from "./waves.ts";
20
- import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
21
+ import { buildDependencyGraph, computeWaves, resolveBaseBranch, resolveRepoRoot, validateGraph } from "./waves.ts";
22
+ import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, preserveFailedLaneProgress, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
21
23
 
22
24
  // ── /orch Execution Engine ───────────────────────────────────────────
23
25
 
@@ -87,6 +89,12 @@ export async function executeOrchBatch(
87
89
  let wavePlan: string[][] = [];
88
90
  // Reference to discovery result for enriching taskFolder paths.
89
91
  let discoveryRef: DiscoveryResult | null = null;
92
+ // TP-029: Track all repo roots encountered during execution.
93
+ // Maps repoRoot → repoId (undefined for primary/repo-mode).
94
+ // Used by inter-wave reset and terminal cleanup to iterate ALL repos
95
+ // that had lanes, not just the primary repoRoot. Parity with resume.ts.
96
+ const encounteredRepoRoots = new Map<string, string | undefined>();
97
+ encounteredRepoRoots.set(repoRoot, undefined); // always include primary
90
98
 
91
99
  execLog("batch", batchState.batchId, "starting batch planning");
92
100
 
@@ -303,6 +311,11 @@ export async function executeOrchBatch(
303
311
  (lanes) => {
304
312
  latestAllocatedLanes = lanes;
305
313
  batchState.currentLanes = lanes;
314
+ // TP-029: Track repos from newly allocated lanes for cleanup coverage
315
+ for (const lane of lanes) {
316
+ const laneRepoRoot = resolveRepoRoot(lane.repoId, repoRoot, workspaceConfig);
317
+ encounteredRepoRoots.set(laneRepoRoot, lane.repoId);
318
+ }
306
319
  if (seedPendingOutcomesForAllocatedLanes(lanes, allTaskOutcomes)) {
307
320
  persistRuntimeState("wave-lanes-allocated", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
308
321
  }
@@ -410,6 +423,7 @@ export async function executeOrchBatch(
410
423
  workspaceConfig,
411
424
  stateRoot,
412
425
  agentRoot,
426
+ runnerConfig.testing_commands,
413
427
  );
414
428
  allMergeResults.push(mergeResult);
415
429
  batchState.mergeResults.push(mergeResult);
@@ -420,14 +434,16 @@ export async function executeOrchBatch(
420
434
  // Emit per-lane merge notifications
421
435
  for (const lr of mergeResult.laneResults) {
422
436
  const durationSec = Math.round(lr.durationMs / 1000);
423
- if (lr.result?.status === "SUCCESS") {
437
+ // TP-032 R006-3: Check lr.error first verification_new_failure lanes
438
+ // have error set even though lr.result.status may be SUCCESS/CONFLICT_RESOLVED.
439
+ if (lr.error) {
440
+ onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error), "error");
441
+ } else if (lr.result?.status === "SUCCESS") {
424
442
  onNotify(ORCH_MESSAGES.orchMergeLaneSuccess(lr.laneNumber, lr.result.merge_commit, durationSec), "info");
425
443
  } else if (lr.result?.status === "CONFLICT_RESOLVED") {
426
444
  onNotify(ORCH_MESSAGES.orchMergeLaneConflictResolved(lr.laneNumber, lr.result.conflicts.length, durationSec), "info");
427
445
  } else if (lr.result?.status === "CONFLICT_UNRESOLVED" || lr.result?.status === "BUILD_FAILURE") {
428
- onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error || lr.result.status), "error");
429
- } else if (lr.error) {
430
- onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error), "error");
446
+ onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.result.status), "error");
431
447
  }
432
448
  }
433
449
 
@@ -450,8 +466,9 @@ export async function executeOrchBatch(
450
466
  }
451
467
 
452
468
  // Emit overall merge result notification
469
+ // TP-032 R006-3: Exclude verification_new_failure lanes from success count
453
470
  const mergedCount = mergeResult.laneResults.filter(
454
- r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
471
+ r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
455
472
  ).length;
456
473
  const mergeTotalSec = Math.round(mergeResult.totalDurationMs / 1000);
457
474
 
@@ -501,50 +518,244 @@ export async function executeOrchBatch(
501
518
  onNotify(ORCH_MESSAGES.orchMergeSkipped(waveIdx + 1), "info");
502
519
  }
503
520
 
521
+ // ── TP-033: Safe-stop on rollback failure ─────────────────
522
+ // When a verification rollback failed, force paused regardless of
523
+ // on_merge_failure policy. The merge worktree and temp branch are
524
+ // preserved for manual recovery using commands in the transaction record.
525
+ if (mergeResult?.rollbackFailed) {
526
+ // TP-033 R004-2: Include persistence error warning when transaction
527
+ // record files may be missing, so operator knows to inspect manually
528
+ const hasPersistErrors = mergeResult.persistenceErrors && mergeResult.persistenceErrors.length > 0;
529
+ const persistWarning = hasPersistErrors
530
+ ? ` WARNING: ${mergeResult.persistenceErrors!.length} transaction record(s) failed to persist — recovery file(s) may be missing.`
531
+ : "";
532
+
533
+ execLog("batch", batchState.batchId, "SAFE-STOP: verification rollback failed — forcing paused regardless of policy", {
534
+ waveIndex: waveIdx,
535
+ configPolicy: orchConfig.failure.on_merge_failure,
536
+ ...(hasPersistErrors ? { persistenceErrors: mergeResult.persistenceErrors } : {}),
537
+ });
538
+
539
+ batchState.phase = "paused";
540
+ batchState.errors.push(
541
+ `Safe-stop at wave ${waveIdx + 1}: verification rollback failed. ` +
542
+ `Merge worktree and temp branch preserved for recovery. ` +
543
+ `Check transaction records in .pi/verification/ for recovery commands.` +
544
+ persistWarning
545
+ );
546
+ persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
547
+ onNotify(
548
+ `🛑 Safe-stop: verification rollback failed at wave ${waveIdx + 1}. ` +
549
+ `Batch force-paused. Merge worktree preserved for manual recovery. ` +
550
+ `See .pi/verification/ transaction records for recovery commands.` +
551
+ persistWarning,
552
+ "error",
553
+ );
554
+ preserveWorktreesForResume = true;
555
+ break;
556
+ }
557
+
504
558
  // ── Handle merge failure ─────────────────────────────────
505
- // Apply config.failure.on_merge_failure policy via shared helper
506
- // for guaranteed parity with resume.ts (TP-005 Step 2).
559
+ // TP-033 Step 2 (R006): Retry policy matrix via shared applyMergeRetryLoop.
560
+ // Classifies the failure, loops retries per the matrix (supports maxAttempts>1),
561
+ // and on exhaustion forces paused regardless of on_merge_failure config.
507
562
  if (mergeResult && (mergeResult.status === "failed" || mergeResult.status === "partial")) {
508
- const policyResult = computeMergeFailurePolicy(mergeResult, waveIdx, orchConfig);
563
+ // Initialize resilience state if not yet present (fresh batch)
564
+ if (!batchState.resilience) {
565
+ batchState.resilience = defaultResilienceState();
566
+ }
509
567
 
510
- execLog("batch", batchState.batchId, `merge failure — applying ${policyResult.policy} policy`, policyResult.logDetails);
568
+ const retryOutcome = applyMergeRetryLoop(
569
+ mergeResult,
570
+ waveIdx,
571
+ batchState.resilience.retryCountByScope,
572
+ {
573
+ performMerge: () => {
574
+ batchState.phase = "merging";
575
+ return mergeWaveByRepo(
576
+ waveResult.allocatedLanes,
577
+ waveResult,
578
+ waveIdx + 1,
579
+ orchConfig,
580
+ repoRoot,
581
+ batchState.batchId,
582
+ batchState.orchBranch,
583
+ workspaceConfig,
584
+ stateRoot,
585
+ agentRoot,
586
+ runnerConfig.testing_commands,
587
+ );
588
+ },
589
+ persist: (trigger) => persistRuntimeState(trigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot),
590
+ log: (message, details) => execLog("batch", batchState.batchId, message, details),
591
+ notify: (message, level) => onNotify(message, level),
592
+ updateMergeResult: (result) => {
593
+ mergeResult = result;
594
+ allMergeResults[allMergeResults.length - 1] = result;
595
+ batchState.mergeResults[batchState.mergeResults.length - 1] = result;
596
+ },
597
+ sleep: sleepSync,
598
+ },
599
+ );
511
600
 
512
- batchState.phase = policyResult.targetPhase;
513
- batchState.errors.push(policyResult.errorMessage);
514
- persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
515
- onNotify(policyResult.notifyMessage, policyResult.notifyLevel);
516
- // DO NOT cleanup/reset worktrees preserve state for debugging/resume
517
- preserveWorktreesForResume = true;
518
- break;
601
+ if (retryOutcome.kind === "retry_succeeded") {
602
+ mergeResult = retryOutcome.mergeResult;
603
+ batchState.phase = "executing";
604
+ persistRuntimeState("merge-retry-succeeded", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
605
+ // Fall through to normal post-merge flow (worktree cleanup, etc.)
606
+ } else if (retryOutcome.kind === "safe_stop") {
607
+ mergeResult = retryOutcome.mergeResult;
608
+ batchState.phase = "paused";
609
+ batchState.errors.push(retryOutcome.errorMessage);
610
+ persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
611
+ onNotify(retryOutcome.notifyMessage, "error");
612
+ preserveWorktreesForResume = true;
613
+ break;
614
+ } else if (retryOutcome.kind === "exhausted") {
615
+ // TP-033 R006-2: Force paused regardless of on_merge_failure config.
616
+ // Retry exhaustion takes precedence over config policy.
617
+ mergeResult = retryOutcome.mergeResult;
618
+ const exhaustionMsg = retryOutcome.errorMessage +
619
+ ` [${retryOutcome.classification ?? "unknown"} ${retryOutcome.lastDecision.currentAttempt}/${retryOutcome.lastDecision.maxAttempts}, scope=${retryOutcome.scopeKey}]`;
620
+
621
+ execLog("batch", batchState.batchId, `merge retry exhausted — forcing paused`, {
622
+ classification: retryOutcome.classification,
623
+ scopeKey: retryOutcome.scopeKey,
624
+ attempts: retryOutcome.lastDecision.currentAttempt,
625
+ maxAttempts: retryOutcome.lastDecision.maxAttempts,
626
+ });
627
+
628
+ batchState.phase = "paused";
629
+ batchState.errors.push(exhaustionMsg);
630
+ persistRuntimeState("merge-retry-exhausted", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
631
+ onNotify(retryOutcome.notifyMessage, "error");
632
+ preserveWorktreesForResume = true;
633
+ break;
634
+ } else {
635
+ // kind === "no_retry": fall through to standard on_merge_failure policy
636
+ mergeResult = retryOutcome.mergeResult;
637
+ const policyResult = computeMergeFailurePolicy(mergeResult, waveIdx, orchConfig);
638
+ const classNote = retryOutcome.classification
639
+ ? ` [not retriable: ${retryOutcome.classification}, scope=${retryOutcome.scopeKey}]`
640
+ : "";
641
+
642
+ execLog("batch", batchState.batchId, `merge failure — applying ${policyResult.policy} policy${classNote}`, policyResult.logDetails);
643
+
644
+ batchState.phase = policyResult.targetPhase;
645
+ batchState.errors.push(policyResult.errorMessage + classNote);
646
+ persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
647
+ onNotify(policyResult.notifyMessage + classNote, policyResult.notifyLevel);
648
+ // DO NOT cleanup/reset worktrees — preserve state for debugging/resume
649
+ preserveWorktreesForResume = true;
650
+ break;
651
+ }
519
652
  }
520
653
 
521
654
  // NOTE: Merged branch cleanup is deferred to Phase 3, AFTER worktree
522
655
  // removal. git branch -D fails if a worktree has the branch checked out.
523
656
 
657
+ // ── TP-028: Preserve partial progress before inter-wave reset ──
658
+ // Failed tasks may have commits on their lane branch that would be lost
659
+ // when the worktree is reset for the next wave. Save these as named
660
+ // branches before any branch-destructive reset/removal occurs.
661
+ // Hoisted outside the if-block so unsafeBranches is accessible to the
662
+ // reset loop below — both blocks share the same guard condition.
663
+ let ppUnsafeBranches = new Set<string>();
664
+ if (waveIdx < rawWaves.length - 1 && !batchState.pauseSignal.paused) {
665
+ const ppOpId = resolveOperatorId(orchConfig);
666
+ const ppResult = preserveFailedLaneProgress(
667
+ latestAllocatedLanes,
668
+ allTaskOutcomes,
669
+ ppOpId,
670
+ batchState.batchId,
671
+ (repoId) => {
672
+ const perRepoRoot = resolveRepoRoot(repoId, repoRoot, workspaceConfig);
673
+ let targetBranch = batchState.orchBranch;
674
+ if (repoId && perRepoRoot !== repoRoot) {
675
+ try {
676
+ targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
677
+ } catch { /* fall back to orchBranch */ }
678
+ }
679
+ return { repoRoot: perRepoRoot, targetBranch };
680
+ },
681
+ );
682
+ ppUnsafeBranches = ppResult.unsafeBranches;
683
+ if (ppResult.results.some(r => r.saved)) {
684
+ execLog("batch", batchState.batchId,
685
+ `preserved partial progress for ${ppResult.results.filter(r => r.saved).length} failed task(s) before inter-wave reset`);
686
+ }
687
+ // Log per-task warnings for failed preservation attempts
688
+ for (const r of ppResult.results) {
689
+ if (!r.saved && (r.commitCount > 0 || r.error)) {
690
+ execLog("batch", batchState.batchId,
691
+ `WARNING: Failed to preserve partial progress for task ${r.taskId} ` +
692
+ `(${r.commitCount} commit(s) at risk on lane branch)`,
693
+ { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" });
694
+ }
695
+ }
696
+ if (ppUnsafeBranches.size > 0) {
697
+ execLog("batch", batchState.batchId,
698
+ `WARNING: ${ppUnsafeBranches.size} lane branch(es) could not be preserved — skipping reset for those lanes to prevent commit loss`,
699
+ { unsafeBranches: [...ppUnsafeBranches] });
700
+ }
701
+ // TP-028: Stamp task outcomes with partial progress data for persistence
702
+ applyPartialProgressToOutcomes(ppResult, allTaskOutcomes);
703
+ }
704
+
524
705
  // ── Post-merge: Reset worktrees for next wave ────────────
525
- // Only reset if merge succeeded AND there are more waves
706
+ // Only reset if merge succeeded AND there are more waves.
707
+ // TP-029: Iterate ALL encountered repo roots (not just primary repoRoot)
708
+ // so that repos active in wave N but not in the final wave still get reset.
709
+ // Follows the resume.ts encounteredRepoRoots pattern for parity.
526
710
  if (waveIdx < rawWaves.length - 1 && !batchState.pauseSignal.paused) {
527
- const prefix = orchConfig.orchestrator.worktree_prefix;
711
+ const resetPrefix = orchConfig.orchestrator.worktree_prefix;
528
712
  const resetOpId = resolveOperatorId(orchConfig);
529
- const existingWorktrees = listWorktrees(prefix, repoRoot, resetOpId, batchState.batchId);
530
-
531
- if (existingWorktrees.length > 0) {
532
- onNotify(
533
- ORCH_MESSAGES.orchWorktreeReset(waveIdx + 1, existingWorktrees.length),
534
- "info",
535
- );
713
+ let totalResetWorktrees = 0;
714
+ // TP-029 R006: Track worktrees that failed reset AND removal
715
+ // so the cleanup gate only fires on true stale state, not
716
+ // successfully-reset reusable worktrees.
717
+ const failedRemovalWorktrees = new Map<string, { repoId: string | undefined; paths: string[] }>();
718
+
719
+ for (const [perRepoRoot, perRepoId] of encounteredRepoRoots) {
720
+ const existingWorktrees = listWorktrees(resetPrefix, perRepoRoot, resetOpId, batchState.batchId);
721
+ if (existingWorktrees.length === 0) continue;
722
+ totalResetWorktrees += existingWorktrees.length;
723
+
724
+ // Per-repo target branch: primary repo uses orchBranch,
725
+ // secondary repos resolve their own branch (parity with resume.ts).
726
+ let targetBranch: string;
727
+ if (perRepoRoot === repoRoot) {
728
+ targetBranch = batchState.orchBranch;
729
+ } else {
730
+ try {
731
+ targetBranch = resolveBaseBranch(perRepoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
732
+ } catch {
733
+ // If resolution fails, fall back to orchBranch (reset will
734
+ // fail gracefully and trigger worktree removal)
735
+ targetBranch = batchState.orchBranch;
736
+ }
737
+ }
536
738
 
537
- const targetBranch = batchState.orchBranch;
538
739
  for (const wt of existingWorktrees) {
539
- const resetResult = safeResetWorktree(wt, targetBranch, repoRoot);
740
+ // TP-028: Skip reset for worktrees whose lane branch has
741
+ // unsaved partial progress (preservation failed with commits)
742
+ if (ppUnsafeBranches.has(wt.branch)) {
743
+ execLog("batch", batchState.batchId,
744
+ `skipping worktree reset for lane ${wt.laneNumber} — branch "${wt.branch}" has unsaved partial progress`,
745
+ { path: wt.path, branch: wt.branch });
746
+ continue;
747
+ }
748
+
749
+ const resetResult = safeResetWorktree(wt, targetBranch, perRepoRoot);
540
750
  if (!resetResult.success) {
541
751
  execLog("batch", batchState.batchId, `worktree reset failed for lane ${wt.laneNumber}`, {
542
752
  error: resetResult.error || "unknown",
543
753
  path: wt.path,
754
+ repoId: perRepoId ?? "(default)",
544
755
  });
545
756
  // If reset fails, remove this worktree so the next wave can recreate it cleanly.
546
757
  try {
547
- removeWorktree(wt, repoRoot);
758
+ removeWorktree(wt, perRepoRoot);
548
759
  execLog("batch", batchState.batchId, `removed unrecoverable worktree for lane ${wt.laneNumber}`);
549
760
  } catch (removeErr: unknown) {
550
761
  execLog("batch", batchState.batchId, `removeWorktree failed for lane ${wt.laneNumber}, attempting force cleanup`, {
@@ -552,15 +763,62 @@ export async function executeOrchBatch(
552
763
  path: wt.path,
553
764
  });
554
765
  // Last resort: force-remove the directory and prune git worktree state.
555
- // This handles cases where git has partially deregistered the worktree
556
- // or undeletable files (e.g., Windows reserved names like "nul") block removal.
557
- forceCleanupWorktree(wt, repoRoot, batchState.batchId);
766
+ forceCleanupWorktree(wt, perRepoRoot, batchState.batchId);
767
+ // Track this worktree for the cleanup gate it may still be registered
768
+ if (!failedRemovalWorktrees.has(perRepoRoot)) {
769
+ failedRemovalWorktrees.set(perRepoRoot, { repoId: perRepoId, paths: [] });
770
+ }
771
+ failedRemovalWorktrees.get(perRepoRoot)!.paths.push(wt.path);
558
772
  }
559
773
  } else {
560
774
  execLog("batch", batchState.batchId, `worktree reset OK for lane ${wt.laneNumber}`);
561
775
  }
562
776
  }
563
777
  }
778
+
779
+ if (totalResetWorktrees > 0) {
780
+ onNotify(
781
+ ORCH_MESSAGES.orchWorktreeReset(waveIdx + 1, totalResetWorktrees),
782
+ "info",
783
+ );
784
+ }
785
+
786
+ // ── TP-029: Post-merge cleanup gate ──────────────────────
787
+ // Only gate on worktrees that the reset loop tried and failed
788
+ // to remove. Successfully-reset reusable worktrees are expected
789
+ // to remain registered — they will be reused in the next wave.
790
+ // For each failed-removal worktree, verify it is still registered
791
+ // before classifying it as truly stale.
792
+ const cleanupGateFailures: CleanupGateRepoFailure[] = [];
793
+ if (failedRemovalWorktrees.size > 0) {
794
+ for (const [perRepoRoot, { repoId: perRepoId, paths: failedPaths }] of failedRemovalWorktrees) {
795
+ const remaining = listWorktrees(resetPrefix, perRepoRoot, resetOpId, batchState.batchId);
796
+ const remainingPaths = new Set(remaining.map(wt => wt.path));
797
+ // Only report worktrees that were targeted for removal but are still registered
798
+ const stale = failedPaths.filter(p => remainingPaths.has(p));
799
+ if (stale.length > 0) {
800
+ cleanupGateFailures.push({
801
+ repoRoot: perRepoRoot,
802
+ repoId: perRepoId,
803
+ staleWorktrees: stale,
804
+ });
805
+ }
806
+ }
807
+ }
808
+
809
+ if (cleanupGateFailures.length > 0) {
810
+ const gatePolicyResult = computeCleanupGatePolicy(waveIdx, cleanupGateFailures);
811
+
812
+ execLog("batch", batchState.batchId, `cleanup gate failed — pausing batch`, gatePolicyResult.logDetails);
813
+
814
+ batchState.phase = gatePolicyResult.targetPhase;
815
+ batchState.errors.push(gatePolicyResult.errorMessage);
816
+ persistRuntimeState(gatePolicyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
817
+ onNotify(gatePolicyResult.notifyMessage, gatePolicyResult.notifyLevel);
818
+ // Preserve remaining worktrees for manual cleanup — do NOT remove them
819
+ preserveWorktreesForResume = true;
820
+ break;
821
+ }
564
822
  }
565
823
  }
566
824
 
@@ -681,6 +939,17 @@ export async function executeOrchBatch(
681
939
  execLog("batch", batchState.batchId, `failed to save batch history: ${err}`);
682
940
  }
683
941
 
942
+ // ── Pre-cleanup: Determine if worktrees should be preserved ──
943
+ // TP-031 (R006): This check MUST run before cleanup so that worktrees
944
+ // survive when failedTasks > 0. Without this, cleanup deletes worktrees
945
+ // before the batch is marked "paused", breaking resumability.
946
+ if (!preserveWorktreesForResume &&
947
+ ((batchState.phase as OrchBatchPhase) === "executing" || (batchState.phase as OrchBatchPhase) === "merging") &&
948
+ batchState.failedTasks > 0) {
949
+ preserveWorktreesForResume = true;
950
+ execLog("batch", batchState.batchId, "pre-cleanup: failedTasks > 0 detected, preserving worktrees for resume");
951
+ }
952
+
684
953
  // ── Phase 3: Cleanup ─────────────────────────────────────────
685
954
  const prefix = orchConfig.orchestrator.worktree_prefix;
686
955
 
@@ -717,34 +986,99 @@ export async function executeOrchBatch(
717
986
  }
718
987
  } catch { /* .pi dir may not exist */ }
719
988
 
720
- // Clean up worktrees use orchBranch to protect unmerged work.
721
- // Lane branches were merged into orchBranch (not baseBranch), so
722
- // unmerged-branch detection must compare against orchBranch.
723
- const targetBranch = batchState.orchBranch;
989
+ // ── TP-028: Preserve partial progress before terminal cleanup ──
990
+ // Save failed task commits as named branches before worktree removal
991
+ // destroys the lane branches. Uses the last wave's allocated lanes
992
+ // to map failed tasks to their lane branches.
993
+ {
994
+ const ppOpId = resolveOperatorId(orchConfig);
995
+ const ppResult = preserveFailedLaneProgress(
996
+ latestAllocatedLanes,
997
+ allTaskOutcomes,
998
+ ppOpId,
999
+ batchState.batchId,
1000
+ (repoId) => {
1001
+ const perRepoRoot = resolveRepoRoot(repoId, repoRoot, workspaceConfig);
1002
+ let targetBranch = batchState.orchBranch;
1003
+ if (repoId && perRepoRoot !== repoRoot) {
1004
+ try {
1005
+ targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
1006
+ } catch { /* fall back to orchBranch */ }
1007
+ }
1008
+ return { repoRoot: perRepoRoot, targetBranch };
1009
+ },
1010
+ );
1011
+ if (ppResult.results.some(r => r.saved)) {
1012
+ execLog("batch", batchState.batchId,
1013
+ `preserved partial progress for ${ppResult.results.filter(r => r.saved).length} failed task(s) before terminal cleanup`);
1014
+ }
1015
+ // Log warnings for failed preservation attempts — at terminal cleanup
1016
+ // we cannot skip deletion (batch is ending), but operators need to know
1017
+ // that commits may become unreachable via reflog only.
1018
+ for (const r of ppResult.results) {
1019
+ if (!r.saved && (r.commitCount > 0 || r.error)) {
1020
+ execLog("batch", batchState.batchId,
1021
+ `WARNING: Failed to preserve partial progress for task ${r.taskId} ` +
1022
+ `(${r.commitCount} commit(s) may become unreachable after cleanup)`,
1023
+ { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" });
1024
+ }
1025
+ }
1026
+ // TP-028: Stamp task outcomes with partial progress data for persistence
1027
+ applyPartialProgressToOutcomes(ppResult, allTaskOutcomes);
1028
+ }
1029
+
1030
+ // TP-029: Clean up worktrees across ALL encountered repos (not just primary).
1031
+ // Per-repo target branch resolution: primary repo uses orchBranch,
1032
+ // secondary repos resolve their own branch via resolveBaseBranch.
1033
+ // Parity with resume.ts:1475-1507.
724
1034
  const cleanupOpId = resolveOperatorId(orchConfig);
725
1035
  execLog("batch", batchState.batchId, "cleaning up worktrees");
726
- const removeResult = removeAllWorktrees(prefix, repoRoot, cleanupOpId, targetBranch, batchState.batchId, orchConfig);
727
-
728
- // Log preserved branches
729
- for (const p of removeResult.preserved) {
730
- execLog("batch", batchState.batchId, `preserving unmerged branch as saved ref`, {
731
- branch: p.branch,
732
- savedBranch: p.savedBranch,
733
- lane: p.laneNumber,
734
- target: targetBranch,
735
- commitCount: p.unmergedCount ?? 0,
736
- });
737
- }
738
1036
 
739
- if (removeResult.failed.length > 0) {
740
- const failedPaths = removeResult.failed.map(f => f.worktree.path).join(", ");
741
- execLog("batch", batchState.batchId, `worktree cleanup: ${removeResult.removed.length} removed, ${removeResult.failed.length} failed, ${removeResult.preserved.length} preserved`, {
742
- failedPaths,
743
- });
744
- } else if (removeResult.totalAttempted > 0) {
745
- execLog("batch", batchState.batchId, `worktree cleanup: ${removeResult.removed.length} removed, ${removeResult.preserved.length} preserved`);
1037
+ for (const [perRepoRoot, perRepoId] of encounteredRepoRoots) {
1038
+ let targetBranch: string | undefined;
1039
+ if (perRepoRoot === repoRoot) {
1040
+ // Primary repo: lane branches were merged into orchBranch
1041
+ targetBranch = batchState.orchBranch;
1042
+ } else {
1043
+ // Secondary repo (workspace mode): resolve the repo's own branch
1044
+ try {
1045
+ targetBranch = resolveBaseBranch(perRepoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
1046
+ } catch {
1047
+ // Fall back to undefined — skips branch protection
1048
+ // (safe because successfully merged branches were already cleaned)
1049
+ targetBranch = undefined;
1050
+ }
1051
+ }
1052
+ const removeResult = removeAllWorktrees(prefix, perRepoRoot, cleanupOpId, targetBranch, batchState.batchId, orchConfig);
1053
+
1054
+ // Log preserved branches
1055
+ for (const p of removeResult.preserved) {
1056
+ execLog("batch", batchState.batchId, `preserving unmerged branch as saved ref`, {
1057
+ branch: p.branch,
1058
+ savedBranch: p.savedBranch,
1059
+ lane: p.laneNumber,
1060
+ target: targetBranch,
1061
+ commitCount: p.unmergedCount ?? 0,
1062
+ repoId: perRepoId ?? "(default)",
1063
+ });
1064
+ }
1065
+
1066
+ if (removeResult.failed.length > 0) {
1067
+ const failedPaths = removeResult.failed.map(f => f.worktree.path).join(", ");
1068
+ execLog("batch", batchState.batchId, `worktree cleanup: ${removeResult.removed.length} removed, ${removeResult.failed.length} failed, ${removeResult.preserved.length} preserved`, {
1069
+ failedPaths,
1070
+ repoId: perRepoId ?? "(default)",
1071
+ });
1072
+ } else if (removeResult.totalAttempted > 0) {
1073
+ execLog("batch", batchState.batchId, `worktree cleanup: ${removeResult.removed.length} removed, ${removeResult.preserved.length} preserved`, {
1074
+ repoId: perRepoId ?? "(default)",
1075
+ });
1076
+ }
746
1077
  }
747
1078
 
1079
+ // NOTE: Empty .worktrees base-dir cleanup (subdirectory mode) is handled
1080
+ // inside removeAllWorktrees() when config is passed — no duplicate pass needed here.
1081
+
748
1082
  // ── Post-worktree-removal: Clean up merged branches ──────
749
1083
  // This MUST run after worktree removal because git branch -D
750
1084
  // fails if any worktree still has the branch checked out.
@@ -753,7 +1087,9 @@ export async function executeOrchBatch(
753
1087
  for (const mergeResult of allMergeResults) {
754
1088
  if (mergeResult.status === "succeeded" || mergeResult.status === "partial") {
755
1089
  for (const lr of mergeResult.laneResults) {
756
- if (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") {
1090
+ // TP-032 R006-3: Exclude verification_new_failure lanes from branch cleanup
1091
+ // (their merge commits were rolled back, so the branch is NOT merged)
1092
+ if (!lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED")) {
757
1093
  const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig);
758
1094
  const ancestorCheck = runGit(
759
1095
  ["merge-base", "--is-ancestor", lr.sourceBranch, lr.targetBranch],
@@ -791,7 +1127,12 @@ export async function executeOrchBatch(
791
1127
  if ((batchState.phase as OrchBatchPhase) === "executing" || (batchState.phase as OrchBatchPhase) === "merging") {
792
1128
  // Normal completion (not stopped, paused, or aborted)
793
1129
  if (batchState.failedTasks > 0) {
794
- batchState.phase = "failed";
1130
+ // TP-031: Default to "paused" so the batch is resumable without --force.
1131
+ // "failed" is reserved for unrecoverable invariant violations after retry
1132
+ // exhaustion (not yet implemented — will be added when retry logic lands).
1133
+ // NOTE: preserveWorktreesForResume was already set pre-cleanup to ensure
1134
+ // worktrees survive; this just sets the phase for state persistence.
1135
+ batchState.phase = "paused";
795
1136
  } else {
796
1137
  batchState.phase = "completed";
797
1138
  }
@@ -829,6 +1170,10 @@ export async function executeOrchBatch(
829
1170
  // ── TS-009: Persist terminal state ──
830
1171
  persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
831
1172
 
1173
+ // ── TP-031: Emit diagnostic reports (JSONL + markdown) ──
1174
+ // Non-fatal: errors are logged but never crash batch finalization.
1175
+ emitDiagnosticReports(assembleDiagnosticInput(orchConfig, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, stateRoot));
1176
+
832
1177
  if (batchState.phase === "paused" || batchState.phase === "stopped") {
833
1178
  execLog("batch", batchState.batchId, "batch ended in non-terminal execution state; completion banner suppressed", {
834
1179
  phase: batchState.phase,