diff --git a/docs/explore-notes/external-mcp.md b/docs/explore-notes/external-mcp.md index 7a59a0ca..19eed615 100644 --- a/docs/explore-notes/external-mcp.md +++ b/docs/explore-notes/external-mcp.md @@ -79,7 +79,8 @@ Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateT description is now the canonical place for what each status means.) Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`, -`PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`. +`VerifyMerges`, `PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, +`CleanupTaskWorktree`. Daily prep: `GetDailyPrepCandidates`, `SetMyDay`. @@ -131,6 +132,18 @@ are reported in `ReviewTaskResult`. `Done` and merged silently with `changedFileCount: 0`, indistinguishable from a small-but-real change. `TaskRefDto.roadblockCount` (on every task-returning tool, stamped by `TaskRunner` from `result.Blocks.Count`) is the MCP-visible signal for *why* a child is empty. +- `skipVerify=true` (also on `MergeTask`/`ContinueMerge`) is batch mode: the merge lands but the + list's verify command is deferred and the task stays `WaitingForReview` with mergeStatus + `merged_verify_pending`. One `VerifyMerges` call after the batch runs the verify once and + promotes them — see [review-merge.md](review-merge.md) → batch verify. + +**`VerifyMerges`** — `verify_merges(taskIds)`: runs the list's verify command ONCE (same per-repo +merge gate) and promotes each listed task to Done via `ApproveReviewAsync` on success. Explicit +ids on purpose — auto-promoting every `WaitingForReview` task would sweep up the handler's own +task and deliberately parked `verify_failed` tasks. Eligibility per task: `WaitingForReview` AND +(worktree `Merged` OR no worktree); an `Active` worktree means the branch never landed and is +refused per-entry (the rest still promote). All ids must share one list. `verify_failed` promotes +nothing; the merges stay in place. **`PreviewMerge`** — non-destructive `git merge-tree --write-tree` mergeability check for one task's worktree branch against `targetBranch` (default: the repo's current branch). Returns diff --git a/docs/explore-notes/review-merge.md b/docs/explore-notes/review-merge.md index 76158f0a..52494fb4 100644 --- a/docs/explore-notes/review-merge.md +++ b/docs/explore-notes/review-merge.md @@ -153,6 +153,27 @@ dead app for minutes while the merge has in fact already landed. so a verify run can't be interrupted by a second merge landing in the same working dir mid-build. +### Batch verify (skipVerify + verify_merges) + +Per-merge verify made an N-task batch pay N × ~7 min, each run testing the same moving main. +`MergeAsync`/`ContinueMergeAsync`/`ApproveAndMergeAsync` take `skipVerify` (MCP-only — +`review_task`/`merge_task`/`continue_merge` expose it; the UI approve never passes it): the merge +lands normally but the gate AND the Done transition are deferred — status +`merged_verify_pending`, task stays `WaitingForReview` with a `Merged` worktree (the same shape +`verify_failed` leaves behind). `TaskMergeService.RunListVerifyAsync` (tool `verify_merges`) then +runs the command once under the same per-repo gate and promotes the explicitly listed tasks via +`ApproveReviewAsync`. Explicit ids, not a query — auto-promotion would sweep up the handler's own +submitted task and parked `verify_failed` tasks. A task whose worktree is still `Active` is +refused per-entry (branch never landed → would go Done with an unmerged branch). + +`PlanningMergeOrchestrator` threads `skipVerify` through the unit merge (`State.SkipVerify`), so +children no longer pay the gate per child; when any child came back `merged_verify_pending` the +drain skips `FinalizeParentDoneAsync` (a finalize would grant a Done nothing verified), still +broadcasts `PlanningCompleted` (the drain IS complete; the UI banner must clear), and returns +`merged_verify_pending` — the parent is promoted later by `verify_merges` like any other task. +The merge-helper Merge prompt approves with `skipVerify=true` and calls `verify_merges` once +after the last merge; the invariant "no Done without a green verify" stays server-enforced. + ### Verify in the preview, not just post-merge `TaskMergeService.PreviewAsync(taskId, targetBranch, runVerify, ct)` can additionally build/test a diff --git a/src/ClaudeDo.Data/PromptFiles.cs b/src/ClaudeDo.Data/PromptFiles.cs index 8b692d0d..9cf17a5c 100644 --- a/src/ClaudeDo.Data/PromptFiles.cs +++ b/src/ClaudeDo.Data/PromptFiles.cs @@ -508,9 +508,8 @@ public static class PromptFiles 1. Never read a task's diff yourself. Instead start a sonnet subagent (Task tool) per task to inspect get_task_diff and sanity-check it against the task's title and description; only its short verdict (clean / risky + reason) comes back into your own context. If preview_merge_set flagged this task in an overlap, tell the subagent to also skim the diff of the other task(s) sharing that file. 2. Before approving, cross-check changedFileCount from preview_merge_set (or the length of get_task_diff's files list — the diff tool has no changedFileCount field). 0 changed files means an empty branch, not a clean success — do not approve it; treat it like a risky diff instead. 3. If the subagent's verdict is risky, or the change looks wrong or incomplete, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip. - 4. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true). - - Clean merge, verify gate passes (or no verify command is set) → the task is Done; move on. - - Clean merge, verify gate fails → the merge stands, but the Done transition is deliberately withheld. Report this task as merged-but-not-Done and move on — do NOT try to fix the verify failure yourself; that is separate follow-up work. + 4. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true, skipVerify=true). skipVerify defers the list's verify command to ONE verify_merges call after the last merge (below) instead of paying it per merge. + - Clean merge → mergeStatus "merged_verify_pending" (expected: the task deliberately stays WaitingForReview until verify_merges passes) or "merged" (list has no verify command; the task is Done). Either way, move on. - Conflict (markers left in the working tree, repoPath returned) → resolve it below. After each merge, main has moved — a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it. @@ -518,12 +517,19 @@ public static class PromptFiles Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted. Resolving a conflict — do this yourself, never via a subagent, since every subagent would share this session's same main checkout: - - Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you — fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve. + - Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId, skipVerify=true). If markers remain it tells you — fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve. - For a task WITH children (a unit merge), pass the PARENT task id to continue_merge / abort_merge. - If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing. - Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- ` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions. - Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish. + ## Verify once — after the last merge + + When every merge above is done (including conflict resolutions), call verify_merges with the ids of ALL tasks that reported merged_verify_pending. It runs the list's verify command a single time and promotes them all to Done on success — that is the per-merge gate you skipped, paid once. + - verify_passed → check each entry's promoted flag; report any skipped entry with its reason. + - verify_failed → the merges stay in place, but every task deliberately stays out of Done. Report them as merged-but-not-Done together with the output tail — do NOT try to fix the verify failure yourself; that is separate follow-up work. + Skip the call only when nothing reported merged_verify_pending. + ## Failed tasks — only after every merge above is done For each task that landed in Failed, read failureReason (returned by get_task/batch_get_tasks) before deciding anything — Failed is a catch-all covering an account/usage limit, a max-turns cutoff, and a genuine crash, and each deserves a different response. Never restart one blind: diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index e858a1fc..6565cf40 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -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? 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? 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 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? 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 VerifyMerges( + [Description("Every task merged with skipVerify=true in this batch (accepts #numbers or ids).")] + string[] taskIds, + CancellationToken cancellationToken = default, + IProgress? 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(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 " + diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index e37059b1..99ee852f 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -49,6 +49,15 @@ public sealed record RevertResult( IReadOnlyList ConflictFiles, string? ErrorMessage); +// Per-task outcome of a batch verify (RunListVerifyAsync): Promoted=true means the task reached +// Done; otherwise Reason says why it was left alone (invalid candidate, or the verify failed). +public sealed record ListVerifyTaskOutcome(string TaskId, int Number, bool Promoted, string? Reason); + +public sealed record ListVerifyResult( + string Status, + IReadOnlyList Tasks, + string? ErrorMessage); + public sealed class TaskMergeService { public const string StatusMerged = "merged"; @@ -56,6 +65,10 @@ public sealed class TaskMergeService public const string StatusBlocked = "blocked"; public const string StatusAborted = "aborted"; public const string StatusVerifyFailed = "verify_failed"; + // Batch mode (skipVerify): the merge landed but the verify gate was deliberately deferred -- + // the task stays out of Done until RunListVerifyAsync passes and promotes it. + public const string StatusMergedVerifyPending = "merged_verify_pending"; + public const string StatusVerifyPassed = "verify_passed"; public const string StatusUntrackedCollision = "untracked_collision"; public const string StatusReverted = "reverted"; @@ -361,7 +374,8 @@ public sealed class TaskMergeService string commitMessage, bool leaveConflictsInTree, CancellationToken ct, - IProgress? progress = null) + IProgress? progress = null, + bool skipVerify = false) { var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct); @@ -463,6 +477,16 @@ public sealed class TaskMergeService // silence here is what makes a working merge look like a dead button. if (!string.IsNullOrWhiteSpace(verifyCommand)) { + if (skipVerify) + { + // Batch mode: the caller defers the gate to one RunListVerifyAsync at the end. + // The Done transition is withheld with it — same invariant as verify_failed. + await _broadcaster.WorkerLog( + $"Merged #{task.Number} \"{task.Title}\" into {targetBranch} — verify deferred to batch", + WorkerLogLevel.Info, DateTime.UtcNow); + return new MergeResult(StatusMergedVerifyPending, Array.Empty(), cleanupWarning); + } + await _broadcaster.OperationProgress(taskId, PhaseVerifying, 0, 0); await _broadcaster.WorkerLog( $"Verify command running after merging #{task.Number} \"{task.Title}\" into {targetBranch}", @@ -500,7 +524,8 @@ public sealed class TaskMergeService => MergeAsync(taskId, targetBranch, removeWorktree, commitMessage, leaveConflictsInTree: false, ct); public async Task ContinueMergeAsync( - string taskId, CancellationToken ct, IProgress? progress = null) + string taskId, CancellationToken ct, IProgress? progress = null, + bool skipVerify = false) { var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct); @@ -566,6 +591,14 @@ public sealed class TaskMergeService var targetBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct); await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct); + if (!string.IsNullOrWhiteSpace(verifyCommand) && skipVerify) + { + await _broadcaster.WorkerLog( + $"Merged #{task.Number} \"{task.Title}\" (conflict resolved) — verify deferred to batch", + WorkerLogLevel.Info, DateTime.UtcNow); + return new MergeResult(StatusMergedVerifyPending, Array.Empty(), null); + } + var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct, progress); if (verifyFailure is not null) { @@ -865,7 +898,8 @@ public sealed class TaskMergeService public async Task ApproveAndMergeAsync( string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct, - IProgress? progress = null) + IProgress? progress = null, + bool skipVerify = false) { var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct); @@ -881,6 +915,9 @@ public sealed class TaskMergeService // per-repo gate as the merge path, so a concurrent merge can't land mid-verify. if (!string.IsNullOrWhiteSpace(verifyCommand) && !string.IsNullOrWhiteSpace(list.WorkingDir)) { + if (skipVerify) + return new MergeResult(StatusMergedVerifyPending, Array.Empty(), null); + var verifyGate = GetMergeGate(list.WorkingDir!); await _broadcaster.OperationProgress(taskId, PhaseVerifying, 0, 0); await verifyGate.WaitAsync(ct); @@ -911,7 +948,102 @@ public sealed class TaskMergeService // MergeAsync transitions the task WaitingForReview -> Done on a successful merge. // Remove the worktree on approve (matching the unit-merge path) so merged // worktrees don't pile up; the merge commit on the target branch is the record. - return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress); + return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify); + } + + /// + /// The batch counterpart to the per-merge verify gate: runs the list's verify command ONCE + /// (under the same per-repo gate) and, on success, promotes every listed task to Done via + /// ApproveReviewAsync. Tasks were merged with skipVerify=true and sit in WaitingForReview. + /// A task is only eligible when its work verifiably landed: worktree state Merged, or no + /// worktree at all (sandbox/list-handler run) -- an Active worktree means the branch was + /// never merged, and promoting it would produce a Done task with an unmerged branch. + /// No verify command configured = no gate (same rule as everywhere else): promote directly. + /// + public async Task RunListVerifyAsync( + string listId, IReadOnlyList taskIds, CancellationToken ct, + IProgress? progress = null) + { + if (taskIds.Count == 0) + return new ListVerifyResult(StatusBlocked, Array.Empty(), "no task ids given"); + + ListEntity? list; + string? verifyCommand; + var candidates = new List<(TaskEntity Task, string? IneligibleReason)>(); + using (var ctx = _dbFactory.CreateDbContext()) + { + var listRepo = new ListRepository(ctx); + list = await listRepo.GetByIdAsync(listId, ct); + if (list is null) + return new ListVerifyResult(StatusBlocked, Array.Empty(), $"list '{listId}' not found"); + verifyCommand = (await listRepo.GetConfigAsync(listId, ct))?.VerifyCommand; + + var taskRepo = new TaskRepository(ctx); + var wtRepo = new WorktreeRepository(ctx); + foreach (var id in taskIds.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var task = await taskRepo.GetByIdAsync(id, ct); + if (task is null) + return new ListVerifyResult(StatusBlocked, Array.Empty(), $"task '{id}' not found"); + var reason = task.ListId != list.Id ? "task belongs to a different list" + : task.Status != TaskStatus.WaitingForReview ? $"task is {task.Status}, not WaitingForReview" + : (await wtRepo.GetByTaskIdAsync(task.Id, ct)) is { } wt && wt.State != WorktreeState.Merged + ? $"worktree state is {wt.State}, not Merged — its branch never landed" + : null; + candidates.Add((task, reason)); + } + } + + if (string.IsNullOrWhiteSpace(list.WorkingDir)) + return new ListVerifyResult(StatusBlocked, Array.Empty(), "list has no working directory"); + + if (!string.IsNullOrWhiteSpace(verifyCommand)) + { + var gate = GetMergeGate(list.WorkingDir); + await _broadcaster.OperationProgress(listId, PhaseVerifying, 0, 0); + await gate.WaitAsync(ct); + MergeResult? failure; + try + { + await _broadcaster.WorkerLog( + $"Batch verify running for {candidates.Count} merged task(s) in \"{list.Name}\"", + WorkerLogLevel.Info, DateTime.UtcNow); + failure = await RunVerifyGateAsync( + verifyCommand, list.WorkingDir, ct, progress, + elapsed => _ = _broadcaster.OperationProgress(listId, PhaseVerifying, (int)elapsed.TotalSeconds, 0)); + } + finally { gate.Release(); } + + if (failure is not null) + { + await _broadcaster.WorkerLog( + $"Batch verify failed in \"{list.Name}\" — {candidates.Count} task(s) stay out of Done", + WorkerLogLevel.Warn, DateTime.UtcNow); + var failed = candidates + .Select(c => new ListVerifyTaskOutcome(c.Task.Id, c.Task.Number, false, + c.IneligibleReason ?? "verify failed")) + .ToList(); + return new ListVerifyResult(StatusVerifyFailed, failed, failure.ErrorMessage); + } + } + + var outcomes = new List(candidates.Count); + foreach (var (task, ineligibleReason) in candidates) + { + if (ineligibleReason is not null) + { + outcomes.Add(new ListVerifyTaskOutcome(task.Id, task.Number, false, ineligibleReason)); + continue; + } + var result = await _state.ApproveReviewAsync(task.Id, ct); + outcomes.Add(new ListVerifyTaskOutcome(task.Id, task.Number, result.Ok, result.Ok ? null : result.Reason)); + } + + var promoted = outcomes.Count(o => o.Promoted); + await _broadcaster.WorkerLog( + $"Batch verify passed in \"{list.Name}\" — {promoted} task(s) promoted to Done", + WorkerLogLevel.Success, DateTime.UtcNow); + return new ListVerifyResult(StatusVerifyPassed, outcomes, null); } private static MergeResult Blocked(string reason) => diff --git a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs index ee4a93f1..db2eba1e 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs @@ -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. public required bool ExternallyDriven { get; init; } + /// 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. + public required bool SkipVerify { get; init; } + /// Set when any child merge came back merged_verify_pending — the drain must then + /// end without finalizing the parent. + public bool AnyVerifyPending { get; set; } public string? CurrentSubtaskId { get; set; } /// 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 StartAsync( string parentTaskId, string targetBranch, CancellationToken ct, bool externallyDriven = false, - IProgress? progress = null) + IProgress? progress = null, bool skipVerify = false) { string workingDir; List 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) diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 56426233..e2a2149a 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -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(); var progress = new Progress(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( - () => 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( () => 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); diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 7db0c193..3531be0e 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -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 RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct) { + CallCount++; CapturedWorkingDir = workingDir; CapturedCommand = command; if (FileToCheck is not null)