taskplane 0.5.11 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +787 -69
- 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,57 +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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
}
|
|
784
|
-
} catch { /* best effort */ }
|
|
1373
|
+
// Build the set of allowed artifact paths (repo-root-relative) from
|
|
1374
|
+
// the completed lanes' task folders.
|
|
1375
|
+
const ALLOWED_ARTIFACT_NAMES = [".DONE", "STATUS.md", "REVIEW_VERDICT.json"];
|
|
1376
|
+
const resolvedRepoRoot = resolve(repoRoot);
|
|
1377
|
+
const allowedRelPaths = new Set<string>();
|
|
1378
|
+
|
|
1379
|
+
for (const lane of orderedLanes) {
|
|
1380
|
+
for (const allocTask of lane.tasks) {
|
|
1381
|
+
const absFolder = resolve(allocTask.task.taskFolder);
|
|
1382
|
+
const relFolder = relative(resolvedRepoRoot, absFolder).replace(/\\/g, "/");
|
|
1383
|
+
|
|
1384
|
+
// Reject paths that escape the repo root
|
|
1385
|
+
if (relFolder.startsWith("..") || relFolder.startsWith("/")) {
|
|
1386
|
+
execLog("merge", `W${waveIndex}`, `skipping task folder outside repo root`, {
|
|
1387
|
+
taskId: allocTask.taskId,
|
|
1388
|
+
folder: relFolder,
|
|
1389
|
+
});
|
|
1390
|
+
continue;
|
|
785
1391
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
// Restore .DONE files only — these are untracked and would cause
|
|
791
|
-
// "dirty working tree" issues on /orch-integrate.
|
|
792
|
-
// Keep STATUS.md modifications in develop's working tree so the
|
|
793
|
-
// dashboard can read current progress from the canonical path.
|
|
794
|
-
for (const file of artifactFiles) {
|
|
795
|
-
if (file.endsWith(".DONE")) {
|
|
796
|
-
const srcPath = join(repoRoot, file);
|
|
797
|
-
try { if (existsSync(srcPath)) unlinkSync(srcPath); } catch { /* best effort */ }
|
|
798
|
-
}
|
|
799
|
-
}
|
|
1392
|
+
|
|
1393
|
+
for (const name of ALLOWED_ARTIFACT_NAMES) {
|
|
1394
|
+
allowedRelPaths.add(`${relFolder}/${name}`);
|
|
800
1395
|
}
|
|
801
1396
|
}
|
|
802
1397
|
}
|
|
1398
|
+
|
|
1399
|
+
if (allowedRelPaths.size > 0) {
|
|
1400
|
+
let staged = 0;
|
|
1401
|
+
let skipped = 0;
|
|
1402
|
+
|
|
1403
|
+
for (const relPath of allowedRelPaths) {
|
|
1404
|
+
const srcPath = join(repoRoot, relPath);
|
|
1405
|
+
if (!existsSync(srcPath)) continue; // File not present (e.g., no REVIEW_VERDICT.json) — skip silently
|
|
1406
|
+
|
|
1407
|
+
const destPath = join(mergeWorkDir, relPath);
|
|
1408
|
+
try {
|
|
1409
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
1410
|
+
copyFileSync(srcPath, destPath);
|
|
1411
|
+
// Use pathspec-safe staging with -- separator
|
|
1412
|
+
spawnSync("git", ["add", "--", relPath], { cwd: mergeWorkDir });
|
|
1413
|
+
staged++;
|
|
1414
|
+
} catch {
|
|
1415
|
+
skipped++;
|
|
1416
|
+
execLog("merge", `W${waveIndex}`, `failed to stage artifact`, { path: relPath });
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
if (staged > 0) {
|
|
1421
|
+
spawnSync("git", ["commit", "-m", `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md, REVIEW_VERDICT.json)`], { cwd: mergeWorkDir });
|
|
1422
|
+
execLog("merge", `W${waveIndex}`, `committed ${staged} task artifact(s) to merge worktree`, {
|
|
1423
|
+
skipped,
|
|
1424
|
+
allowedCandidates: allowedRelPaths.size,
|
|
1425
|
+
});
|
|
1426
|
+
} else {
|
|
1427
|
+
execLog("merge", `W${waveIndex}`, `no task artifacts to stage (0 of ${allowedRelPaths.size} candidates present/changed)`);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// Keep both .DONE and STATUS.md in develop's working tree:
|
|
1431
|
+
// - STATUS.md: dashboard reads current progress from canonical path
|
|
1432
|
+
// - .DONE: harmless untracked files, cleaned up by /orch-integrate stash
|
|
1433
|
+
// Previous approach of deleting .DONE caused them to be missing
|
|
1434
|
+
// after ff integration (git couldn't reliably restore them).
|
|
1435
|
+
}
|
|
803
1436
|
}
|
|
804
1437
|
|
|
805
1438
|
// ── Update target branch ref and clean up merge worktree ────────
|
|
806
|
-
|
|
807
|
-
|
|
1439
|
+
// TP-032 R006-2: blockAdvancement overrides all success determination.
|
|
1440
|
+
// When verification rollback fails, the temp branch contains a bad merge commit
|
|
1441
|
+
// that would be included in branch advancement — so we block entirely.
|
|
1442
|
+
// Also exclude verification_new_failure lanes (with successful rollback) from
|
|
1443
|
+
// success accounting: they have laneResult.error set, so !r.error filters them.
|
|
1444
|
+
const anySuccess = !blockAdvancement && laneResults.some(
|
|
1445
|
+
r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
|
|
808
1446
|
);
|
|
809
1447
|
|
|
1448
|
+
if (blockAdvancement) {
|
|
1449
|
+
execLog("merge", `W${waveIndex}`, "branch advancement BLOCKED due to verification rollback failure — " +
|
|
1450
|
+
"temp branch may contain unverified merge commit");
|
|
1451
|
+
}
|
|
1452
|
+
|
|
810
1453
|
if (anySuccess) {
|
|
811
1454
|
// Get the temp branch HEAD commit — this is the merged result.
|
|
812
1455
|
const revParseResult = spawnSync("git", ["rev-parse", tempBranch], { cwd: repoRoot });
|
|
@@ -886,15 +1529,25 @@ export function mergeWave(
|
|
|
886
1529
|
}
|
|
887
1530
|
}
|
|
888
1531
|
|
|
889
|
-
// Clean up merge worktree and temp branch
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1532
|
+
// Clean up merge worktree and temp branch.
|
|
1533
|
+
// TP-033: When rollback failed (safe-stop), preserve merge worktree and temp
|
|
1534
|
+
// branch for manual recovery. The operator can use the recovery commands in
|
|
1535
|
+
// the transaction record to restore consistency.
|
|
1536
|
+
if (rollbackFailed) {
|
|
1537
|
+
execLog("merge", `W${waveIndex}`, "SAFE-STOP: preserving merge worktree and temp branch for recovery", {
|
|
1538
|
+
mergeWorkDir,
|
|
1539
|
+
tempBranch,
|
|
1540
|
+
});
|
|
1541
|
+
} else {
|
|
1542
|
+
// TP-029: Apply forceRemoveMergeWorktree fallback so locked/corrupted
|
|
1543
|
+
// merge worktrees don't persist between attempts.
|
|
1544
|
+
forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
|
|
1545
|
+
try {
|
|
1546
|
+
// Small delay to ensure worktree lock is released
|
|
1547
|
+
sleepSync(500);
|
|
1548
|
+
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
1549
|
+
} catch { /* best effort */ }
|
|
1550
|
+
}
|
|
898
1551
|
|
|
899
1552
|
// Determine overall status
|
|
900
1553
|
let status: MergeWaveResult["status"];
|
|
@@ -909,12 +1562,12 @@ export function mergeWave(
|
|
|
909
1562
|
const totalDurationMs = Date.now() - startTime;
|
|
910
1563
|
|
|
911
1564
|
execLog("merge", `W${waveIndex}`, `wave merge complete: ${status}`, {
|
|
912
|
-
mergedLanes: laneResults.filter(r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED").length,
|
|
1565
|
+
mergedLanes: laneResults.filter(r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")).length,
|
|
913
1566
|
failedLane: failedLane ?? 0,
|
|
914
1567
|
duration: `${Math.round(totalDurationMs / 1000)}s`,
|
|
915
1568
|
});
|
|
916
1569
|
|
|
917
|
-
|
|
1570
|
+
const result: MergeWaveResult = {
|
|
918
1571
|
waveIndex,
|
|
919
1572
|
status,
|
|
920
1573
|
laneResults,
|
|
@@ -922,6 +1575,21 @@ export function mergeWave(
|
|
|
922
1575
|
failureReason,
|
|
923
1576
|
totalDurationMs,
|
|
924
1577
|
};
|
|
1578
|
+
|
|
1579
|
+
// TP-033: Attach transaction metadata
|
|
1580
|
+
if (transactionRecords.length > 0) {
|
|
1581
|
+
result.transactionRecords = transactionRecords;
|
|
1582
|
+
}
|
|
1583
|
+
if (rollbackFailed) {
|
|
1584
|
+
result.rollbackFailed = true;
|
|
1585
|
+
}
|
|
1586
|
+
// TP-033 R004-2: Surface persistence failures so operator knows
|
|
1587
|
+
// recovery guidance may reference missing transaction record files
|
|
1588
|
+
if (persistenceErrors.length > 0) {
|
|
1589
|
+
result.persistenceErrors = persistenceErrors;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
return result;
|
|
925
1593
|
}
|
|
926
1594
|
|
|
927
1595
|
|
|
@@ -1000,6 +1668,7 @@ export function mergeWaveByRepo(
|
|
|
1000
1668
|
workspaceConfig?: WorkspaceConfig | null,
|
|
1001
1669
|
stateRoot?: string,
|
|
1002
1670
|
agentRoot?: string,
|
|
1671
|
+
testingCommands?: Record<string, string>,
|
|
1003
1672
|
): MergeWaveResult {
|
|
1004
1673
|
const startTime = Date.now();
|
|
1005
1674
|
|
|
@@ -1054,6 +1723,7 @@ export function mergeWaveByRepo(
|
|
|
1054
1723
|
baseBranch,
|
|
1055
1724
|
stateRoot,
|
|
1056
1725
|
agentRoot,
|
|
1726
|
+
testingCommands,
|
|
1057
1727
|
);
|
|
1058
1728
|
// Attach empty repoResults for consistent shape
|
|
1059
1729
|
return { ...result, repoResults: [] };
|
|
@@ -1062,6 +1732,9 @@ export function mergeWaveByRepo(
|
|
|
1062
1732
|
// ── Workspace mode: per-repo merge loops ─────────────────────
|
|
1063
1733
|
const allLaneResults: MergeLaneResult[] = [];
|
|
1064
1734
|
const repoOutcomes: RepoMergeOutcome[] = [];
|
|
1735
|
+
const allTransactionRecords: TransactionRecord[] = [];
|
|
1736
|
+
// TP-033 R004-2: Accumulate persistence errors across all repo groups
|
|
1737
|
+
const allPersistenceErrors: string[] = [];
|
|
1065
1738
|
let firstFailedLane: number | null = null;
|
|
1066
1739
|
let firstFailureReason: string | null = null;
|
|
1067
1740
|
// Track repo-level failures independently of lane-level failures.
|
|
@@ -1069,6 +1742,8 @@ export function mergeWaveByRepo(
|
|
|
1069
1742
|
// pre-lane setup errors (temp branch creation, worktree creation).
|
|
1070
1743
|
// We must detect these to avoid misclassifying the aggregate as "succeeded".
|
|
1071
1744
|
let anyRepoFailed = false;
|
|
1745
|
+
// TP-033: Track rollback failures across all repo groups
|
|
1746
|
+
let anyRollbackFailed = false;
|
|
1072
1747
|
|
|
1073
1748
|
for (const group of repoGroups) {
|
|
1074
1749
|
const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig);
|
|
@@ -1103,11 +1778,25 @@ export function mergeWaveByRepo(
|
|
|
1103
1778
|
groupBaseBranch,
|
|
1104
1779
|
stateRoot,
|
|
1105
1780
|
agentRoot,
|
|
1781
|
+
testingCommands,
|
|
1782
|
+
group.repoId,
|
|
1106
1783
|
);
|
|
1107
1784
|
|
|
1108
1785
|
// Accumulate lane results
|
|
1109
1786
|
allLaneResults.push(...groupResult.laneResults);
|
|
1110
1787
|
|
|
1788
|
+
// TP-033: Accumulate transaction records and rollback status
|
|
1789
|
+
if (groupResult.transactionRecords) {
|
|
1790
|
+
allTransactionRecords.push(...groupResult.transactionRecords);
|
|
1791
|
+
}
|
|
1792
|
+
// TP-033 R004-2: Accumulate persistence errors
|
|
1793
|
+
if (groupResult.persistenceErrors) {
|
|
1794
|
+
allPersistenceErrors.push(...groupResult.persistenceErrors);
|
|
1795
|
+
}
|
|
1796
|
+
if (groupResult.rollbackFailed) {
|
|
1797
|
+
anyRollbackFailed = true;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1111
1800
|
// Build per-repo outcome
|
|
1112
1801
|
const repoOutcome: RepoMergeOutcome = {
|
|
1113
1802
|
repoId: group.repoId,
|
|
@@ -1132,6 +1821,20 @@ export function mergeWaveByRepo(
|
|
|
1132
1821
|
: `[repo:${group.repoId ?? "default"}] Merge failed (setup error)`;
|
|
1133
1822
|
}
|
|
1134
1823
|
}
|
|
1824
|
+
|
|
1825
|
+
// TP-033 R004-1: Safe-stop — halt all remaining repo merges immediately
|
|
1826
|
+
// when a rollback failure is detected. Continuing would advance refs in
|
|
1827
|
+
// other repos, making manual recovery harder.
|
|
1828
|
+
if (anyRollbackFailed) {
|
|
1829
|
+
const processedIndex = repoGroups.indexOf(group);
|
|
1830
|
+
const remainingGroups = repoGroups.slice(processedIndex + 1);
|
|
1831
|
+
if (remainingGroups.length > 0) {
|
|
1832
|
+
execLog("merge", `W${waveIndex}`, `safe-stop: skipping ${remainingGroups.length} remaining repo group(s) after rollback failure`, {
|
|
1833
|
+
skippedRepos: remainingGroups.map(g => g.repoId ?? "(default)").join(", "),
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
break;
|
|
1837
|
+
}
|
|
1135
1838
|
}
|
|
1136
1839
|
|
|
1137
1840
|
// ── Aggregate status ─────────────────────────────────────────
|
|
@@ -1139,8 +1842,9 @@ export function mergeWaveByRepo(
|
|
|
1139
1842
|
// - anyLaneSucceeded: at least one lane merged successfully across all repos
|
|
1140
1843
|
// - anyRepoFailed: at least one repo had a non-succeeded status (includes
|
|
1141
1844
|
// both lane-level failures AND repo setup failures with failedLane=null)
|
|
1845
|
+
// TP-032 R006-3: Exclude verification_new_failure lanes from success determination
|
|
1142
1846
|
const anyLaneSucceeded = allLaneResults.some(
|
|
1143
|
-
r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
|
|
1847
|
+
r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
|
|
1144
1848
|
);
|
|
1145
1849
|
|
|
1146
1850
|
let status: MergeWaveResult["status"];
|
|
@@ -1157,11 +1861,11 @@ export function mergeWaveByRepo(
|
|
|
1157
1861
|
execLog("merge", `W${waveIndex}`, `repo-scoped wave merge complete: ${status}`, {
|
|
1158
1862
|
repoCount: repoOutcomes.length,
|
|
1159
1863
|
repoStatuses: repoOutcomes.map(r => `${r.repoId ?? "default"}:${r.status}`).join(", "),
|
|
1160
|
-
mergedLanes: allLaneResults.filter(r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED").length,
|
|
1864
|
+
mergedLanes: allLaneResults.filter(r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")).length,
|
|
1161
1865
|
duration: `${Math.round(totalDurationMs / 1000)}s`,
|
|
1162
1866
|
});
|
|
1163
1867
|
|
|
1164
|
-
|
|
1868
|
+
const aggregateResult: MergeWaveResult = {
|
|
1165
1869
|
waveIndex,
|
|
1166
1870
|
status,
|
|
1167
1871
|
laneResults: allLaneResults,
|
|
@@ -1170,6 +1874,20 @@ export function mergeWaveByRepo(
|
|
|
1170
1874
|
totalDurationMs,
|
|
1171
1875
|
repoResults: repoOutcomes,
|
|
1172
1876
|
};
|
|
1877
|
+
|
|
1878
|
+
// TP-033: Attach transaction metadata from all repo groups
|
|
1879
|
+
if (allTransactionRecords.length > 0) {
|
|
1880
|
+
aggregateResult.transactionRecords = allTransactionRecords;
|
|
1881
|
+
}
|
|
1882
|
+
if (anyRollbackFailed) {
|
|
1883
|
+
aggregateResult.rollbackFailed = true;
|
|
1884
|
+
}
|
|
1885
|
+
// TP-033 R004-2: Surface persistence errors from all repo groups
|
|
1886
|
+
if (allPersistenceErrors.length > 0) {
|
|
1887
|
+
aggregateResult.persistenceErrors = allPersistenceErrors;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
return aggregateResult;
|
|
1173
1891
|
}
|
|
1174
1892
|
|
|
1175
1893
|
// ── Auto-Integration ─────────────────────────────────────────────────
|