feat(merge): verify once per batch via skipVerify + verify_merges
Per-merge verify made an N-task batch pay N x ~7 min, each run testing the same moving main. review_task/merge_task/continue_merge now take skipVerify: the merge lands but the gate AND the Done transition are deferred (status merged_verify_pending, task stays WaitingForReview). The new verify_merges tool runs the list verify command once for the explicitly listed tasks and promotes them to Done on success - explicit ids so the handler own submitted task and parked verify_failed tasks are never swept up; an Active worktree is refused per entry. PlanningMergeOrchestrator threads the flag through the unit merge (no more per-child verify) and skips FinalizeParentDoneAsync when verifies were deferred. The merge-helper Merge prompt approves with skipVerify=true and calls verify_merges once after the last merge. UI approve is unchanged; the no-Done-without-green-verify invariant stays server-enforced.
This commit is contained in:
+18
-18
@@ -1757,7 +1757,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
||||
allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None);
|
||||
allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Merged);
|
||||
Assert.True(result.ConflictsInTree);
|
||||
@@ -1781,7 +1781,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
||||
allowWaitingForReview: true, leaveConflictsInTree: false, CancellationToken.None);
|
||||
allowWaitingForReview: true, leaveConflictsInTree: false, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Merged);
|
||||
Assert.False(result.ConflictsInTree);
|
||||
@@ -1800,7 +1800,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.ReviewTask(task.Id, "approve", null, null,
|
||||
leaveConflictsInTree: true, CancellationToken.None);
|
||||
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus);
|
||||
Assert.Equal(list.WorkingDir, result.RepoPath);
|
||||
@@ -1840,13 +1840,13 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.ReviewTask(task.Id, "approve", null, null,
|
||||
leaveConflictsInTree: true, CancellationToken.None);
|
||||
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Resolve the conflict on disk and stage it.
|
||||
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
||||
|
||||
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
||||
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.True(result.Merged);
|
||||
Assert.Equal("Done", result.TaskStatus);
|
||||
@@ -1869,7 +1869,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var plainSut = BuildSut(CreateQueue());
|
||||
await plainSut.ReviewTask(task.Id, "approve", null, null,
|
||||
leaveConflictsInTree: true, CancellationToken.None);
|
||||
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
|
||||
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
||||
@@ -1887,7 +1887,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var reports = new List<ProgressNotificationValue>();
|
||||
var progress = new Progress<ProgressNotificationValue>(reports.Add);
|
||||
|
||||
var result = await sut.ContinueMerge(task.Id, CancellationToken.None, progress);
|
||||
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None, progress: progress);
|
||||
|
||||
Assert.True(result.Merged);
|
||||
Assert.Equal("Done", result.TaskStatus);
|
||||
@@ -1908,10 +1908,10 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.ReviewTask(task.Id, "approve", null, null,
|
||||
leaveConflictsInTree: true, CancellationToken.None);
|
||||
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Markers never resolved — continue must refuse and list the files.
|
||||
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
||||
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Merged);
|
||||
Assert.Contains("README.md", result.Conflicts);
|
||||
@@ -1930,7 +1930,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.ReviewTask(task.Id, "approve", null, null,
|
||||
leaveConflictsInTree: true, CancellationToken.None);
|
||||
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
var dto = await sut.AbortMerge(task.Id, CancellationToken.None);
|
||||
|
||||
@@ -1948,7 +1948,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.ContinueMerge(task.Id, CancellationToken.None));
|
||||
() => sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None));
|
||||
Assert.Contains("mid-merge", ex.Message);
|
||||
}
|
||||
|
||||
@@ -1974,7 +1974,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
|
||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None);
|
||||
await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||
|
||||
@@ -2016,7 +2016,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var merge = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
||||
allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None);
|
||||
allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
Assert.True(merge.ConflictsInTree);
|
||||
|
||||
var before = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||
@@ -2037,7 +2037,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var finalConflicts = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||
Assert.Equal(0, finalConflicts.RemainingHunks);
|
||||
|
||||
var continueResult = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
||||
var continueResult = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
||||
Assert.True(continueResult.Merged);
|
||||
Assert.Equal("Done", continueResult.TaskStatus);
|
||||
|
||||
@@ -2057,13 +2057,13 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.MergeTask(task.Id, target, true, dryRun: false,
|
||||
allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None);
|
||||
allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Resolve only one of the three hunks.
|
||||
await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
|
||||
|
||||
// continue_merge must still refuse: writing one hunk must not have staged the file as resolved.
|
||||
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
||||
var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Merged);
|
||||
Assert.Contains("README.md", result.Conflicts);
|
||||
@@ -2079,7 +2079,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
|
||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None);
|
||||
await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None));
|
||||
@@ -2212,7 +2212,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
// Resolve the conflict on disk.
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
|
||||
|
||||
var result = await sut.ContinueMerge(parentId, CancellationToken.None);
|
||||
var result = await sut.ContinueMerge(parentId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.True(result.Merged);
|
||||
Assert.Equal("Done", result.TaskStatus);
|
||||
|
||||
Reference in New Issue
Block a user