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:
@@ -1742,6 +1742,178 @@ public class TaskMergeServiceTests : IDisposable
|
||||
Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
|
||||
&& c.Args[0] is string s && s.Contains("Auto-rebase failed") && s.Contains("task-b"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_SkipVerify_DefersGateAndDoneTransition()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "batch.txt"), "x\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "must not run") };
|
||||
var (svc, _) = BuildService(db, fakeVerify);
|
||||
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
||||
commitMessage: "Merge", leaveConflictsInTree: false, CancellationToken.None, skipVerify: true);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusMergedVerifyPending, result.Status);
|
||||
Assert.Null(fakeVerify.CapturedCommand);
|
||||
|
||||
// Merge landed (worktree Merged), but the Done transition is deferred.
|
||||
using var ctx = db.CreateContext();
|
||||
Assert.Equal(WorktreeState.Merged, (await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id))!.State);
|
||||
Assert.Equal(TaskStatus.WaitingForReview, (await new TaskRepository(ctx).GetByIdAsync(task.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_SkipVerify_NoVerifyCommand_CompletesNormally()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "plain.txt"), "x\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
||||
commitMessage: "Merge", leaveConflictsInTree: false, CancellationToken.None, skipVerify: true);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||
using var ctx = db.CreateContext();
|
||||
Assert.Equal(TaskStatus.Done, (await new TaskRepository(ctx).GetByIdAsync(task.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunListVerifyAsync_Passes_PromotesPendingAndRefusesActiveWorktree()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, merged) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(merged, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "batch.txt"), "x\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, merged, list, CancellationToken.None);
|
||||
|
||||
// A second task whose worktree is still Active -- its branch never landed, so a passing
|
||||
// verify must NOT promote it.
|
||||
var unmerged = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = list.Id,
|
||||
Title = "unmerged",
|
||||
Status = TaskStatus.WaitingForReview,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
using (var ctx = db.CreateContext())
|
||||
await new TaskRepository(ctx).AddAsync(unmerged);
|
||||
await SeedWorktree(db, unmerged.Id, "/tmp/never-merged", "claudedo/never", "abc");
|
||||
|
||||
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
||||
var (svc, _) = BuildService(db, fakeVerify);
|
||||
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var mergeResult = await svc.MergeAsync(merged.Id, currentBranch, removeWorktree: false,
|
||||
commitMessage: "Merge", leaveConflictsInTree: false, CancellationToken.None, skipVerify: true);
|
||||
Assert.Equal(TaskMergeService.StatusMergedVerifyPending, mergeResult.Status);
|
||||
|
||||
var verify = await svc.RunListVerifyAsync(list.Id, new[] { merged.Id, unmerged.Id }, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusVerifyPassed, verify.Status);
|
||||
Assert.Equal(1, fakeVerify.CallCount);
|
||||
Assert.Equal("dotnet test", fakeVerify.CapturedCommand);
|
||||
Assert.Equal(repo.RepoDir, fakeVerify.CapturedWorkingDir);
|
||||
|
||||
var mergedOutcome = verify.Tasks.Single(o => o.TaskId == merged.Id);
|
||||
Assert.True(mergedOutcome.Promoted);
|
||||
var unmergedOutcome = verify.Tasks.Single(o => o.TaskId == unmerged.Id);
|
||||
Assert.False(unmergedOutcome.Promoted);
|
||||
Assert.Contains("Active", unmergedOutcome.Reason);
|
||||
|
||||
using var ctx2 = db.CreateContext();
|
||||
Assert.Equal(TaskStatus.Done, (await new TaskRepository(ctx2).GetByIdAsync(merged.Id))!.Status);
|
||||
Assert.Equal(TaskStatus.WaitingForReview, (await new TaskRepository(ctx2).GetByIdAsync(unmerged.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunListVerifyAsync_Fails_NothingPromoted()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "batch.txt"), "x\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "3 failed") };
|
||||
var (svc, _) = BuildService(db, fakeVerify);
|
||||
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
||||
commitMessage: "Merge", leaveConflictsInTree: false, CancellationToken.None, skipVerify: true);
|
||||
|
||||
var verify = await svc.RunListVerifyAsync(list.Id, new[] { task.Id }, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusVerifyFailed, verify.Status);
|
||||
Assert.Contains("3 failed", verify.ErrorMessage);
|
||||
Assert.All(verify.Tasks, o => Assert.False(o.Promoted));
|
||||
|
||||
using var ctx = db.CreateContext();
|
||||
Assert.Equal(TaskStatus.WaitingForReview, (await new TaskRepository(ctx).GetByIdAsync(task.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAndMergeAsync_NoWorktree_SkipVerify_StaysPendingAndPromotesViaListVerify()
|
||||
{
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
||||
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
||||
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
||||
var (svc, _) = BuildService(db, fakeVerify);
|
||||
|
||||
var result = await svc.ApproveAndMergeAsync(task.Id, "", leaveConflictsInTree: false,
|
||||
CancellationToken.None, skipVerify: true);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusMergedVerifyPending, result.Status);
|
||||
Assert.Equal(0, fakeVerify.CallCount);
|
||||
using (var ctx = db.CreateContext())
|
||||
Assert.Equal(TaskStatus.WaitingForReview, (await new TaskRepository(ctx).GetByIdAsync(task.Id))!.Status);
|
||||
|
||||
// A worktree-less task is a valid batch-verify candidate (there was no branch to land).
|
||||
var verify = await svc.RunListVerifyAsync(list.Id, new[] { task.Id }, CancellationToken.None);
|
||||
Assert.Equal(TaskMergeService.StatusVerifyPassed, verify.Status);
|
||||
Assert.Equal(1, fakeVerify.CallCount);
|
||||
using (var ctx = db.CreateContext())
|
||||
Assert.Equal(TaskStatus.Done, (await new TaskRepository(ctx).GetByIdAsync(task.Id))!.Status);
|
||||
}
|
||||
}
|
||||
|
||||
#region Test doubles
|
||||
@@ -1749,6 +1921,7 @@ public class TaskMergeServiceTests : IDisposable
|
||||
internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
|
||||
{
|
||||
public VerifyCommandResult Result { get; set; } = new(0, false, "");
|
||||
public int CallCount { get; private set; }
|
||||
public string? CapturedWorkingDir { get; private set; }
|
||||
public string? CapturedCommand { get; private set; }
|
||||
|
||||
@@ -1763,6 +1936,7 @@ internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
|
||||
|
||||
public async Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
CallCount++;
|
||||
CapturedWorkingDir = workingDir;
|
||||
CapturedCommand = command;
|
||||
if (FileToCheck is not null)
|
||||
|
||||
Reference in New Issue
Block a user