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.
@@ -5,19 +5,21 @@
5
5
  import { existsSync } from "fs";
6
6
  import { join } from "path";
7
7
 
8
+ import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
8
9
  import { runDiscovery } from "./discovery.ts";
9
10
  import { executeOrchBatch } from "./engine.ts";
10
11
  import { computeTransitiveDependents, execLog, executeWave, pollUntilTaskComplete, spawnLaneSession, tmuxHasSession } from "./execution.ts";
11
12
  import type { MonitorUpdateCallback } from "./execution.ts";
12
13
  import { getCurrentBranch, runGit } from "./git.ts";
13
14
  import { attemptAutoIntegration, mergeWaveByRepo } from "./merge.ts";
14
- import { computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
15
+ import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
16
+ import type { CleanupGateRepoFailure } from "./messages.ts";
15
17
  import { resolveOperatorId } from "./naming.ts";
16
- import { deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
17
- import { StateFileError } from "./types.ts";
18
+ import { applyPartialProgressToOutcomes, deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
19
+ import { defaultResilienceState, StateFileError } from "./types.ts";
18
20
  import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, PersistedLaneRecord, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
19
21
  import { buildDependencyGraph, resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
20
- import { deleteBranchBestEffort, forceCleanupWorktree, listWorktrees, removeAllWorktrees, removeWorktree, safeResetWorktree } from "./worktree.ts";
22
+ import { deleteBranchBestEffort, forceCleanupWorktree, listWorktrees, preserveFailedLaneProgress, removeAllWorktrees, removeWorktree, safeResetWorktree, sleepSync } from "./worktree.ts";
21
23
 
22
24
  // ── Resume Repo Helpers ──────────────────────────────────────────────
23
25
 
@@ -191,20 +193,23 @@ export function collectAllRepoRoots(
191
193
  * Check whether a persisted batch state is eligible for resume.
192
194
  *
193
195
  * Resume eligibility matrix:
194
- * | Phase | Eligible? | Reason |
195
- * |-----------|-----------|-------------------------------------------|
196
- * | paused | ✅ | Batch was paused (user/merge-failure) |
197
- * | executing | ✅ | Batch was executing when orchestrator died |
198
- * | merging | ✅ | Batch was merging when orchestrator died |
199
- * | stopped | ❌ | Batch was stopped by policy |
200
- * | failed | ❌ | Batch has terminal failure |
201
- * | completed | ❌ | Batch already completed |
202
- * | idle | ❌ | Batch never started execution |
203
- * | planning | ❌ | Batch was still planning |
196
+ * | Phase | Normal | --force | Reason |
197
+ * |-----------|-----------|-----------|-------------------------------------------|
198
+ * | paused | ✅ | ✅ | Batch was paused (user/merge-failure) |
199
+ * | executing | ✅ | ✅ | Batch was executing when orchestrator died |
200
+ * | merging | ✅ | ✅ | Batch was merging when orchestrator died |
201
+ * | stopped | ❌ | ✅ | Batch was stopped by policy |
202
+ * | failed | ❌ | ✅ | Batch has terminal failure |
203
+ * | completed | ❌ | ❌ | Batch already completed |
204
+ * | idle | ❌ | ❌ | Batch never started execution |
205
+ * | planning | ❌ | ❌ | Batch was still planning |
204
206
  *
205
207
  * Pure function — no process or filesystem access.
208
+ *
209
+ * @param state - Persisted batch state to check
210
+ * @param force - When true, `stopped` and `failed` phases become eligible
206
211
  */
207
- export function checkResumeEligibility(state: PersistedBatchState): ResumeEligibility {
212
+ export function checkResumeEligibility(state: PersistedBatchState, force: boolean = false): ResumeEligibility {
208
213
  const { phase, batchId } = state;
209
214
 
210
215
  switch (phase) {
@@ -233,17 +238,33 @@ export function checkResumeEligibility(state: PersistedBatchState): ResumeEligib
233
238
  };
234
239
 
235
240
  case "stopped":
241
+ if (force) {
242
+ return {
243
+ eligible: true,
244
+ reason: `Batch ${batchId} was stopped by failure policy. Force-resuming (--force).`,
245
+ phase,
246
+ batchId,
247
+ };
248
+ }
236
249
  return {
237
250
  eligible: false,
238
- reason: `Batch ${batchId} was stopped by failure policy. Use /orch-abort to clean up, then start a new batch.`,
251
+ reason: `Batch ${batchId} was stopped by failure policy. Use --force to resume, or /orch-abort to clean up.`,
239
252
  phase,
240
253
  batchId,
241
254
  };
242
255
 
243
256
  case "failed":
257
+ if (force) {
258
+ return {
259
+ eligible: true,
260
+ reason: `Batch ${batchId} has a terminal failure. Force-resuming (--force).`,
261
+ phase,
262
+ batchId,
263
+ };
264
+ }
244
265
  return {
245
266
  eligible: false,
246
- reason: `Batch ${batchId} has a terminal failure. Use /orch-abort to clean up, then start a new batch.`,
267
+ reason: `Batch ${batchId} has a terminal failure. Use --force to resume, or /orch-abort to clean up.`,
247
268
  phase,
248
269
  batchId,
249
270
  };
@@ -251,7 +272,7 @@ export function checkResumeEligibility(state: PersistedBatchState): ResumeEligib
251
272
  case "completed":
252
273
  return {
253
274
  eligible: false,
254
- reason: `Batch ${batchId} already completed. Delete the state file or start a new batch.`,
275
+ reason: `Batch ${batchId} already completed. ${force ? "--force cannot resume a completed batch. " : ""}Delete the state file or start a new batch.`,
255
276
  phase,
256
277
  batchId,
257
278
  };
@@ -522,6 +543,127 @@ export function computeResumePoint(
522
543
  }
523
544
 
524
545
 
546
+ // ── Pre-Resume Diagnostics ───────────────────────────────────────────
547
+
548
+ /**
549
+ * Result of a single diagnostic check.
550
+ */
551
+ export interface DiagnosticCheckResult {
552
+ /** Short label for the check */
553
+ check: string;
554
+ /** Whether the check passed */
555
+ passed: boolean;
556
+ /** Human-readable detail (reason for failure or confirmation) */
557
+ detail: string;
558
+ }
559
+
560
+ /**
561
+ * Aggregate result of pre-resume diagnostics.
562
+ */
563
+ export interface PreResumeDiagnosticsResult {
564
+ /** Whether all checks passed and resume can proceed */
565
+ passed: boolean;
566
+ /** Individual check results */
567
+ checks: DiagnosticCheckResult[];
568
+ /** Summary message for operator display */
569
+ summary: string;
570
+ }
571
+
572
+ /**
573
+ * Run pre-resume diagnostics before allowing a force-resume.
574
+ *
575
+ * Checks performed (per repo in workspace mode):
576
+ * 1. **State coherence:** batch-state.json exists and is loadable
577
+ * 2. **Branch consistency:** orch branch exists in each repo
578
+ * 3. **Worktree health:** persisted lane worktrees are accessible or cleanly absent
579
+ *
580
+ * Pure-ish function — reads filesystem/git state but does not mutate anything.
581
+ *
582
+ * @param persistedState - Loaded batch state
583
+ * @param repoRoot - Default repo root (cwd)
584
+ * @param stateRoot - Root for state files (.pi/)
585
+ * @param workspaceConfig - Workspace configuration (null in repo mode)
586
+ * @returns Diagnostics result with pass/fail and per-check details
587
+ */
588
+ export function runPreResumeDiagnostics(
589
+ persistedState: PersistedBatchState,
590
+ repoRoot: string,
591
+ stateRoot: string,
592
+ workspaceConfig?: WorkspaceConfig | null,
593
+ ): PreResumeDiagnosticsResult {
594
+ const checks: DiagnosticCheckResult[] = [];
595
+
596
+ // 1. State coherence — verify batch-state.json is well-formed
597
+ // (Already loaded by caller, so if we get here the state is valid.)
598
+ checks.push({
599
+ check: "state-coherence",
600
+ passed: true,
601
+ detail: `Batch state loaded successfully (batchId: ${persistedState.batchId}, phase: ${persistedState.phase})`,
602
+ });
603
+
604
+ // 2. Branch consistency — verify orch branch exists in each repo
605
+ const repoRoots = collectRepoRoots(persistedState, repoRoot, workspaceConfig);
606
+ for (const root of repoRoots) {
607
+ const repoId = resolveRepoIdFromRoot(root, workspaceConfig);
608
+ const label = repoId ? `repo:${repoId}` : "default-repo";
609
+
610
+ if (persistedState.orchBranch) {
611
+ const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${persistedState.orchBranch}`], root);
612
+ if (branchCheck.ok) {
613
+ checks.push({
614
+ check: `branch-consistency:${label}`,
615
+ passed: true,
616
+ detail: `Orch branch "${persistedState.orchBranch}" exists in ${label}`,
617
+ });
618
+ } else {
619
+ checks.push({
620
+ check: `branch-consistency:${label}`,
621
+ passed: false,
622
+ detail: `Orch branch "${persistedState.orchBranch}" not found in ${label}. ` +
623
+ `The branch may have been deleted or the repo is in an inconsistent state.`,
624
+ });
625
+ }
626
+ }
627
+ }
628
+
629
+ // 3. Worktree health — check each persisted lane worktree
630
+ for (const lane of persistedState.lanes) {
631
+ if (!lane.worktreePath) continue;
632
+
633
+ const wtExists = existsSync(lane.worktreePath);
634
+ if (wtExists) {
635
+ // Verify it's a valid git worktree (has .git file/directory)
636
+ const gitMarker = join(lane.worktreePath, ".git");
637
+ const isValidWt = existsSync(gitMarker);
638
+ checks.push({
639
+ check: `worktree-health:lane-${lane.laneNumber}`,
640
+ passed: isValidWt,
641
+ detail: isValidWt
642
+ ? `Lane ${lane.laneNumber} worktree exists and has valid .git marker`
643
+ : `Lane ${lane.laneNumber} worktree exists at ${lane.worktreePath} but lacks .git marker (corrupted)`,
644
+ });
645
+ } else {
646
+ // Absent worktree is OK — resume will re-create or skip
647
+ checks.push({
648
+ check: `worktree-health:lane-${lane.laneNumber}`,
649
+ passed: true,
650
+ detail: `Lane ${lane.laneNumber} worktree absent (will be re-created on resume)`,
651
+ });
652
+ }
653
+ }
654
+
655
+ const failed = checks.filter(c => !c.passed);
656
+ const passed = failed.length === 0;
657
+
658
+ const summary = passed
659
+ ? `✅ Pre-resume diagnostics passed (${checks.length} checks)`
660
+ : `❌ Pre-resume diagnostics failed (${failed.length}/${checks.length} checks failed):\n` +
661
+ failed.map(c => ` • ${c.check}: ${c.detail}`).join("\n");
662
+
663
+ return { passed, checks, summary };
664
+ }
665
+
666
+
525
667
  export async function resumeOrchBatch(
526
668
  orchConfig: OrchestratorConfig,
527
669
  runnerConfig: TaskRunnerConfig,
@@ -532,6 +674,7 @@ export async function resumeOrchBatch(
532
674
  workspaceConfig?: WorkspaceConfig | null,
533
675
  workspaceRoot?: string,
534
676
  agentRoot?: string,
677
+ force: boolean = false,
535
678
  ): Promise<void> {
536
679
  const repoRoot = cwd;
537
680
  // State files (.pi/batch-state.json, lane-state, etc.) belong in the workspace root,
@@ -563,7 +706,7 @@ export async function resumeOrchBatch(
563
706
  }
564
707
 
565
708
  // ── 2. Check eligibility ─────────────────────────────────────
566
- const eligibility = checkResumeEligibility(persistedState);
709
+ const eligibility = checkResumeEligibility(persistedState, force);
567
710
  if (!eligibility.eligible) {
568
711
  onNotify(
569
712
  ORCH_MESSAGES.resumePhaseNotResumable(persistedState.batchId, persistedState.phase, eligibility.reason),
@@ -572,6 +715,37 @@ export async function resumeOrchBatch(
572
715
  return;
573
716
  }
574
717
 
718
+ // ── 2b. Force-resume: pre-resume diagnostics & state mutation ──
719
+ const isForceResume = force && (persistedState.phase === "stopped" || persistedState.phase === "failed");
720
+ if (isForceResume) {
721
+ onNotify(
722
+ ORCH_MESSAGES.forceResumeStarting(persistedState.batchId, persistedState.phase),
723
+ "warning",
724
+ );
725
+
726
+ // Run pre-resume diagnostics before allowing force-resume
727
+ const diagnostics = runPreResumeDiagnostics(persistedState, repoRoot, stateRoot, workspaceConfig);
728
+ onNotify(diagnostics.summary, diagnostics.passed ? "info" : "error");
729
+
730
+ if (!diagnostics.passed) {
731
+ onNotify(
732
+ ORCH_MESSAGES.forceResumeDiagnosticsFailed(persistedState.batchId),
733
+ "error",
734
+ );
735
+ return;
736
+ }
737
+
738
+ // Record force intent in resilience state
739
+ persistedState.resilience.resumeForced = true;
740
+
741
+ // Reset phase to paused so normal resume flow can proceed
742
+ execLog("resume", persistedState.batchId, `force-resume: phase ${persistedState.phase} → paused`, {
743
+ diagnosticChecks: diagnostics.checks.length,
744
+ diagnosticsPassed: diagnostics.passed,
745
+ });
746
+ persistedState.phase = "paused";
747
+ }
748
+
575
749
  onNotify(
576
750
  ORCH_MESSAGES.resumeStarting(persistedState.batchId, persistedState.phase),
577
751
  "info",
@@ -702,6 +876,14 @@ export async function resumeOrchBatch(
702
876
  batchState.currentWaveIndex = resumePoint.resumeWaveIndex;
703
877
  batchState.waveResults = [];
704
878
 
879
+ // v3: Carry forward resilience and diagnostics from persisted state
880
+ batchState.resilience = persistedState.resilience;
881
+ batchState.diagnostics = persistedState.diagnostics;
882
+ // Carry forward unknown fields for roundtrip preservation
883
+ if (persistedState._extraFields) {
884
+ batchState._extraFields = persistedState._extraFields;
885
+ }
886
+
705
887
  // ── 7. Re-run discovery for ParsedTask metadata ──────────────
706
888
  // We need fresh ParsedTask data (taskFolder, promptPath) for execution.
707
889
  // Use "all" to discover all areas.
@@ -953,6 +1135,7 @@ export async function resumeOrchBatch(
953
1135
  workspaceConfig,
954
1136
  stateRoot,
955
1137
  agentRoot,
1138
+ runnerConfig.testing_commands,
956
1139
  );
957
1140
 
958
1141
  if (reExecMergeResult.status === "succeeded") {
@@ -962,8 +1145,9 @@ export async function resumeOrchBatch(
962
1145
  );
963
1146
 
964
1147
  // Clean up merged branches (resolve per-lane repo root for workspace mode)
1148
+ // TP-032 R006-3: Exclude verification_new_failure lanes from branch cleanup
965
1149
  for (const lr of reExecMergeResult.laneResults) {
966
- if (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") {
1150
+ if (!lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED")) {
967
1151
  const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig);
968
1152
  deleteBranchBestEffort(lr.sourceBranch, laneRepoRoot);
969
1153
  }
@@ -1024,6 +1208,11 @@ export async function resumeOrchBatch(
1024
1208
  : persistedTask?.exitReason ?? "",
1025
1209
  sessionName: persistedTask?.sessionName ?? "",
1026
1210
  doneFileFound: status === "succeeded" ? true : task.doneFileFound,
1211
+ // Carry forward partial progress from persisted state (TP-028)
1212
+ partialProgressCommits: persistedTask?.partialProgressCommits,
1213
+ partialProgressBranch: persistedTask?.partialProgressBranch,
1214
+ // v3: Carry forward exit diagnostic from persisted state (TP-030)
1215
+ exitDiagnostic: persistedTask?.exitDiagnostic,
1027
1216
  });
1028
1217
  }
1029
1218
 
@@ -1232,20 +1421,23 @@ export async function resumeOrchBatch(
1232
1421
  workspaceConfig,
1233
1422
  stateRoot,
1234
1423
  agentRoot,
1424
+ runnerConfig.testing_commands,
1235
1425
  );
1236
1426
  batchState.mergeResults.push(mergeResult);
1237
1427
 
1238
1428
  // Emit per-lane merge notifications
1239
1429
  for (const lr of mergeResult.laneResults) {
1240
1430
  const durationSec = Math.round(lr.durationMs / 1000);
1241
- if (lr.result?.status === "SUCCESS") {
1431
+ // TP-032 R006-3: Check lr.error first verification_new_failure lanes
1432
+ // have error set even though lr.result.status may be SUCCESS/CONFLICT_RESOLVED.
1433
+ if (lr.error) {
1434
+ onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error), "error");
1435
+ } else if (lr.result?.status === "SUCCESS") {
1242
1436
  onNotify(ORCH_MESSAGES.orchMergeLaneSuccess(lr.laneNumber, lr.result.merge_commit, durationSec), "info");
1243
1437
  } else if (lr.result?.status === "CONFLICT_RESOLVED") {
1244
1438
  onNotify(ORCH_MESSAGES.orchMergeLaneConflictResolved(lr.laneNumber, lr.result.conflicts.length, durationSec), "info");
1245
1439
  } else if (lr.result?.status === "CONFLICT_UNRESOLVED" || lr.result?.status === "BUILD_FAILURE") {
1246
- onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error || lr.result.status), "error");
1247
- } else if (lr.error) {
1248
- onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error), "error");
1440
+ onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.result.status), "error");
1249
1441
  }
1250
1442
  }
1251
1443
 
@@ -1257,8 +1449,9 @@ export async function resumeOrchBatch(
1257
1449
  mergeResult = { ...mergeResult, status: "partial", failedLane: mixedOutcomeLanes[0].laneNumber, failureReason };
1258
1450
  }
1259
1451
 
1452
+ // TP-032 R006-3: Exclude verification_new_failure lanes from success count
1260
1453
  const mergedCount = mergeResult.laneResults.filter(
1261
- r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
1454
+ r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
1262
1455
  ).length;
1263
1456
  const mergeTotalSec = Math.round(mergeResult.totalDurationMs / 1000);
1264
1457
 
@@ -1304,24 +1497,139 @@ export async function resumeOrchBatch(
1304
1497
  onNotify(ORCH_MESSAGES.orchMergeSkipped(waveIdx + 1), "info");
1305
1498
  }
1306
1499
 
1307
- // Handle merge failure shared helper guarantees parity with engine.ts (TP-005 Step 2)
1308
- if (mergeResult && (mergeResult.status === "failed" || mergeResult.status === "partial")) {
1309
- const policyResult = computeMergeFailurePolicy(mergeResult, waveIdx, orchConfig);
1310
-
1311
- execLog("batch", batchState.batchId, `merge failure — applying ${policyResult.policy} policy`, policyResult.logDetails);
1500
+ // ── TP-033: Safe-stop on rollback failure ─────────────────
1501
+ // When a verification rollback failed, force paused regardless of
1502
+ // on_merge_failure policy. The merge worktree and temp branch are
1503
+ // preserved for manual recovery using commands in the transaction record.
1504
+ if (mergeResult?.rollbackFailed) {
1505
+ // TP-033 R004-2: Include persistence error warning when transaction
1506
+ // record files may be missing, so operator knows to inspect manually
1507
+ const hasPersistErrors = mergeResult.persistenceErrors && mergeResult.persistenceErrors.length > 0;
1508
+ const persistWarning = hasPersistErrors
1509
+ ? ` WARNING: ${mergeResult.persistenceErrors!.length} transaction record(s) failed to persist — recovery file(s) may be missing.`
1510
+ : "";
1511
+
1512
+ execLog("batch", batchState.batchId, "SAFE-STOP: verification rollback failed — forcing paused regardless of policy", {
1513
+ waveIndex: waveIdx,
1514
+ configPolicy: orchConfig.failure.on_merge_failure,
1515
+ ...(hasPersistErrors ? { persistenceErrors: mergeResult.persistenceErrors } : {}),
1516
+ });
1312
1517
 
1313
- batchState.phase = policyResult.targetPhase;
1314
- batchState.errors.push(policyResult.errorMessage);
1315
- persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
1316
- onNotify(policyResult.notifyMessage, policyResult.notifyLevel);
1518
+ batchState.phase = "paused";
1519
+ batchState.errors.push(
1520
+ `Safe-stop at wave ${waveIdx + 1}: verification rollback failed. ` +
1521
+ `Merge worktree and temp branch preserved for recovery. ` +
1522
+ `Check transaction records in .pi/verification/ for recovery commands.` +
1523
+ persistWarning
1524
+ );
1525
+ persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
1526
+ onNotify(
1527
+ `🛑 Safe-stop: verification rollback failed at wave ${waveIdx + 1}. ` +
1528
+ `Batch force-paused. Merge worktree preserved for manual recovery. ` +
1529
+ `See .pi/verification/ transaction records for recovery commands.` +
1530
+ persistWarning,
1531
+ "error",
1532
+ );
1317
1533
  preserveWorktreesForResume = true;
1318
1534
  break;
1319
1535
  }
1320
1536
 
1537
+ // Handle merge failure — TP-033 Step 2 (R006): Retry policy matrix via shared applyMergeRetryLoop.
1538
+ // Uses the same centralized loop as engine.ts for guaranteed parity.
1539
+ if (mergeResult && (mergeResult.status === "failed" || mergeResult.status === "partial")) {
1540
+ // Initialize resilience state if not yet present
1541
+ if (!batchState.resilience) {
1542
+ batchState.resilience = defaultResilienceState();
1543
+ }
1544
+
1545
+ const retryOutcome = applyMergeRetryLoop(
1546
+ mergeResult,
1547
+ waveIdx,
1548
+ batchState.resilience.retryCountByScope,
1549
+ {
1550
+ performMerge: () => {
1551
+ batchState.phase = "merging";
1552
+ return mergeWaveByRepo(
1553
+ waveResult.allocatedLanes,
1554
+ waveResult,
1555
+ waveIdx + 1,
1556
+ orchConfig,
1557
+ repoRoot,
1558
+ batchState.batchId,
1559
+ batchState.orchBranch,
1560
+ workspaceConfig,
1561
+ stateRoot,
1562
+ agentRoot,
1563
+ runnerConfig.testing_commands,
1564
+ );
1565
+ },
1566
+ persist: (trigger) => persistRuntimeState(trigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot),
1567
+ log: (message, details) => execLog("batch", batchState.batchId, message, details),
1568
+ notify: (message, level) => onNotify(message, level),
1569
+ updateMergeResult: (result) => {
1570
+ mergeResult = result;
1571
+ batchState.mergeResults[batchState.mergeResults.length - 1] = result;
1572
+ },
1573
+ sleep: sleepSync,
1574
+ },
1575
+ );
1576
+
1577
+ if (retryOutcome.kind === "retry_succeeded") {
1578
+ mergeResult = retryOutcome.mergeResult;
1579
+ batchState.phase = "executing";
1580
+ persistRuntimeState("merge-retry-succeeded", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
1581
+ // Fall through to normal post-merge flow
1582
+ } else if (retryOutcome.kind === "safe_stop") {
1583
+ mergeResult = retryOutcome.mergeResult;
1584
+ batchState.phase = "paused";
1585
+ batchState.errors.push(retryOutcome.errorMessage);
1586
+ persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
1587
+ onNotify(retryOutcome.notifyMessage, "error");
1588
+ preserveWorktreesForResume = true;
1589
+ break;
1590
+ } else if (retryOutcome.kind === "exhausted") {
1591
+ // TP-033 R006-2: Force paused regardless of on_merge_failure config.
1592
+ mergeResult = retryOutcome.mergeResult;
1593
+ const exhaustionMsg = retryOutcome.errorMessage +
1594
+ ` [${retryOutcome.classification ?? "unknown"} ${retryOutcome.lastDecision.currentAttempt}/${retryOutcome.lastDecision.maxAttempts}, scope=${retryOutcome.scopeKey}]`;
1595
+
1596
+ execLog("batch", batchState.batchId, `merge retry exhausted — forcing paused`, {
1597
+ classification: retryOutcome.classification,
1598
+ scopeKey: retryOutcome.scopeKey,
1599
+ attempts: retryOutcome.lastDecision.currentAttempt,
1600
+ maxAttempts: retryOutcome.lastDecision.maxAttempts,
1601
+ });
1602
+
1603
+ batchState.phase = "paused";
1604
+ batchState.errors.push(exhaustionMsg);
1605
+ persistRuntimeState("merge-retry-exhausted", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
1606
+ onNotify(retryOutcome.notifyMessage, "error");
1607
+ preserveWorktreesForResume = true;
1608
+ break;
1609
+ } else {
1610
+ // kind === "no_retry": fall through to standard on_merge_failure policy
1611
+ mergeResult = retryOutcome.mergeResult;
1612
+ const policyResult = computeMergeFailurePolicy(mergeResult, waveIdx, orchConfig);
1613
+ const classNote = retryOutcome.classification
1614
+ ? ` [not retriable: ${retryOutcome.classification}, scope=${retryOutcome.scopeKey}]`
1615
+ : "";
1616
+
1617
+ execLog("batch", batchState.batchId, `merge failure — applying ${policyResult.policy} policy${classNote}`, policyResult.logDetails);
1618
+
1619
+ batchState.phase = policyResult.targetPhase;
1620
+ batchState.errors.push(policyResult.errorMessage + classNote);
1621
+ persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
1622
+ onNotify(policyResult.notifyMessage + classNote, policyResult.notifyLevel);
1623
+ preserveWorktreesForResume = true;
1624
+ break;
1625
+ }
1626
+ }
1627
+
1321
1628
  // Post-merge: reset worktrees for next wave
1629
+ // TP-032 R006-3: Exclude verification_new_failure lanes from branch cleanup
1322
1630
  if (mergeResult && mergeResult.status === "succeeded") {
1323
1631
  for (const lr of mergeResult.laneResults) {
1324
- if (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") {
1632
+ if (!lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED")) {
1325
1633
  const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig);
1326
1634
  const ancestorCheck = runGit(["merge-base", "--is-ancestor", lr.sourceBranch, lr.targetBranch], laneRepoRoot);
1327
1635
  if (ancestorCheck.ok) {
@@ -1331,9 +1639,58 @@ export async function resumeOrchBatch(
1331
1639
  }
1332
1640
  }
1333
1641
 
1642
+ // ── TP-028: Preserve partial progress before inter-wave reset ──
1643
+ // Hoisted outside the if-block so unsafeBranches is accessible to the
1644
+ // reset loop below — both blocks share the same guard condition.
1645
+ let ppUnsafeBranches = new Set<string>();
1646
+ if (waveIdx < persistedState.wavePlan.length - 1 && !batchState.pauseSignal.paused) {
1647
+ const ppOpId = resolveOperatorId(orchConfig);
1648
+ const ppResult = preserveFailedLaneProgress(
1649
+ latestAllocatedLanes,
1650
+ allTaskOutcomes,
1651
+ ppOpId,
1652
+ batchState.batchId,
1653
+ (repoId) => {
1654
+ const perRepoRoot = resolveRepoRoot(repoId, repoRoot, workspaceConfig);
1655
+ let targetBranch = batchState.orchBranch;
1656
+ if (repoId && perRepoRoot !== repoRoot) {
1657
+ try {
1658
+ targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
1659
+ } catch { /* fall back to orchBranch */ }
1660
+ }
1661
+ return { repoRoot: perRepoRoot, targetBranch };
1662
+ },
1663
+ );
1664
+ ppUnsafeBranches = ppResult.unsafeBranches;
1665
+ if (ppResult.results.some(r => r.saved)) {
1666
+ execLog("batch", batchState.batchId,
1667
+ `preserved partial progress for ${ppResult.results.filter(r => r.saved).length} failed task(s) before inter-wave reset`);
1668
+ }
1669
+ // Log per-task warnings for failed preservation attempts
1670
+ for (const r of ppResult.results) {
1671
+ if (!r.saved && (r.commitCount > 0 || r.error)) {
1672
+ execLog("batch", batchState.batchId,
1673
+ `WARNING: Failed to preserve partial progress for task ${r.taskId} ` +
1674
+ `(${r.commitCount} commit(s) at risk on lane branch)`,
1675
+ { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" });
1676
+ }
1677
+ }
1678
+ if (ppUnsafeBranches.size > 0) {
1679
+ execLog("batch", batchState.batchId,
1680
+ `WARNING: ${ppUnsafeBranches.size} lane branch(es) could not be preserved — skipping reset for those lanes to prevent commit loss`,
1681
+ { unsafeBranches: [...ppUnsafeBranches] });
1682
+ }
1683
+ // TP-028: Stamp task outcomes with partial progress data for persistence
1684
+ applyPartialProgressToOutcomes(ppResult, allTaskOutcomes);
1685
+ }
1686
+
1334
1687
  if (waveIdx < persistedState.wavePlan.length - 1 && !batchState.pauseSignal.paused) {
1335
1688
  const wtPrefix = orchConfig.orchestrator.worktree_prefix;
1336
1689
  const resetOpId = resolveOperatorId(orchConfig);
1690
+ // TP-029 R006: Track worktrees that failed reset AND removal
1691
+ // so the cleanup gate only fires on true stale state, not
1692
+ // successfully-reset reusable worktrees. (Parity with engine.ts)
1693
+ const failedRemovalWorktrees = new Map<string, { repoId: string | undefined; paths: string[] }>();
1337
1694
 
1338
1695
  // Use encounteredRepoRoots which includes both persisted lanes
1339
1696
  // AND newly allocated lanes from resumed waves, ensuring repos
@@ -1357,21 +1714,124 @@ export async function resumeOrchBatch(
1357
1714
  }
1358
1715
  }
1359
1716
  for (const wt of existingWorktrees) {
1717
+ // TP-028: Skip reset for worktrees whose lane branch has
1718
+ // unsaved partial progress (preservation failed with commits)
1719
+ if (ppUnsafeBranches.has(wt.branch)) {
1720
+ execLog("batch", batchState.batchId,
1721
+ `skipping worktree reset for lane ${wt.laneNumber} — branch "${wt.branch}" has unsaved partial progress`,
1722
+ { path: wt.path, branch: wt.branch });
1723
+ continue;
1724
+ }
1725
+
1360
1726
  const resetResult = safeResetWorktree(wt, targetBranch, perRepoRoot);
1361
1727
  if (!resetResult.success) {
1362
1728
  try {
1363
1729
  removeWorktree(wt, perRepoRoot);
1364
1730
  } catch {
1365
1731
  forceCleanupWorktree(wt, perRepoRoot, batchState.batchId);
1732
+ // Track this worktree for the cleanup gate — it may still be registered
1733
+ const perRepoId = perRepoRoot === repoRoot
1734
+ ? undefined
1735
+ : resolveRepoIdFromRoot(perRepoRoot, workspaceConfig);
1736
+ if (!failedRemovalWorktrees.has(perRepoRoot)) {
1737
+ failedRemovalWorktrees.set(perRepoRoot, { repoId: perRepoId, paths: [] });
1738
+ }
1739
+ failedRemovalWorktrees.get(perRepoRoot)!.paths.push(wt.path);
1366
1740
  }
1367
1741
  }
1368
1742
  }
1369
1743
  }
1370
1744
  }
1745
+
1746
+ // ── TP-029: Post-merge cleanup gate (parity with engine.ts) ──
1747
+ // Only gate on worktrees that the reset loop tried and failed
1748
+ // to remove. Successfully-reset reusable worktrees are expected
1749
+ // to remain registered — they will be reused in the next wave.
1750
+ // For each failed-removal worktree, verify it is still registered
1751
+ // before classifying it as truly stale.
1752
+ const cleanupGateFailures: CleanupGateRepoFailure[] = [];
1753
+ if (failedRemovalWorktrees.size > 0) {
1754
+ for (const [perRepoRoot, { repoId: perRepoId, paths: failedPaths }] of failedRemovalWorktrees) {
1755
+ const remaining = listWorktrees(wtPrefix, perRepoRoot, resetOpId, batchState.batchId);
1756
+ const remainingPaths = new Set(remaining.map(wt => wt.path));
1757
+ // Only report worktrees that were targeted for removal but are still registered
1758
+ const stale = failedPaths.filter(p => remainingPaths.has(p));
1759
+ if (stale.length > 0) {
1760
+ cleanupGateFailures.push({
1761
+ repoRoot: perRepoRoot,
1762
+ repoId: perRepoId,
1763
+ staleWorktrees: stale,
1764
+ });
1765
+ }
1766
+ }
1767
+ }
1768
+
1769
+ if (cleanupGateFailures.length > 0) {
1770
+ const gatePolicyResult = computeCleanupGatePolicy(waveIdx, cleanupGateFailures);
1771
+
1772
+ execLog("batch", batchState.batchId, `cleanup gate failed — pausing batch`, gatePolicyResult.logDetails);
1773
+
1774
+ batchState.phase = gatePolicyResult.targetPhase;
1775
+ batchState.errors.push(gatePolicyResult.errorMessage);
1776
+ persistRuntimeState(gatePolicyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
1777
+ onNotify(gatePolicyResult.notifyMessage, gatePolicyResult.notifyLevel);
1778
+ preserveWorktreesForResume = true;
1779
+ break;
1780
+ }
1371
1781
  }
1372
1782
  }
1373
1783
 
1784
+ // ── Pre-cleanup: Determine if worktrees should be preserved ──
1785
+ // TP-031 (R006): Parity with engine.ts — this check MUST run before cleanup
1786
+ // so that worktrees survive when failedTasks > 0. Without this, cleanup
1787
+ // deletes worktrees before the batch is marked "paused", breaking resumability.
1788
+ if (!preserveWorktreesForResume &&
1789
+ ((batchState.phase as OrchBatchPhase) === "executing" || (batchState.phase as OrchBatchPhase) === "merging") &&
1790
+ batchState.failedTasks > 0) {
1791
+ preserveWorktreesForResume = true;
1792
+ execLog("resume", batchState.batchId, "pre-cleanup: failedTasks > 0 detected, preserving worktrees for resume");
1793
+ }
1794
+
1374
1795
  // ── 11. Cleanup and terminal state ───────────────────────────
1796
+
1797
+ // ── TP-028: Preserve partial progress before terminal cleanup ──
1798
+ if (!preserveWorktreesForResume) {
1799
+ const ppOpId = resolveOperatorId(orchConfig);
1800
+ const ppResult = preserveFailedLaneProgress(
1801
+ latestAllocatedLanes,
1802
+ allTaskOutcomes,
1803
+ ppOpId,
1804
+ batchState.batchId,
1805
+ (repoId) => {
1806
+ const perRepoRoot = resolveRepoRoot(repoId, repoRoot, workspaceConfig);
1807
+ let targetBranch = batchState.orchBranch;
1808
+ if (repoId && perRepoRoot !== repoRoot) {
1809
+ try {
1810
+ targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
1811
+ } catch { /* fall back to orchBranch */ }
1812
+ }
1813
+ return { repoRoot: perRepoRoot, targetBranch };
1814
+ },
1815
+ );
1816
+ if (ppResult.results.some(r => r.saved)) {
1817
+ execLog("batch", batchState.batchId,
1818
+ `preserved partial progress for ${ppResult.results.filter(r => r.saved).length} failed task(s) before terminal cleanup`);
1819
+ }
1820
+ // Log warnings for failed preservation attempts — at terminal cleanup
1821
+ // we cannot skip deletion (batch is ending), but operators need to know
1822
+ // that commits may become unreachable via reflog only.
1823
+ for (const r of ppResult.results) {
1824
+ if (!r.saved && (r.commitCount > 0 || r.error)) {
1825
+ execLog("batch", batchState.batchId,
1826
+ `WARNING: Failed to preserve partial progress for task ${r.taskId} ` +
1827
+ `(${r.commitCount} commit(s) may become unreachable after cleanup)`,
1828
+ { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" });
1829
+ }
1830
+ }
1831
+ // TP-028: Stamp task outcomes with partial progress data for persistence
1832
+ applyPartialProgressToOutcomes(ppResult, allTaskOutcomes);
1833
+ }
1834
+
1375
1835
  if (!preserveWorktreesForResume) {
1376
1836
  const wtPrefix = orchConfig.orchestrator.worktree_prefix;
1377
1837
  const cleanupOpId = resolveOperatorId(orchConfig);
@@ -1416,7 +1876,12 @@ export async function resumeOrchBatch(
1416
1876
 
1417
1877
  if ((batchState.phase as OrchBatchPhase) === "executing" || (batchState.phase as OrchBatchPhase) === "merging") {
1418
1878
  if (batchState.failedTasks > 0) {
1419
- batchState.phase = "failed";
1879
+ // TP-031: Parity with engine.ts default to "paused" so the batch is
1880
+ // resumable without --force. "failed" is reserved for unrecoverable
1881
+ // invariant violations after retry exhaustion.
1882
+ // NOTE: preserveWorktreesForResume was already set pre-cleanup to ensure
1883
+ // worktrees survive; this just sets the phase for state persistence.
1884
+ batchState.phase = "paused";
1420
1885
  } else {
1421
1886
  batchState.phase = "completed";
1422
1887
  }
@@ -1450,6 +1915,10 @@ export async function resumeOrchBatch(
1450
1915
 
1451
1916
  persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
1452
1917
 
1918
+ // ── TP-031: Emit diagnostic reports (JSONL + markdown) ──
1919
+ // Non-fatal: errors are logged but never crash batch finalization.
1920
+ emitDiagnosticReports(assembleDiagnosticInput(orchConfig, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, stateRoot));
1921
+
1453
1922
  if (batchState.phase === "paused" || batchState.phase === "stopped") {
1454
1923
  execLog("resume", batchState.batchId, "resumed batch ended in non-terminal state", { phase: batchState.phase });
1455
1924
  } else {