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.
- package/README.md +1 -1
- package/bin/rpc-wrapper.mjs +777 -0
- package/dashboard/public/app.js +45 -6
- package/dashboard/public/style.css +31 -0
- package/dashboard/server.cjs +326 -1
- package/extensions/task-runner.ts +1111 -30
- package/extensions/taskplane/config-loader.ts +31 -0
- package/extensions/taskplane/config-schema.ts +88 -0
- package/extensions/taskplane/diagnostic-reports.ts +463 -0
- package/extensions/taskplane/diagnostics.ts +323 -0
- package/extensions/taskplane/engine.ts +407 -62
- package/extensions/taskplane/extension.ts +259 -8
- package/extensions/taskplane/index.ts +1 -0
- package/extensions/taskplane/merge.ts +786 -66
- package/extensions/taskplane/messages.ts +594 -2
- package/extensions/taskplane/persistence.ts +342 -19
- package/extensions/taskplane/quality-gate.ts +1033 -0
- package/extensions/taskplane/resume.ts +505 -36
- package/extensions/taskplane/supervisor-primer.md +664 -0
- package/extensions/taskplane/types.ts +534 -6
- package/extensions/taskplane/verification.ts +537 -0
- package/extensions/taskplane/worktree.ts +329 -1
- package/package.json +1 -1
- package/skills/create-taskplane-task/references/prompt-template.md +0 -2
- package/templates/agents/task-reviewer.md +54 -2
- package/templates/agents/task-worker.md +8 -4
|
@@ -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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
776
|
-
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
805
|
-
|
|
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
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ─────────────────────────────────────────────────
|