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:
mika kuns
2026-08-26 15:29:20 +02:00
parent c4928b4def
commit 168ab1cd2f
8 changed files with 491 additions and 41 deletions
@@ -44,6 +44,13 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
/// from a running Claude session) rather than a direct UI action. Conflicts on such a
/// merge must not auto-pop the in-app resolver — the driving session owns resolution.</summary>
public required bool ExternallyDriven { get; init; }
/// <summary>Batch mode: every child merge defers the verify gate, and the parent is NOT
/// finalized to Done — a later RunListVerifyAsync (verify_merges) runs the gate once and
/// promotes it. Only meaningful when the list has a verify command at all.</summary>
public required bool SkipVerify { get; init; }
/// <summary>Set when any child merge came back merged_verify_pending — the drain must then
/// end without finalizing the parent.</summary>
public bool AnyVerifyPending { get; set; }
public string? CurrentSubtaskId { get; set; }
/// <summary>True from the moment the last child has merged until FinalizeParentDoneAsync
/// returns. CurrentSubtaskId is already null in this window (no subtask left to merge),
@@ -75,7 +82,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
public async Task<PlanningMergeResult> StartAsync(
string parentTaskId, string targetBranch, CancellationToken ct, bool externallyDriven = false,
IProgress<ProgressNotificationValue>? progress = null)
IProgress<ProgressNotificationValue>? progress = null, bool skipVerify = false)
{
string workingDir;
List<TaskEntity> children;
@@ -143,6 +150,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
IsPlanning = isPlanning,
WorkingDir = workingDir,
ExternallyDriven = externallyDriven,
SkipVerify = skipVerify,
};
if (!_states.TryAdd(parentTaskId, state))
throw new InvalidOperationException($"Merge already in progress for {parentTaskId}.");
@@ -181,7 +189,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
"no in-progress merge to continue; if the worker was restarted during a conflict, use AbortPlanningMerge to reset the repository");
var current = state.CurrentSubtaskId;
var result = await _merge.ContinueMergeAsync(current, ct, progress);
var result = await _merge.ContinueMergeAsync(current, ct, progress, state.SkipVerify);
if (result.Status == TaskMergeService.StatusConflict)
{
@@ -189,7 +197,11 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
return PlanningMergeResult.Conflict;
}
if (result.Status != TaskMergeService.StatusMerged)
if (result.Status == TaskMergeService.StatusMergedVerifyPending)
{
state.AnyVerifyPending = true;
}
else if (result.Status != TaskMergeService.StatusMerged)
{
_logger.LogWarning(
"Planning continue blocked on subtask {Subtask}: {Msg}",
@@ -262,7 +274,8 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
commitMessage: "", // blank -> TaskMergeService builds the conventional default
leaveConflictsInTree: true,
ct,
progress);
progress,
state.SkipVerify);
if (result.Status == TaskMergeService.StatusConflict)
{
@@ -272,7 +285,11 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
return PlanningMergeResult.Conflict;
}
if (result.Status != TaskMergeService.StatusMerged)
if (result.Status == TaskMergeService.StatusMergedVerifyPending)
{
state.AnyVerifyPending = true;
}
else if (result.Status != TaskMergeService.StatusMerged)
{
_logger.LogWarning(
"Planning merge blocked on subtask {Subtask}: {Msg}",
@@ -284,10 +301,21 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
await _broadcaster.PlanningSubtaskMerged(planningTaskId, subtaskId);
}
state.CurrentSubtaskId = null;
// Batch mode with deferred verifies: the parent must NOT be finalized to Done here --
// that would grant a Done no verify ever gated. It stays WaitingForReview until the
// caller's verify_merges passes and promotes it. PlanningCompleted still fires: the
// drain itself IS complete, and the UI needs its draining banner cleared.
if (state.AnyVerifyPending)
{
await _broadcaster.PlanningCompleted(planningTaskId);
return new PlanningMergeResult(TaskMergeService.StatusMergedVerifyPending, null);
}
// No subtask left to merge, but the parent isn't Done yet -- HasActiveMerge must keep
// reporting true through this window (CurrentSubtaskId is already null) so a Cancel
// racing FinalizeParentDoneAsync's ApproveReviewAsync call is still refused.
state.CurrentSubtaskId = null;
state.IsFinalizing = true;
var (finalized, reason) = await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
if (finalized)