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:
+84
-8
@@ -674,6 +674,12 @@ public sealed class ExternalMcpService
|
||||
"in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " +
|
||||
"or abort_merge to cancel.")]
|
||||
bool leaveConflictsInTree = false,
|
||||
[Description("Batch mode for an approve: merge normally but defer the list's verify command instead of " +
|
||||
"running it after this one merge. The task then stays WaitingForReview with mergeStatus " +
|
||||
"merged_verify_pending; after the LAST merge of the batch call verify_merges once with every " +
|
||||
"pending task id — it runs the verify command a single time and promotes them all to Done. " +
|
||||
"No-op when the list has no verify command.")]
|
||||
bool skipVerify = false,
|
||||
CancellationToken cancellationToken = default,
|
||||
IProgress<ProgressNotificationValue>? progress = null)
|
||||
{
|
||||
@@ -710,7 +716,7 @@ public sealed class ExternalMcpService
|
||||
// externallyDriven: true — this call came from an MCP session, not the UI's
|
||||
// Approve button. A unit-merge conflict must not auto-open the in-app resolver;
|
||||
// the driving session resolves it via continue_merge/abort_merge instead.
|
||||
var startResult = await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken, externallyDriven: true, progress);
|
||||
var startResult = await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken, externallyDriven: true, progress, skipVerify);
|
||||
if (startResult.Status == TaskMergeService.StatusBlocked)
|
||||
throw new InvalidOperationException(startResult.Reason ?? "approve failed");
|
||||
mergeStatus = startResult.Status;
|
||||
@@ -721,6 +727,11 @@ public sealed class ExternalMcpService
|
||||
mergeMessage = "unit merge paused on a conflict — markers left in the working tree; " +
|
||||
"resolve them then call continue_merge with the parent task id, or abort_merge to cancel";
|
||||
}
|
||||
else if (startResult.Status == TaskMergeService.StatusMergedVerifyPending)
|
||||
{
|
||||
mergeMessage = "unit merged; verify deferred — the parent stays WaitingForReview until " +
|
||||
"verify_merges (with this parent's id) passes";
|
||||
}
|
||||
else if (startResult.Status != TaskMergeService.StatusMerged)
|
||||
{
|
||||
mergeMessage = startResult.Reason;
|
||||
@@ -728,12 +739,17 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
else
|
||||
{
|
||||
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, cancellationToken, progress);
|
||||
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, cancellationToken, progress, skipVerify);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
|
||||
mergeStatus = r.Status;
|
||||
mergeConflicts = r.ConflictFiles;
|
||||
if (r.Status == TaskMergeService.StatusConflict)
|
||||
if (r.Status == TaskMergeService.StatusMergedVerifyPending)
|
||||
{
|
||||
mergeMessage = "merged; verify deferred — the task stays WaitingForReview until " +
|
||||
"verify_merges (with its id) passes";
|
||||
}
|
||||
else if (r.Status == TaskMergeService.StatusConflict)
|
||||
{
|
||||
if (leaveConflictsInTree)
|
||||
{
|
||||
@@ -992,6 +1008,9 @@ public sealed class ExternalMcpService
|
||||
"leave the conflict markers in the working tree at repoPath (conflictsInTree=true) so you can " +
|
||||
"resolve them there and call continue_merge, or abort_merge to cancel.")]
|
||||
bool leaveConflictsInTree = false,
|
||||
[Description("Batch mode: defer the list's verify command instead of running it after this one merge " +
|
||||
"(see review_task's skipVerify). Call verify_merges once after the batch.")]
|
||||
bool skipVerify = false,
|
||||
CancellationToken cancellationToken = default,
|
||||
IProgress<ProgressNotificationValue>? progress = null)
|
||||
{
|
||||
@@ -1022,9 +1041,11 @@ public sealed class ExternalMcpService
|
||||
|
||||
// Blank on purpose: TaskMergeService builds the conventional default message.
|
||||
var result = await _merge.MergeAsync(
|
||||
taskId, targetBranch, removeWorktree: false, commitMessage: "", leaveConflictsInTree, cancellationToken, progress);
|
||||
taskId, targetBranch, removeWorktree: false, commitMessage: "", leaveConflictsInTree, cancellationToken, progress, skipVerify);
|
||||
|
||||
if (result.Status == TaskMergeService.StatusMerged)
|
||||
// merged_verify_pending: the branch IS merged (that's what this tool reports on) — only
|
||||
// the task's own Done transition is deferred to verify_merges.
|
||||
if (result.Status is TaskMergeService.StatusMerged or TaskMergeService.StatusMergedVerifyPending)
|
||||
{
|
||||
string? mergeCommit = null;
|
||||
try
|
||||
@@ -1053,6 +1074,9 @@ public sealed class ExternalMcpService
|
||||
McpToolDocs.ProgressHint)]
|
||||
public async Task<MergeContinuationResultDto> ContinueMerge(
|
||||
string taskId,
|
||||
[Description("Batch mode: defer the list's verify command (see review_task's skipVerify). Pass the same " +
|
||||
"value you approved with — a unit merge remembers its own flag and ignores this one.")]
|
||||
bool skipVerify = false,
|
||||
CancellationToken cancellationToken = default,
|
||||
IProgress<ProgressNotificationValue>? progress = null)
|
||||
{
|
||||
@@ -1069,9 +1093,15 @@ public sealed class ExternalMcpService
|
||||
|
||||
if (_planningMerge.HasActiveMerge(taskId))
|
||||
{
|
||||
await _planningMerge.ContinueAsync(taskId, cancellationToken, progress);
|
||||
var unitResult = await _planningMerge.ContinueAsync(taskId, cancellationToken, progress);
|
||||
var parent = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
if (parent.Status == TaskStatus.Done)
|
||||
if (unitResult.Status == TaskMergeService.StatusMergedVerifyPending)
|
||||
{
|
||||
merged = true;
|
||||
message = "unit merged; verify deferred — the parent stays WaitingForReview until " +
|
||||
"verify_merges (with this parent's id) passes";
|
||||
}
|
||||
else if (parent.Status == TaskStatus.Done)
|
||||
{
|
||||
merged = true;
|
||||
}
|
||||
@@ -1096,11 +1126,17 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
else
|
||||
{
|
||||
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken, progress);
|
||||
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken, progress, skipVerify);
|
||||
if (r.Status == TaskMergeService.StatusMerged)
|
||||
{
|
||||
merged = true;
|
||||
}
|
||||
else if (r.Status == TaskMergeService.StatusMergedVerifyPending)
|
||||
{
|
||||
merged = true;
|
||||
message = "merged; verify deferred — the task stays WaitingForReview until " +
|
||||
"verify_merges (with its id) passes";
|
||||
}
|
||||
else if (r.Status == TaskMergeService.StatusConflict)
|
||||
{
|
||||
merged = false;
|
||||
@@ -1119,6 +1155,46 @@ public sealed class ExternalMcpService
|
||||
return new MergeContinuationResultDto(merged, reloaded.Status.ToString(), conflicts, repoPath, message);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Run the list's verify command ONCE for a batch of merges made with skipVerify=true, then promote the " +
|
||||
"listed tasks to Done. Call it a single time after the LAST merge of the batch instead of paying the " +
|
||||
"verify per merge. All tasks must belong to the same list; each must be WaitingForReview with its merge " +
|
||||
"already landed (mergeStatus merged_verify_pending). status verify_passed: check each entry's promoted " +
|
||||
"flag (an ineligible task is skipped with a reason, the rest still promote). status verify_failed: the " +
|
||||
"merges stay in place but NO task reaches Done; the command's output tail is in errorMessage — report it, " +
|
||||
"don't fix it yourself. A list without a verify command promotes directly." + McpToolDocs.ProgressHint)]
|
||||
public async Task<ListVerifyResult> VerifyMerges(
|
||||
[Description("Every task merged with skipVerify=true in this batch (accepts #numbers or ids).")]
|
||||
string[] taskIds,
|
||||
CancellationToken cancellationToken = default,
|
||||
IProgress<ProgressNotificationValue>? progress = null)
|
||||
{
|
||||
if (taskIds.Length == 0)
|
||||
throw new InvalidOperationException("taskIds must not be empty.");
|
||||
// Same rationale as review_task's first report: the verify itself reports periodically,
|
||||
// but the wait on the per-repo merge gate before it would otherwise sit silent.
|
||||
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "verify_merges started" });
|
||||
|
||||
string? listId = null;
|
||||
var resolved = new List<string>(taskIds.Length);
|
||||
foreach (var raw in taskIds)
|
||||
{
|
||||
var id = await TaskIdResolver.ResolveAsync(_tasks, raw, cancellationToken);
|
||||
var task = await _tasks.GetByIdAsync(id, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {raw} not found.");
|
||||
listId ??= task.ListId;
|
||||
if (task.ListId != listId)
|
||||
throw new InvalidOperationException(
|
||||
"All tasks must belong to the same list — one verify command gates one repo.");
|
||||
resolved.Add(id);
|
||||
}
|
||||
|
||||
var result = await _merge.RunListVerifyAsync(listId!, resolved, cancellationToken, progress);
|
||||
if (result.Status == TaskMergeService.StatusBlocked)
|
||||
throw new InvalidOperationException(result.ErrorMessage ?? "verify blocked");
|
||||
return result;
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
|
||||
"Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " +
|
||||
|
||||
Reference in New Issue
Block a user