taskplane 0.5.11 → 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,18 +2,20 @@
2
2
  * Merge orchestration, merge agents, merge worktree
3
3
  * @module orch/merge
4
4
  */
5
- import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdirSync } from "fs";
6
- import { spawnSync } from "child_process";
7
- import { join, dirname } from "path";
5
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdirSync, rmSync } from "fs";
6
+ import { execSync, spawnSync } from "child_process";
7
+ import { join, dirname, resolve, relative } from "path";
8
8
 
9
9
  import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
10
10
  import { resolveOperatorId } from "./naming.ts";
11
11
  import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MS, MergeError, VALID_MERGE_STATUSES } from "./types.ts";
12
- import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
12
+ import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
13
13
  import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
14
14
  import { generateMergeWorktreePath, sleepSync } from "./worktree.ts";
15
15
  import { getCurrentBranch, runGit } from "./git.ts";
16
16
  import { ORCH_MESSAGES } from "./messages.ts";
17
+ import { captureBaseline, diffFingerprints, runVerificationCommands, parseTestOutput, deduplicateFingerprints } from "./verification.ts";
18
+ import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
17
19
 
18
20
  // ── Merge Implementation ─────────────────────────────────────────────
19
21
 
@@ -473,6 +475,296 @@ export function waitForMergeResult(
473
475
  }
474
476
  }
475
477
 
478
+ /**
479
+ * Force-remove a merge worktree directory and prune stale git references.
480
+ *
481
+ * TP-029: Applies the same forceCleanupWorktree pattern used for lane
482
+ * worktrees. Tries `git worktree remove --force` first, then falls back
483
+ * to `rm -rf` + `git worktree prune` if the initial removal fails.
484
+ *
485
+ * Used in both stale-prep cleanup (before creating a fresh merge worktree)
486
+ * and end-of-wave cleanup (after merge completes).
487
+ *
488
+ * @param mergeWorkDir - Absolute path to the merge worktree directory
489
+ * @param repoRoot - Main repository root for git operations
490
+ * @param context - Logging context (e.g., "W1" for wave 1)
491
+ */
492
+ function forceRemoveMergeWorktree(
493
+ mergeWorkDir: string,
494
+ repoRoot: string,
495
+ context: string,
496
+ ): void {
497
+ if (!existsSync(mergeWorkDir)) return;
498
+
499
+ // Try git worktree remove --force first
500
+ const removeResult = spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { cwd: repoRoot });
501
+ if (removeResult.status === 0) {
502
+ return;
503
+ }
504
+
505
+ // Fallback: force-remove the directory and prune git worktree state
506
+ const stderr = removeResult.stderr?.toString().trim() || "";
507
+ execLog("merge", context, `git worktree remove failed for merge worktree, applying force cleanup`, {
508
+ error: stderr.slice(0, 200),
509
+ path: mergeWorkDir,
510
+ });
511
+
512
+ try {
513
+ rmSync(mergeWorkDir, { recursive: true, force: true });
514
+ execLog("merge", context, `force-removed merge worktree directory`, { path: mergeWorkDir });
515
+ } catch (rmErr: unknown) {
516
+ // Node's rmSync may fail on Windows reserved-name files — try OS-level removal
517
+ const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr);
518
+ execLog("merge", context, `rmSync failed for merge worktree, trying OS-level removal`, { error: rmMsg });
519
+ try {
520
+ if (process.platform === "win32") {
521
+ execSync(`rd /s /q "${mergeWorkDir}"`, { stdio: "pipe", timeout: 30_000 });
522
+ } else {
523
+ execSync(`rm -rf "${mergeWorkDir}"`, { stdio: "pipe", timeout: 30_000 });
524
+ }
525
+ execLog("merge", context, `OS-level removal of merge worktree succeeded`, { path: mergeWorkDir });
526
+ } catch (osErr: unknown) {
527
+ const osMsg = osErr instanceof Error ? osErr.message : String(osErr);
528
+ execLog("merge", context, `OS-level removal also failed — manual cleanup needed`, {
529
+ path: mergeWorkDir,
530
+ error: osMsg,
531
+ });
532
+ }
533
+ }
534
+
535
+ // Prune stale worktree references
536
+ runGit(["worktree", "prune"], repoRoot);
537
+ }
538
+
539
+ // ── Transaction Record Persistence (TP-033) ─────────────────────────
540
+
541
+ /**
542
+ * Persist a transaction record to disk as JSON.
543
+ *
544
+ * Written to: `.pi/verification/{opId}/txn-b{batchId}-repo-{repoId}-wave-{n}-lane-{k}.json`
545
+ *
546
+ * When repoId is null/undefined (repo mode), uses "default" as the repo slug.
547
+ * Non-alphanumeric characters in repoId are sanitized to underscores.
548
+ *
549
+ * @param record - The transaction record to persist
550
+ * @param stateRoot - Root directory for .pi state files
551
+ */
552
+ /**
553
+ * Persist a transaction record to disk. Returns null on success, or an error
554
+ * message string on failure. Persistence is best-effort — callers should
555
+ * accumulate errors and surface them in MergeWaveResult.persistenceErrors
556
+ * so operators know recovery guidance may reference missing files.
557
+ */
558
+ function persistTransactionRecord(record: TransactionRecord, stateRoot: string): string | null {
559
+ try {
560
+ const repoSlug = record.repoId
561
+ ? record.repoId.replace(/[^a-zA-Z0-9_-]/g, "_")
562
+ : "default";
563
+ const verifyDir = join(stateRoot, ".pi", "verification", record.opId);
564
+ mkdirSync(verifyDir, { recursive: true });
565
+ const fileName = `txn-b${record.batchId}-repo-${repoSlug}-wave-${record.waveIndex}-lane-${record.laneNumber}.json`;
566
+ writeFileSync(
567
+ join(verifyDir, fileName),
568
+ JSON.stringify(record, null, 2),
569
+ "utf-8",
570
+ );
571
+ execLog("merge", `W${record.waveIndex}`, `transaction record persisted`, {
572
+ file: fileName,
573
+ status: record.status,
574
+ });
575
+ return null;
576
+ } catch (err: unknown) {
577
+ // Transaction record persistence is best-effort — don't fail the merge
578
+ const errMsg = err instanceof Error ? err.message : String(err);
579
+ execLog("merge", `W${record.waveIndex}`, `failed to persist transaction record: ${errMsg}`);
580
+ return `lane ${record.laneNumber} (repo: ${record.repoId ?? "default"}): ${errMsg}`;
581
+ }
582
+ }
583
+
584
+ // ── Orchestrator-Side Verification (TP-032) ──────────────────────────
585
+
586
+ /**
587
+ * Run post-merge verification and compare against baseline.
588
+ *
589
+ * Captures fingerprints from the merge worktree after a successful merge,
590
+ * diffs against the pre-merge baseline, and classifies the result:
591
+ * - "pass": no new failures (only pre-existing or fixed)
592
+ * - "verification_new_failure": genuinely new failures detected
593
+ * - "flaky_suspected": new failures disappeared on re-run (warning only)
594
+ *
595
+ * Flaky handling: when new failures are detected and flakyReruns > 0,
596
+ * only the commands that produced new failures are re-run up to
597
+ * flakyReruns times. If the failures disappear on any re-run attempt,
598
+ * the result is reclassified as "flaky_suspected". When flakyReruns is
599
+ * 0, no re-runs are attempted and new failures immediately block.
600
+ *
601
+ * @param testingCommands - Named verification commands (from testing.commands config)
602
+ * @param mergeWorkDir - Merge worktree path (post-merge state)
603
+ * @param baseline - Pre-merge baseline to compare against
604
+ * @param laneNumber - Lane number (for logging/persistence)
605
+ * @param waveIndex - Wave index (for persistence naming)
606
+ * @param batchId - Batch ID (for persistence naming)
607
+ * @param opId - Operator ID (for persistence naming)
608
+ * @param sessionName - Session name for structured logging
609
+ * @param stateRoot - State root for persistence (workspace root or repo root)
610
+ * @param repoId - Repository ID for workspace-mode artifact naming (optional)
611
+ * @param flakyReruns - Number of flaky re-runs (0 = disabled, default 1)
612
+ * @returns VerificationBaselineResult with classification and details
613
+ */
614
+ function runPostMergeVerification(
615
+ testingCommands: Record<string, string>,
616
+ mergeWorkDir: string,
617
+ baseline: VerificationBaseline,
618
+ laneNumber: number,
619
+ waveIndex: number,
620
+ batchId: string,
621
+ opId: string,
622
+ sessionName: string,
623
+ stateRoot: string,
624
+ repoId?: string,
625
+ flakyReruns: number = 1,
626
+ ): VerificationBaselineResult {
627
+ execLog("merge", sessionName, "capturing post-merge verification fingerprints");
628
+
629
+ // Capture post-merge fingerprints
630
+ const postMerge = captureBaseline(testingCommands, mergeWorkDir);
631
+
632
+ // Persist post-merge snapshot for debugging
633
+ try {
634
+ const verifyDir = join(stateRoot, ".pi", "verification", opId);
635
+ mkdirSync(verifyDir, { recursive: true });
636
+ // TP-032 R006-1: Include repoId in filename to prevent overwrites
637
+ // when mergeWaveByRepo() calls mergeWave() once per repo group.
638
+ const repoSuffix = repoId ? `-repo-${repoId.replace(/[^a-zA-Z0-9_-]/g, "_")}` : "";
639
+ const postFileName = `post-b${batchId}-w${waveIndex}${repoSuffix}-lane${laneNumber}.json`;
640
+ writeFileSync(
641
+ join(verifyDir, postFileName),
642
+ JSON.stringify(postMerge, null, 2),
643
+ "utf-8",
644
+ );
645
+ } catch {
646
+ // Best effort — persistence failure doesn't block verification
647
+ }
648
+
649
+ // Diff fingerprints
650
+ const diff = diffFingerprints(baseline.fingerprints, postMerge.fingerprints);
651
+
652
+ execLog("merge", sessionName, "verification diff computed", {
653
+ newFailures: diff.newFailures.length,
654
+ preExisting: diff.preExisting.length,
655
+ fixed: diff.fixed.length,
656
+ });
657
+
658
+ // No new failures — pass
659
+ if (diff.newFailures.length === 0) {
660
+ return {
661
+ performed: true,
662
+ newFailureCount: 0,
663
+ preExistingCount: diff.preExisting.length,
664
+ fixedCount: diff.fixed.length,
665
+ classification: "pass",
666
+ newFailureSummary: "",
667
+ flakyRerunPerformed: false,
668
+ };
669
+ }
670
+
671
+ // ── Flaky re-run: re-run only the commands that produced new failures ──
672
+ // Only when flakyReruns > 0 (0 = disabled — any new failure immediately blocks)
673
+ if (flakyReruns > 0) {
674
+ // Identify which commandIds produced new failures
675
+ const failedCommandIds = new Set(diff.newFailures.map(fp => fp.commandId));
676
+ const rerunCommands: Record<string, string> = {};
677
+ for (const cmdId of failedCommandIds) {
678
+ if (testingCommands[cmdId]) {
679
+ rerunCommands[cmdId] = testingCommands[cmdId];
680
+ }
681
+ }
682
+
683
+ // Re-run up to flakyReruns times; break early if failures clear
684
+ let clearedOnRerun = false;
685
+ for (let attempt = 0; attempt < flakyReruns; attempt++) {
686
+ execLog("merge", sessionName, `new failures detected — running flaky re-run ${attempt + 1}/${flakyReruns}`, {
687
+ failedCommands: [...failedCommandIds].join(", "),
688
+ rerunCount: Object.keys(rerunCommands).length,
689
+ });
690
+
691
+ const rerunResults = runVerificationCommands(rerunCommands, mergeWorkDir);
692
+
693
+ // Parse re-run fingerprints
694
+ const rerunFingerprints: TestFingerprint[] = [];
695
+ for (const result of rerunResults) {
696
+ const fps = parseTestOutput(result);
697
+ rerunFingerprints.push(...fps);
698
+ }
699
+ const dedupedRerun = deduplicateFingerprints(rerunFingerprints);
700
+
701
+ // Re-diff: compare baseline against re-run results for the failed commands only
702
+ // Filter baseline fingerprints to only the commands we re-ran
703
+ const baselineForRerun = baseline.fingerprints.filter(fp => failedCommandIds.has(fp.commandId));
704
+ const rerunDiff = diffFingerprints(baselineForRerun, dedupedRerun);
705
+
706
+ if (rerunDiff.newFailures.length === 0) {
707
+ // Failures disappeared on re-run — flaky suspected
708
+ execLog("merge", sessionName, `flaky re-run ${attempt + 1} cleared all new failures — classifying as flaky_suspected`);
709
+ clearedOnRerun = true;
710
+ break;
711
+ }
712
+
713
+ // If this is the last attempt and failures persist, return failure
714
+ if (attempt === flakyReruns - 1) {
715
+ const summary = rerunDiff.newFailures
716
+ .slice(0, 5)
717
+ .map(fp => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`)
718
+ .join("; ");
719
+ const truncated = rerunDiff.newFailures.length > 5
720
+ ? ` ... and ${rerunDiff.newFailures.length - 5} more`
721
+ : "";
722
+
723
+ return {
724
+ performed: true,
725
+ newFailureCount: rerunDiff.newFailures.length,
726
+ preExistingCount: diff.preExisting.length,
727
+ fixedCount: diff.fixed.length,
728
+ classification: "verification_new_failure",
729
+ newFailureSummary: summary + truncated,
730
+ flakyRerunPerformed: true,
731
+ };
732
+ }
733
+ }
734
+
735
+ if (clearedOnRerun) {
736
+ return {
737
+ performed: true,
738
+ newFailureCount: 0,
739
+ preExistingCount: diff.preExisting.length,
740
+ fixedCount: diff.fixed.length,
741
+ classification: "flaky_suspected",
742
+ newFailureSummary: `Flaky: ${diff.newFailures.length} failure(s) disappeared on re-run`,
743
+ flakyRerunPerformed: true,
744
+ };
745
+ }
746
+ }
747
+
748
+ // flakyReruns === 0 or fallthrough: new failures block immediately
749
+ const summary = diff.newFailures
750
+ .slice(0, 5)
751
+ .map(fp => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`)
752
+ .join("; ");
753
+ const truncated = diff.newFailures.length > 5
754
+ ? ` ... and ${diff.newFailures.length - 5} more`
755
+ : "";
756
+
757
+ return {
758
+ performed: true,
759
+ newFailureCount: diff.newFailures.length,
760
+ preExistingCount: diff.preExisting.length,
761
+ fixedCount: diff.fixed.length,
762
+ classification: "verification_new_failure",
763
+ newFailureSummary: summary + truncated,
764
+ flakyRerunPerformed: flakyReruns > 0,
765
+ };
766
+ }
767
+
476
768
  /**
477
769
  * Merge a completed wave's lane branches into the base branch.
478
770
  *
@@ -515,6 +807,8 @@ export function mergeWave(
515
807
  baseBranch: string,
516
808
  stateRoot?: string,
517
809
  agentRoot?: string,
810
+ testingCommands?: Record<string, string>,
811
+ repoId?: string,
518
812
  ): MergeWaveResult {
519
813
  const startTime = Date.now();
520
814
  const tmuxPrefix = config.orchestrator.tmux_prefix;
@@ -574,13 +868,15 @@ export function mergeWave(
574
868
  const tempBranch = `_merge-temp-${opId}-${batchId}`;
575
869
  const mergeWorkDir = generateMergeWorktreePath(repoRoot, opId, batchId, config);
576
870
 
577
- // Clean up stale merge worktree/branch from prior failed attempt
578
- try {
579
- if (existsSync(mergeWorkDir)) {
580
- spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { cwd: repoRoot });
581
- sleepSync(500);
582
- }
583
- } catch { /* best effort */ }
871
+ // Clean up stale merge worktree/branch from prior failed attempt.
872
+ // TP-029: Apply forceRemoveMergeWorktree fallback so stale merge worktrees
873
+ // from prior failed attempts don't block new merge creation.
874
+ forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
875
+ if (existsSync(mergeWorkDir)) {
876
+ // Force cleanup didn't fully remove — wait and retry once
877
+ sleepSync(500);
878
+ forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
879
+ }
584
880
  try {
585
881
  spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
586
882
  } catch { /* branch may not exist */ }
@@ -614,12 +910,109 @@ export function mergeWave(
614
910
  tempBranch,
615
911
  });
616
912
 
913
+ // ── Orchestrator-side baseline capture (TP-032) ────────────────
914
+ // Capture verification fingerprints on the pre-merge state of the merge
915
+ // worktree. This baseline is compared against post-merge fingerprints
916
+ // for each lane to detect genuinely new failures vs pre-existing ones.
917
+ // Only runs when verification.enabled === true AND testing.commands present.
918
+ let baseline: VerificationBaseline | null = null;
919
+ const hasTestingCommands = testingCommands && Object.keys(testingCommands).length > 0;
920
+ const verificationEnabled = config.verification.enabled;
921
+ const verificationMode = config.verification.mode;
922
+ const flakyReruns = config.verification.flaky_reruns;
923
+
924
+ if (verificationEnabled && !hasTestingCommands) {
925
+ // Verification is enabled but no testing commands configured — treat as
926
+ // baseline-unavailable. Strict/permissive handling below.
927
+ if (verificationMode === "strict") {
928
+ execLog("merge", `W${waveIndex}`, "verification enabled but no testing commands configured — strict mode: failing merge");
929
+ // Clean up worktree and temp branch before returning failure
930
+ forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
931
+ try { spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot }); } catch { /* best effort */ }
932
+ return {
933
+ waveIndex, status: "failed", laneResults: [],
934
+ failedLane: null,
935
+ failureReason: "Verification enabled (strict mode) but no testing commands configured in taskRunner.testing.commands",
936
+ totalDurationMs: Date.now() - startTime,
937
+ };
938
+ } else {
939
+ execLog("merge", `W${waveIndex}`, "verification enabled but no testing commands configured — permissive mode: continuing without verification");
940
+ }
941
+ }
942
+
943
+ if (verificationEnabled && hasTestingCommands) {
944
+ execLog("merge", `W${waveIndex}`, "capturing verification baseline on pre-merge state", {
945
+ commandCount: Object.keys(testingCommands).length,
946
+ commands: Object.keys(testingCommands).join(", "),
947
+ });
948
+
949
+ try {
950
+ baseline = captureBaseline(testingCommands, mergeWorkDir);
951
+
952
+ // Persist baseline for debugging/auditability
953
+ const piDir = stateRoot ?? repoRoot;
954
+ const verifyDir = join(piDir, ".pi", "verification", opId);
955
+ mkdirSync(verifyDir, { recursive: true });
956
+ // TP-032 R006-1: Include repoId in filename to prevent overwrites
957
+ // when mergeWaveByRepo() calls mergeWave() once per repo group.
958
+ const repoSuffix = repoId ? `-repo-${repoId.replace(/[^a-zA-Z0-9_-]/g, "_")}` : "";
959
+ const baselineFileName = `baseline-b${batchId}-w${waveIndex}${repoSuffix}.json`;
960
+ writeFileSync(
961
+ join(verifyDir, baselineFileName),
962
+ JSON.stringify(baseline, null, 2),
963
+ "utf-8",
964
+ );
965
+
966
+ execLog("merge", `W${waveIndex}`, "verification baseline captured", {
967
+ fingerprints: baseline.fingerprints.length,
968
+ preExistingFailures: baseline.fingerprints.length,
969
+ storedAt: join(verifyDir, baselineFileName),
970
+ });
971
+ } catch (err: unknown) {
972
+ const errMsg = err instanceof Error ? err.message : String(err);
973
+ if (verificationMode === "strict") {
974
+ execLog("merge", `W${waveIndex}`, `baseline capture failed — strict mode: failing merge`, {
975
+ error: errMsg,
976
+ });
977
+ // Clean up worktree and temp branch before returning failure
978
+ forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
979
+ try { spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot }); } catch { /* best effort */ }
980
+ return {
981
+ waveIndex, status: "failed", laneResults: [],
982
+ failedLane: null,
983
+ failureReason: `Verification baseline capture failed (strict mode): ${errMsg}`,
984
+ totalDurationMs: Date.now() - startTime,
985
+ };
986
+ }
987
+ execLog("merge", `W${waveIndex}`, `baseline capture failed — permissive mode: continuing without baseline verification`, {
988
+ error: errMsg,
989
+ });
990
+ // Permissive: baseline capture failure is non-fatal — merge proceeds without
991
+ // orchestrator-side verification. Merge-agent verification (merge.verify)
992
+ // still applies independently.
993
+ baseline = null;
994
+ }
995
+ }
996
+
617
997
  // Sequential merge loop
618
998
  let failedLane: number | null = null;
619
999
  let failureReason: string | null = null;
1000
+ // TP-032 R006-2: When verification rollback fails, the temp branch still contains
1001
+ // the bad merge commit. Branch advancement MUST be blocked entirely — not just for
1002
+ // the verification-blocked lane, but for all lanes, because the temp branch HEAD
1003
+ // includes the unverified commit and any prior successful merges built on top of it.
1004
+ let blockAdvancement = false;
1005
+
1006
+ // TP-033: Collect transaction records for all lane merges in this wave
1007
+ const transactionRecords: TransactionRecord[] = [];
1008
+ // TP-033 R004-2: Track persistence errors for operator visibility
1009
+ const persistenceErrors: string[] = [];
1010
+ // TP-033: Track whether any rollback failure triggered safe-stop
1011
+ let rollbackFailed = false;
620
1012
 
621
1013
  for (const lane of orderedLanes) {
622
1014
  const laneStart = Date.now();
1015
+ const txnStartedAt = new Date().toISOString();
623
1016
  const sessionName = `${tmuxPrefix}-${opId}-merge-${lane.laneNumber}`;
624
1017
  const resultFileName = `merge-result-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.json`;
625
1018
  const piDir = stateRoot ?? repoRoot;
@@ -627,9 +1020,34 @@ export function mergeWave(
627
1020
  const requestFileName = `merge-request-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.txt`;
628
1021
  const requestFilePath = join(piDir, ".pi", requestFileName);
629
1022
 
1023
+ // ── TP-033: Capture baseHEAD (temp branch HEAD before lane merge) ──
1024
+ // Always captured for transaction record — not conditional on baseline.
1025
+ // This is the rollback target if verification detects new failures.
1026
+ let baseHEAD = "";
1027
+ {
1028
+ const headResult = spawnSync("git", ["rev-parse", "HEAD"], { cwd: mergeWorkDir, encoding: "utf-8" });
1029
+ if (headResult.status === 0) {
1030
+ baseHEAD = headResult.stdout.trim();
1031
+ }
1032
+ }
1033
+
1034
+ // ── TP-033: Capture laneHEAD (source branch tip being merged in) ──
1035
+ let laneHEAD = "";
1036
+ {
1037
+ const laneRef = spawnSync("git", ["rev-parse", lane.branch], { cwd: repoRoot, encoding: "utf-8" });
1038
+ if (laneRef.status === 0) {
1039
+ laneHEAD = laneRef.stdout.trim();
1040
+ }
1041
+ }
1042
+
1043
+ // TP-032 compat: preLaneHead is baseHEAD (renamed for clarity in txn model)
1044
+ const preLaneHead = baseHEAD;
1045
+
630
1046
  execLog("merge", sessionName, `starting merge for lane ${lane.laneNumber}`, {
631
1047
  sourceBranch: lane.branch,
632
1048
  targetBranch,
1049
+ baseHEAD: baseHEAD.slice(0, 8),
1050
+ laneHEAD: laneHEAD.slice(0, 8),
633
1051
  });
634
1052
 
635
1053
  try {
@@ -643,6 +1061,11 @@ export function mergeWave(
643
1061
  }
644
1062
 
645
1063
  // Build merge request content
1064
+ // TP-032 R006-3: Preserve merge.verify commands independently of baseline
1065
+ // fingerprinting. The orchestrator-side baseline comparison (testing.commands)
1066
+ // is additive — it does NOT replace the merge agent's own verification
1067
+ // (merge.verify). Agents may run build checks or other non-fingerprintable
1068
+ // commands via merge.verify that must not be silently suppressed.
646
1069
  const mergeRequestContent = buildMergeRequest(
647
1070
  lane,
648
1071
  targetBranch,
@@ -668,8 +1091,8 @@ export function mergeWave(
668
1091
  // Best effort
669
1092
  }
670
1093
 
671
- // Record lane result
672
- laneResults.push({
1094
+ // Record lane result (verificationBaseline populated below if applicable)
1095
+ const laneResult: MergeLaneResult = {
673
1096
  laneNumber: lane.laneNumber,
674
1097
  laneId: lane.laneId,
675
1098
  sourceBranch: lane.branch,
@@ -678,7 +1101,8 @@ export function mergeWave(
678
1101
  error: null,
679
1102
  durationMs: Date.now() - laneStart,
680
1103
  repoId: lane.repoId,
681
- });
1104
+ };
1105
+ laneResults.push(laneResult);
682
1106
 
683
1107
  // Handle merge outcome
684
1108
  switch (mergeResult.status) {
@@ -708,8 +1132,14 @@ export function mergeWave(
708
1132
  break;
709
1133
 
710
1134
  case "BUILD_FAILURE":
1135
+ // TP-032: When baseline is active, BUILD_FAILURE from the merge agent
1136
+ // should not normally occur (we suppress verify commands). But if it does
1137
+ // (e.g., agent detected build failure independently), log and proceed as
1138
+ // a regular failure — the orchestrator-side verification below will not
1139
+ // run because the agent already reverted the merge commit.
711
1140
  execLog("merge", sessionName, "merge failed — verification failed", {
712
1141
  output: mergeResult.verification.output.slice(0, 200),
1142
+ baselineActive: !!baseline,
713
1143
  });
714
1144
  failedLane = lane.laneNumber;
715
1145
  failureReason = `Post-merge verification failed in lane ${lane.laneNumber}: ` +
@@ -717,6 +1147,162 @@ export function mergeWave(
717
1147
  break;
718
1148
  }
719
1149
 
1150
+ // ── TP-033: Capture mergedHEAD after successful merge commit ──
1151
+ let mergedHEAD: string | null = null;
1152
+ if (mergeResult.status === "SUCCESS" || mergeResult.status === "CONFLICT_RESOLVED") {
1153
+ const postMergeRef = spawnSync("git", ["rev-parse", "HEAD"], { cwd: mergeWorkDir, encoding: "utf-8" });
1154
+ if (postMergeRef.status === 0) {
1155
+ mergedHEAD = postMergeRef.stdout.trim();
1156
+ }
1157
+ }
1158
+
1159
+ // ── TP-033: Initialize transaction record for this lane ──
1160
+ let txnStatus: TransactionStatus = failedLane !== null ? "merge_failed" : "committed";
1161
+ let txnRollbackAttempted = false;
1162
+ let txnRollbackResult: string | null = null;
1163
+ let txnRecoveryCommands: string[] = [];
1164
+
1165
+ // ── Orchestrator-side post-merge verification (TP-032) ──────
1166
+ // After a successful merge (SUCCESS/CONFLICT_RESOLVED), capture
1167
+ // post-merge fingerprints and diff against baseline. New failures
1168
+ // that weren't in the baseline block merge advancement.
1169
+ if (
1170
+ baseline !== null &&
1171
+ hasTestingCommands &&
1172
+ verificationEnabled &&
1173
+ failedLane === null &&
1174
+ (mergeResult.status === "SUCCESS" || mergeResult.status === "CONFLICT_RESOLVED")
1175
+ ) {
1176
+ const verificationResult = runPostMergeVerification(
1177
+ testingCommands!,
1178
+ mergeWorkDir,
1179
+ baseline,
1180
+ lane.laneNumber,
1181
+ waveIndex,
1182
+ batchId,
1183
+ opId,
1184
+ sessionName,
1185
+ stateRoot ?? repoRoot,
1186
+ repoId,
1187
+ flakyReruns,
1188
+ );
1189
+
1190
+ // Attach verification result to the lane result
1191
+ laneResult.verificationBaseline = verificationResult;
1192
+
1193
+ if (verificationResult.classification === "verification_new_failure") {
1194
+ execLog("merge", sessionName, "orchestrator-side verification detected new failures", {
1195
+ newFailures: verificationResult.newFailureCount,
1196
+ preExisting: verificationResult.preExistingCount,
1197
+ summary: verificationResult.newFailureSummary.slice(0, 200),
1198
+ });
1199
+
1200
+ // ── TP-032: Rollback merge commit on verification_new_failure ──
1201
+ // Reset the temp branch to pre-lane HEAD so the failed lane's
1202
+ // merge commit doesn't get included in branch advancement.
1203
+ // TP-032 R006-2: Mark lane as errored so it's excluded from success
1204
+ // counters and branch advancement (R006-3).
1205
+ laneResult.error = `verification_new_failure: ${verificationResult.newFailureCount} new failure(s)`;
1206
+
1207
+ if (preLaneHead) {
1208
+ txnRollbackAttempted = true;
1209
+ execLog("merge", sessionName, "rolling back temp branch to pre-lane HEAD", {
1210
+ preLaneHead: preLaneHead.slice(0, 8),
1211
+ });
1212
+ const resetResult = spawnSync("git", ["reset", "--hard", preLaneHead], { cwd: mergeWorkDir });
1213
+ if (resetResult.status === 0) {
1214
+ execLog("merge", sessionName, "temp branch rolled back successfully");
1215
+ txnStatus = "rolled_back";
1216
+ txnRollbackResult = "success";
1217
+ } else {
1218
+ // TP-032 R006-2: Rollback failure is merge-fatal for this wave.
1219
+ // The temp branch still contains the failing merge commit — target
1220
+ // ref advancement MUST NOT proceed for ANY lane, because the temp
1221
+ // branch HEAD includes the unverified commit.
1222
+ const resetErr = resetResult.stderr?.toString().trim() || "unknown error";
1223
+ laneResult.error = `verification_new_failure: rollback reset failed (${resetErr}) — ` +
1224
+ `temp branch may contain failing merge commit, advancement blocked`;
1225
+ blockAdvancement = true;
1226
+ txnStatus = "rollback_failed";
1227
+ txnRollbackResult = `reset failed: ${resetErr}`;
1228
+
1229
+ // ── TP-033: Safe-stop — emit recovery commands ──
1230
+ txnRecoveryCommands = [
1231
+ `# Recovery: manually reset merge worktree to pre-lane HEAD`,
1232
+ `cd "${mergeWorkDir}"`,
1233
+ `git reset --hard ${preLaneHead}`,
1234
+ `# Then re-run merge or resume orchestration`,
1235
+ ];
1236
+ rollbackFailed = true;
1237
+
1238
+ execLog("merge", sessionName, `CRITICAL: rollback reset failed: ${resetErr} — safe-stop triggered`, {
1239
+ preLaneHead: preLaneHead.slice(0, 8),
1240
+ recoveryCommands: txnRecoveryCommands,
1241
+ });
1242
+ }
1243
+ } else {
1244
+ // TP-032 R006-2: No pre-lane HEAD captured — cannot roll back.
1245
+ // Block advancement since the bad commit cannot be removed.
1246
+ laneResult.error = `verification_new_failure: no pre-lane HEAD available for rollback — ` +
1247
+ `advancement blocked`;
1248
+ blockAdvancement = true;
1249
+ txnStatus = "rollback_failed";
1250
+ txnRollbackAttempted = false;
1251
+ txnRollbackResult = "no baseHEAD captured — rollback impossible";
1252
+
1253
+ // ── TP-033: Safe-stop — emit recovery commands ──
1254
+ txnRecoveryCommands = [
1255
+ `# Recovery: no baseHEAD was captured for rollback`,
1256
+ `# Inspect merge worktree state manually:`,
1257
+ `cd "${mergeWorkDir}"`,
1258
+ `git log --oneline -5`,
1259
+ `# Determine the correct pre-merge commit and reset:`,
1260
+ `# git reset --hard <correct-commit>`,
1261
+ ];
1262
+ rollbackFailed = true;
1263
+
1264
+ execLog("merge", sessionName, "CRITICAL: no baseHEAD — cannot roll back, safe-stop triggered");
1265
+ }
1266
+
1267
+ failedLane = lane.laneNumber;
1268
+ failureReason = `Verification baseline comparison detected ${verificationResult.newFailureCount} new failure(s) ` +
1269
+ `in lane ${lane.laneNumber} (${verificationResult.preExistingCount} pre-existing). ` +
1270
+ verificationResult.newFailureSummary.slice(0, 300);
1271
+ } else if (verificationResult.classification === "flaky_suspected") {
1272
+ execLog("merge", sessionName, "flaky test suspected — failures disappeared on re-run (warning only)", {
1273
+ newFailures: verificationResult.newFailureCount,
1274
+ flakyRerun: true,
1275
+ });
1276
+ // Warning only — does not block merge advancement
1277
+ } else {
1278
+ execLog("merge", sessionName, "orchestrator-side verification passed", {
1279
+ preExisting: verificationResult.preExistingCount,
1280
+ fixed: verificationResult.fixedCount,
1281
+ });
1282
+ }
1283
+ }
1284
+
1285
+ // ── TP-033: Persist transaction record for this lane ──
1286
+ const txnRecord: TransactionRecord = {
1287
+ opId,
1288
+ batchId,
1289
+ waveIndex,
1290
+ laneNumber: lane.laneNumber,
1291
+ repoId: repoId ?? null,
1292
+ baseHEAD,
1293
+ laneHEAD,
1294
+ mergedHEAD,
1295
+ status: txnStatus,
1296
+ rollbackAttempted: txnRollbackAttempted,
1297
+ rollbackResult: txnRollbackResult,
1298
+ recoveryCommands: txnRecoveryCommands,
1299
+ startedAt: txnStartedAt,
1300
+ completedAt: new Date().toISOString(),
1301
+ };
1302
+ transactionRecords.push(txnRecord);
1303
+ const txnPersistError = persistTransactionRecord(txnRecord, stateRoot ?? repoRoot);
1304
+ if (txnPersistError) persistenceErrors.push(txnPersistError);
1305
+
720
1306
  // Stop merging if this lane failed
721
1307
  if (failedLane !== null) break;
722
1308
 
@@ -749,6 +1335,27 @@ export function mergeWave(
749
1335
  repoId: lane.repoId,
750
1336
  });
751
1337
 
1338
+ // ── TP-033: Transaction record for merge error ──
1339
+ const errorTxnRecord: TransactionRecord = {
1340
+ opId,
1341
+ batchId,
1342
+ waveIndex,
1343
+ laneNumber: lane.laneNumber,
1344
+ repoId: repoId ?? null,
1345
+ baseHEAD,
1346
+ laneHEAD,
1347
+ mergedHEAD: null,
1348
+ status: "merge_failed",
1349
+ rollbackAttempted: false,
1350
+ rollbackResult: null,
1351
+ recoveryCommands: [],
1352
+ startedAt: txnStartedAt,
1353
+ completedAt: new Date().toISOString(),
1354
+ };
1355
+ transactionRecords.push(errorTxnRecord);
1356
+ const errorTxnPersistError = persistTransactionRecord(errorTxnRecord, stateRoot ?? repoRoot);
1357
+ if (errorTxnPersistError) persistenceErrors.push(errorTxnPersistError);
1358
+
752
1359
  failedLane = lane.laneNumber;
753
1360
  failureReason = `Merge error in lane ${lane.laneNumber}: ${errMsg}`;
754
1361
  break;
@@ -756,57 +1363,93 @@ export function mergeWave(
756
1363
  }
757
1364
 
758
1365
  // ── Stage workspace task artifacts into merge worktree ──────────
759
- // In workspace mode, workers write .DONE and STATUS.md to the canonical
760
- // task folder (e.g., shared-libs/task-management/...) which is the repo's
761
- // checked-out working tree (develop). These files need to be on the orch
762
- // branch, not develop. Copy them into the merge worktree (which is on the
763
- // orch branch's temp) and commit, so they're included in the update-ref.
1366
+ // TP-035: Tightened artifact staging only allowlisted task-owned files
1367
+ // are staged. The allowlist is derived per-task-folder from completed lanes:
1368
+ // exactly `.DONE`, `STATUS.md`, and `REVIEW_VERDICT.json` (when present).
1369
+ // Files outside known task folders, worktree internals, and repo-escape
1370
+ // paths are rejected. Uses resolve+relative path containment consistent
1371
+ // with ensureTaskFilesCommitted() in execution.ts.
764
1372
  if (mergeWorkDir) {
765
- const statusResult = spawnSync("git", ["status", "--porcelain"], { cwd: repoRoot, encoding: "utf-8" });
766
- if (statusResult.status === 0 && statusResult.stdout) {
767
- const lines = statusResult.stdout.split("\n").filter((l: string) => l.trim());
768
- const artifactFiles = lines
769
- .map((l: string) => l.slice(3).trim())
770
- .filter((f: string) => f.endsWith(".DONE") || f.endsWith("STATUS.md"));
771
-
772
- if (artifactFiles.length > 0) {
773
- let staged = 0;
774
- for (const file of artifactFiles) {
775
- const srcPath = join(repoRoot, file);
776
- const destPath = join(mergeWorkDir, file);
777
- try {
778
- if (existsSync(srcPath)) {
779
- mkdirSync(dirname(destPath), { recursive: true });
780
- copyFileSync(srcPath, destPath);
781
- spawnSync("git", ["add", file], { cwd: mergeWorkDir });
782
- staged++;
783
- }
784
- } catch { /* best effort */ }
1373
+ // Build the set of allowed artifact paths (repo-root-relative) from
1374
+ // the completed lanes' task folders.
1375
+ const ALLOWED_ARTIFACT_NAMES = [".DONE", "STATUS.md", "REVIEW_VERDICT.json"];
1376
+ const resolvedRepoRoot = resolve(repoRoot);
1377
+ const allowedRelPaths = new Set<string>();
1378
+
1379
+ for (const lane of orderedLanes) {
1380
+ for (const allocTask of lane.tasks) {
1381
+ const absFolder = resolve(allocTask.task.taskFolder);
1382
+ const relFolder = relative(resolvedRepoRoot, absFolder).replace(/\\/g, "/");
1383
+
1384
+ // Reject paths that escape the repo root
1385
+ if (relFolder.startsWith("..") || relFolder.startsWith("/")) {
1386
+ execLog("merge", `W${waveIndex}`, `skipping task folder outside repo root`, {
1387
+ taskId: allocTask.taskId,
1388
+ folder: relFolder,
1389
+ });
1390
+ continue;
785
1391
  }
786
- if (staged > 0) {
787
- spawnSync("git", ["commit", "-m", `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md)`], { cwd: mergeWorkDir });
788
- execLog("merge", `W${waveIndex}`, `committed ${staged} task artifact(s) to merge worktree`);
789
-
790
- // Restore .DONE files only — these are untracked and would cause
791
- // "dirty working tree" issues on /orch-integrate.
792
- // Keep STATUS.md modifications in develop's working tree so the
793
- // dashboard can read current progress from the canonical path.
794
- for (const file of artifactFiles) {
795
- if (file.endsWith(".DONE")) {
796
- const srcPath = join(repoRoot, file);
797
- try { if (existsSync(srcPath)) unlinkSync(srcPath); } catch { /* best effort */ }
798
- }
799
- }
1392
+
1393
+ for (const name of ALLOWED_ARTIFACT_NAMES) {
1394
+ allowedRelPaths.add(`${relFolder}/${name}`);
800
1395
  }
801
1396
  }
802
1397
  }
1398
+
1399
+ if (allowedRelPaths.size > 0) {
1400
+ let staged = 0;
1401
+ let skipped = 0;
1402
+
1403
+ for (const relPath of allowedRelPaths) {
1404
+ const srcPath = join(repoRoot, relPath);
1405
+ if (!existsSync(srcPath)) continue; // File not present (e.g., no REVIEW_VERDICT.json) — skip silently
1406
+
1407
+ const destPath = join(mergeWorkDir, relPath);
1408
+ try {
1409
+ mkdirSync(dirname(destPath), { recursive: true });
1410
+ copyFileSync(srcPath, destPath);
1411
+ // Use pathspec-safe staging with -- separator
1412
+ spawnSync("git", ["add", "--", relPath], { cwd: mergeWorkDir });
1413
+ staged++;
1414
+ } catch {
1415
+ skipped++;
1416
+ execLog("merge", `W${waveIndex}`, `failed to stage artifact`, { path: relPath });
1417
+ }
1418
+ }
1419
+
1420
+ if (staged > 0) {
1421
+ spawnSync("git", ["commit", "-m", `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md, REVIEW_VERDICT.json)`], { cwd: mergeWorkDir });
1422
+ execLog("merge", `W${waveIndex}`, `committed ${staged} task artifact(s) to merge worktree`, {
1423
+ skipped,
1424
+ allowedCandidates: allowedRelPaths.size,
1425
+ });
1426
+ } else {
1427
+ execLog("merge", `W${waveIndex}`, `no task artifacts to stage (0 of ${allowedRelPaths.size} candidates present/changed)`);
1428
+ }
1429
+
1430
+ // Keep both .DONE and STATUS.md in develop's working tree:
1431
+ // - STATUS.md: dashboard reads current progress from canonical path
1432
+ // - .DONE: harmless untracked files, cleaned up by /orch-integrate stash
1433
+ // Previous approach of deleting .DONE caused them to be missing
1434
+ // after ff integration (git couldn't reliably restore them).
1435
+ }
803
1436
  }
804
1437
 
805
1438
  // ── Update target branch ref and clean up merge worktree ────────
806
- const anySuccess = laneResults.some(
807
- r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
1439
+ // TP-032 R006-2: blockAdvancement overrides all success determination.
1440
+ // When verification rollback fails, the temp branch contains a bad merge commit
1441
+ // that would be included in branch advancement — so we block entirely.
1442
+ // Also exclude verification_new_failure lanes (with successful rollback) from
1443
+ // success accounting: they have laneResult.error set, so !r.error filters them.
1444
+ const anySuccess = !blockAdvancement && laneResults.some(
1445
+ r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
808
1446
  );
809
1447
 
1448
+ if (blockAdvancement) {
1449
+ execLog("merge", `W${waveIndex}`, "branch advancement BLOCKED due to verification rollback failure — " +
1450
+ "temp branch may contain unverified merge commit");
1451
+ }
1452
+
810
1453
  if (anySuccess) {
811
1454
  // Get the temp branch HEAD commit — this is the merged result.
812
1455
  const revParseResult = spawnSync("git", ["rev-parse", tempBranch], { cwd: repoRoot });
@@ -886,15 +1529,25 @@ export function mergeWave(
886
1529
  }
887
1530
  }
888
1531
 
889
- // Clean up merge worktree and temp branch (always, regardless of outcome)
890
- try {
891
- spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { cwd: repoRoot });
892
- } catch { /* best effort */ }
893
- try {
894
- // Small delay to ensure worktree lock is released
895
- sleepSync(500);
896
- spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
897
- } catch { /* best effort */ }
1532
+ // Clean up merge worktree and temp branch.
1533
+ // TP-033: When rollback failed (safe-stop), preserve merge worktree and temp
1534
+ // branch for manual recovery. The operator can use the recovery commands in
1535
+ // the transaction record to restore consistency.
1536
+ if (rollbackFailed) {
1537
+ execLog("merge", `W${waveIndex}`, "SAFE-STOP: preserving merge worktree and temp branch for recovery", {
1538
+ mergeWorkDir,
1539
+ tempBranch,
1540
+ });
1541
+ } else {
1542
+ // TP-029: Apply forceRemoveMergeWorktree fallback so locked/corrupted
1543
+ // merge worktrees don't persist between attempts.
1544
+ forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
1545
+ try {
1546
+ // Small delay to ensure worktree lock is released
1547
+ sleepSync(500);
1548
+ spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
1549
+ } catch { /* best effort */ }
1550
+ }
898
1551
 
899
1552
  // Determine overall status
900
1553
  let status: MergeWaveResult["status"];
@@ -909,12 +1562,12 @@ export function mergeWave(
909
1562
  const totalDurationMs = Date.now() - startTime;
910
1563
 
911
1564
  execLog("merge", `W${waveIndex}`, `wave merge complete: ${status}`, {
912
- mergedLanes: laneResults.filter(r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED").length,
1565
+ mergedLanes: laneResults.filter(r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")).length,
913
1566
  failedLane: failedLane ?? 0,
914
1567
  duration: `${Math.round(totalDurationMs / 1000)}s`,
915
1568
  });
916
1569
 
917
- return {
1570
+ const result: MergeWaveResult = {
918
1571
  waveIndex,
919
1572
  status,
920
1573
  laneResults,
@@ -922,6 +1575,21 @@ export function mergeWave(
922
1575
  failureReason,
923
1576
  totalDurationMs,
924
1577
  };
1578
+
1579
+ // TP-033: Attach transaction metadata
1580
+ if (transactionRecords.length > 0) {
1581
+ result.transactionRecords = transactionRecords;
1582
+ }
1583
+ if (rollbackFailed) {
1584
+ result.rollbackFailed = true;
1585
+ }
1586
+ // TP-033 R004-2: Surface persistence failures so operator knows
1587
+ // recovery guidance may reference missing transaction record files
1588
+ if (persistenceErrors.length > 0) {
1589
+ result.persistenceErrors = persistenceErrors;
1590
+ }
1591
+
1592
+ return result;
925
1593
  }
926
1594
 
927
1595
 
@@ -1000,6 +1668,7 @@ export function mergeWaveByRepo(
1000
1668
  workspaceConfig?: WorkspaceConfig | null,
1001
1669
  stateRoot?: string,
1002
1670
  agentRoot?: string,
1671
+ testingCommands?: Record<string, string>,
1003
1672
  ): MergeWaveResult {
1004
1673
  const startTime = Date.now();
1005
1674
 
@@ -1054,6 +1723,7 @@ export function mergeWaveByRepo(
1054
1723
  baseBranch,
1055
1724
  stateRoot,
1056
1725
  agentRoot,
1726
+ testingCommands,
1057
1727
  );
1058
1728
  // Attach empty repoResults for consistent shape
1059
1729
  return { ...result, repoResults: [] };
@@ -1062,6 +1732,9 @@ export function mergeWaveByRepo(
1062
1732
  // ── Workspace mode: per-repo merge loops ─────────────────────
1063
1733
  const allLaneResults: MergeLaneResult[] = [];
1064
1734
  const repoOutcomes: RepoMergeOutcome[] = [];
1735
+ const allTransactionRecords: TransactionRecord[] = [];
1736
+ // TP-033 R004-2: Accumulate persistence errors across all repo groups
1737
+ const allPersistenceErrors: string[] = [];
1065
1738
  let firstFailedLane: number | null = null;
1066
1739
  let firstFailureReason: string | null = null;
1067
1740
  // Track repo-level failures independently of lane-level failures.
@@ -1069,6 +1742,8 @@ export function mergeWaveByRepo(
1069
1742
  // pre-lane setup errors (temp branch creation, worktree creation).
1070
1743
  // We must detect these to avoid misclassifying the aggregate as "succeeded".
1071
1744
  let anyRepoFailed = false;
1745
+ // TP-033: Track rollback failures across all repo groups
1746
+ let anyRollbackFailed = false;
1072
1747
 
1073
1748
  for (const group of repoGroups) {
1074
1749
  const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig);
@@ -1103,11 +1778,25 @@ export function mergeWaveByRepo(
1103
1778
  groupBaseBranch,
1104
1779
  stateRoot,
1105
1780
  agentRoot,
1781
+ testingCommands,
1782
+ group.repoId,
1106
1783
  );
1107
1784
 
1108
1785
  // Accumulate lane results
1109
1786
  allLaneResults.push(...groupResult.laneResults);
1110
1787
 
1788
+ // TP-033: Accumulate transaction records and rollback status
1789
+ if (groupResult.transactionRecords) {
1790
+ allTransactionRecords.push(...groupResult.transactionRecords);
1791
+ }
1792
+ // TP-033 R004-2: Accumulate persistence errors
1793
+ if (groupResult.persistenceErrors) {
1794
+ allPersistenceErrors.push(...groupResult.persistenceErrors);
1795
+ }
1796
+ if (groupResult.rollbackFailed) {
1797
+ anyRollbackFailed = true;
1798
+ }
1799
+
1111
1800
  // Build per-repo outcome
1112
1801
  const repoOutcome: RepoMergeOutcome = {
1113
1802
  repoId: group.repoId,
@@ -1132,6 +1821,20 @@ export function mergeWaveByRepo(
1132
1821
  : `[repo:${group.repoId ?? "default"}] Merge failed (setup error)`;
1133
1822
  }
1134
1823
  }
1824
+
1825
+ // TP-033 R004-1: Safe-stop — halt all remaining repo merges immediately
1826
+ // when a rollback failure is detected. Continuing would advance refs in
1827
+ // other repos, making manual recovery harder.
1828
+ if (anyRollbackFailed) {
1829
+ const processedIndex = repoGroups.indexOf(group);
1830
+ const remainingGroups = repoGroups.slice(processedIndex + 1);
1831
+ if (remainingGroups.length > 0) {
1832
+ execLog("merge", `W${waveIndex}`, `safe-stop: skipping ${remainingGroups.length} remaining repo group(s) after rollback failure`, {
1833
+ skippedRepos: remainingGroups.map(g => g.repoId ?? "(default)").join(", "),
1834
+ });
1835
+ }
1836
+ break;
1837
+ }
1135
1838
  }
1136
1839
 
1137
1840
  // ── Aggregate status ─────────────────────────────────────────
@@ -1139,8 +1842,9 @@ export function mergeWaveByRepo(
1139
1842
  // - anyLaneSucceeded: at least one lane merged successfully across all repos
1140
1843
  // - anyRepoFailed: at least one repo had a non-succeeded status (includes
1141
1844
  // both lane-level failures AND repo setup failures with failedLane=null)
1845
+ // TP-032 R006-3: Exclude verification_new_failure lanes from success determination
1142
1846
  const anyLaneSucceeded = allLaneResults.some(
1143
- r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
1847
+ r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
1144
1848
  );
1145
1849
 
1146
1850
  let status: MergeWaveResult["status"];
@@ -1157,11 +1861,11 @@ export function mergeWaveByRepo(
1157
1861
  execLog("merge", `W${waveIndex}`, `repo-scoped wave merge complete: ${status}`, {
1158
1862
  repoCount: repoOutcomes.length,
1159
1863
  repoStatuses: repoOutcomes.map(r => `${r.repoId ?? "default"}:${r.status}`).join(", "),
1160
- mergedLanes: allLaneResults.filter(r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED").length,
1864
+ mergedLanes: allLaneResults.filter(r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")).length,
1161
1865
  duration: `${Math.round(totalDurationMs / 1000)}s`,
1162
1866
  });
1163
1867
 
1164
- return {
1868
+ const aggregateResult: MergeWaveResult = {
1165
1869
  waveIndex,
1166
1870
  status,
1167
1871
  laneResults: allLaneResults,
@@ -1170,6 +1874,20 @@ export function mergeWaveByRepo(
1170
1874
  totalDurationMs,
1171
1875
  repoResults: repoOutcomes,
1172
1876
  };
1877
+
1878
+ // TP-033: Attach transaction metadata from all repo groups
1879
+ if (allTransactionRecords.length > 0) {
1880
+ aggregateResult.transactionRecords = allTransactionRecords;
1881
+ }
1882
+ if (anyRollbackFailed) {
1883
+ aggregateResult.rollbackFailed = true;
1884
+ }
1885
+ // TP-033 R004-2: Surface persistence errors from all repo groups
1886
+ if (allPersistenceErrors.length > 0) {
1887
+ aggregateResult.persistenceErrors = allPersistenceErrors;
1888
+ }
1889
+
1890
+ return aggregateResult;
1173
1891
  }
1174
1892
 
1175
1893
  // ── Auto-Integration ─────────────────────────────────────────────────