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,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,55 +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) =>
771
- (f.endsWith(".DONE") || f.endsWith("STATUS.md")) &&
772
- !f.includes(".worktrees/"), // Never stage worktree internals
773
- );
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;
1391
+ }
774
1392
 
775
- if (artifactFiles.length > 0) {
776
- let staged = 0;
777
- for (const file of artifactFiles) {
778
- const srcPath = join(repoRoot, file);
779
- const destPath = join(mergeWorkDir, file);
780
- try {
781
- if (existsSync(srcPath)) {
782
- mkdirSync(dirname(destPath), { recursive: true });
783
- copyFileSync(srcPath, destPath);
784
- spawnSync("git", ["add", file], { cwd: mergeWorkDir });
785
- staged++;
786
- }
787
- } catch { /* best effort */ }
1393
+ for (const name of ALLOWED_ARTIFACT_NAMES) {
1394
+ allowedRelPaths.add(`${relFolder}/${name}`);
788
1395
  }
789
- if (staged > 0) {
790
- spawnSync("git", ["commit", "-m", `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md)`], { cwd: mergeWorkDir });
791
- execLog("merge", `W${waveIndex}`, `committed ${staged} task artifact(s) to merge worktree`);
792
-
793
- // Keep both .DONE and STATUS.md in develop's working tree:
794
- // - STATUS.md: dashboard reads current progress from canonical path
795
- // - .DONE: harmless untracked files, cleaned up by /orch-integrate stash
796
- // Previous approach of deleting .DONE caused them to be missing
797
- // after ff integration (git couldn't reliably restore them).
1396
+ }
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 });
798
1417
  }
799
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).
800
1435
  }
801
1436
  }
802
1437
 
803
1438
  // ── Update target branch ref and clean up merge worktree ────────
804
- const anySuccess = laneResults.some(
805
- 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"),
806
1446
  );
807
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
+
808
1453
  if (anySuccess) {
809
1454
  // Get the temp branch HEAD commit — this is the merged result.
810
1455
  const revParseResult = spawnSync("git", ["rev-parse", tempBranch], { cwd: repoRoot });
@@ -884,15 +1529,25 @@ export function mergeWave(
884
1529
  }
885
1530
  }
886
1531
 
887
- // Clean up merge worktree and temp branch (always, regardless of outcome)
888
- try {
889
- spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { cwd: repoRoot });
890
- } catch { /* best effort */ }
891
- try {
892
- // Small delay to ensure worktree lock is released
893
- sleepSync(500);
894
- spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
895
- } 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
+ }
896
1551
 
897
1552
  // Determine overall status
898
1553
  let status: MergeWaveResult["status"];
@@ -907,12 +1562,12 @@ export function mergeWave(
907
1562
  const totalDurationMs = Date.now() - startTime;
908
1563
 
909
1564
  execLog("merge", `W${waveIndex}`, `wave merge complete: ${status}`, {
910
- 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,
911
1566
  failedLane: failedLane ?? 0,
912
1567
  duration: `${Math.round(totalDurationMs / 1000)}s`,
913
1568
  });
914
1569
 
915
- return {
1570
+ const result: MergeWaveResult = {
916
1571
  waveIndex,
917
1572
  status,
918
1573
  laneResults,
@@ -920,6 +1575,21 @@ export function mergeWave(
920
1575
  failureReason,
921
1576
  totalDurationMs,
922
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;
923
1593
  }
924
1594
 
925
1595
 
@@ -998,6 +1668,7 @@ export function mergeWaveByRepo(
998
1668
  workspaceConfig?: WorkspaceConfig | null,
999
1669
  stateRoot?: string,
1000
1670
  agentRoot?: string,
1671
+ testingCommands?: Record<string, string>,
1001
1672
  ): MergeWaveResult {
1002
1673
  const startTime = Date.now();
1003
1674
 
@@ -1052,6 +1723,7 @@ export function mergeWaveByRepo(
1052
1723
  baseBranch,
1053
1724
  stateRoot,
1054
1725
  agentRoot,
1726
+ testingCommands,
1055
1727
  );
1056
1728
  // Attach empty repoResults for consistent shape
1057
1729
  return { ...result, repoResults: [] };
@@ -1060,6 +1732,9 @@ export function mergeWaveByRepo(
1060
1732
  // ── Workspace mode: per-repo merge loops ─────────────────────
1061
1733
  const allLaneResults: MergeLaneResult[] = [];
1062
1734
  const repoOutcomes: RepoMergeOutcome[] = [];
1735
+ const allTransactionRecords: TransactionRecord[] = [];
1736
+ // TP-033 R004-2: Accumulate persistence errors across all repo groups
1737
+ const allPersistenceErrors: string[] = [];
1063
1738
  let firstFailedLane: number | null = null;
1064
1739
  let firstFailureReason: string | null = null;
1065
1740
  // Track repo-level failures independently of lane-level failures.
@@ -1067,6 +1742,8 @@ export function mergeWaveByRepo(
1067
1742
  // pre-lane setup errors (temp branch creation, worktree creation).
1068
1743
  // We must detect these to avoid misclassifying the aggregate as "succeeded".
1069
1744
  let anyRepoFailed = false;
1745
+ // TP-033: Track rollback failures across all repo groups
1746
+ let anyRollbackFailed = false;
1070
1747
 
1071
1748
  for (const group of repoGroups) {
1072
1749
  const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig);
@@ -1101,11 +1778,25 @@ export function mergeWaveByRepo(
1101
1778
  groupBaseBranch,
1102
1779
  stateRoot,
1103
1780
  agentRoot,
1781
+ testingCommands,
1782
+ group.repoId,
1104
1783
  );
1105
1784
 
1106
1785
  // Accumulate lane results
1107
1786
  allLaneResults.push(...groupResult.laneResults);
1108
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
+
1109
1800
  // Build per-repo outcome
1110
1801
  const repoOutcome: RepoMergeOutcome = {
1111
1802
  repoId: group.repoId,
@@ -1130,6 +1821,20 @@ export function mergeWaveByRepo(
1130
1821
  : `[repo:${group.repoId ?? "default"}] Merge failed (setup error)`;
1131
1822
  }
1132
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
+ }
1133
1838
  }
1134
1839
 
1135
1840
  // ── Aggregate status ─────────────────────────────────────────
@@ -1137,8 +1842,9 @@ export function mergeWaveByRepo(
1137
1842
  // - anyLaneSucceeded: at least one lane merged successfully across all repos
1138
1843
  // - anyRepoFailed: at least one repo had a non-succeeded status (includes
1139
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
1140
1846
  const anyLaneSucceeded = allLaneResults.some(
1141
- r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
1847
+ r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
1142
1848
  );
1143
1849
 
1144
1850
  let status: MergeWaveResult["status"];
@@ -1155,11 +1861,11 @@ export function mergeWaveByRepo(
1155
1861
  execLog("merge", `W${waveIndex}`, `repo-scoped wave merge complete: ${status}`, {
1156
1862
  repoCount: repoOutcomes.length,
1157
1863
  repoStatuses: repoOutcomes.map(r => `${r.repoId ?? "default"}:${r.status}`).join(", "),
1158
- 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,
1159
1865
  duration: `${Math.round(totalDurationMs / 1000)}s`,
1160
1866
  });
1161
1867
 
1162
- return {
1868
+ const aggregateResult: MergeWaveResult = {
1163
1869
  waveIndex,
1164
1870
  status,
1165
1871
  laneResults: allLaneResults,
@@ -1168,6 +1874,20 @@ export function mergeWaveByRepo(
1168
1874
  totalDurationMs,
1169
1875
  repoResults: repoOutcomes,
1170
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;
1171
1891
  }
1172
1892
 
1173
1893
  // ── Auto-Integration ─────────────────────────────────────────────────