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:
@@ -79,7 +79,8 @@ Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateT
|
|||||||
description is now the canonical place for what each status means.)
|
description is now the canonical place for what each status means.)
|
||||||
|
|
||||||
Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`,
|
Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`,
|
||||||
`PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`.
|
`VerifyMerges`, `PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`,
|
||||||
|
`CleanupTaskWorktree`.
|
||||||
|
|
||||||
Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
|
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
|
`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
|
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.
|
`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
|
**`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
|
task's worktree branch against `targetBranch` (default: the repo's current branch). Returns
|
||||||
|
|||||||
@@ -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
|
so a verify run can't be interrupted by a second merge landing in the same working dir
|
||||||
mid-build.
|
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
|
### Verify in the preview, not just post-merge
|
||||||
|
|
||||||
`TaskMergeService.PreviewAsync(taskId, targetBranch, runVerify, ct)` can additionally build/test a
|
`TaskMergeService.PreviewAsync(taskId, targetBranch, runVerify, ct)` can additionally build/test a
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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).
|
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, verify gate passes (or no verify command is set) → the task is Done; move on.
|
- 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.
|
||||||
- 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.
|
|
||||||
- Conflict (markers left in the working tree, repoPath returned) → resolve it below.
|
- 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.
|
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.
|
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:
|
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.
|
- 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.
|
- 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 -- <the resolved paths>` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
|
- 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 -- <the resolved paths>` 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.
|
- 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
|
## 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:
|
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:
|
||||||
|
|||||||
+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, " +
|
"in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " +
|
||||||
"or abort_merge to cancel.")]
|
"or abort_merge to cancel.")]
|
||||||
bool leaveConflictsInTree = false,
|
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,
|
CancellationToken cancellationToken = default,
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
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
|
// 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;
|
// 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.
|
// 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)
|
if (startResult.Status == TaskMergeService.StatusBlocked)
|
||||||
throw new InvalidOperationException(startResult.Reason ?? "approve failed");
|
throw new InvalidOperationException(startResult.Reason ?? "approve failed");
|
||||||
mergeStatus = startResult.Status;
|
mergeStatus = startResult.Status;
|
||||||
@@ -721,6 +727,11 @@ public sealed class ExternalMcpService
|
|||||||
mergeMessage = "unit merge paused on a conflict — markers left in the working tree; " +
|
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";
|
"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)
|
else if (startResult.Status != TaskMergeService.StatusMerged)
|
||||||
{
|
{
|
||||||
mergeMessage = startResult.Reason;
|
mergeMessage = startResult.Reason;
|
||||||
@@ -728,12 +739,17 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
else
|
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)
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
|
throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
|
||||||
mergeStatus = r.Status;
|
mergeStatus = r.Status;
|
||||||
mergeConflicts = r.ConflictFiles;
|
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)
|
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 " +
|
"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.")]
|
"resolve them there and call continue_merge, or abort_merge to cancel.")]
|
||||||
bool leaveConflictsInTree = false,
|
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,
|
CancellationToken cancellationToken = default,
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
IProgress<ProgressNotificationValue>? progress = null)
|
||||||
{
|
{
|
||||||
@@ -1022,9 +1041,11 @@ public sealed class ExternalMcpService
|
|||||||
|
|
||||||
// Blank on purpose: TaskMergeService builds the conventional default message.
|
// Blank on purpose: TaskMergeService builds the conventional default message.
|
||||||
var result = await _merge.MergeAsync(
|
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;
|
string? mergeCommit = null;
|
||||||
try
|
try
|
||||||
@@ -1053,6 +1074,9 @@ public sealed class ExternalMcpService
|
|||||||
McpToolDocs.ProgressHint)]
|
McpToolDocs.ProgressHint)]
|
||||||
public async Task<MergeContinuationResultDto> ContinueMerge(
|
public async Task<MergeContinuationResultDto> ContinueMerge(
|
||||||
string taskId,
|
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,
|
CancellationToken cancellationToken = default,
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
IProgress<ProgressNotificationValue>? progress = null)
|
||||||
{
|
{
|
||||||
@@ -1069,9 +1093,15 @@ public sealed class ExternalMcpService
|
|||||||
|
|
||||||
if (_planningMerge.HasActiveMerge(taskId))
|
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))!;
|
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;
|
merged = true;
|
||||||
}
|
}
|
||||||
@@ -1096,11 +1126,17 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken, progress);
|
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken, progress, skipVerify);
|
||||||
if (r.Status == TaskMergeService.StatusMerged)
|
if (r.Status == TaskMergeService.StatusMerged)
|
||||||
{
|
{
|
||||||
merged = true;
|
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)
|
else if (r.Status == TaskMergeService.StatusConflict)
|
||||||
{
|
{
|
||||||
merged = false;
|
merged = false;
|
||||||
@@ -1119,6 +1155,46 @@ public sealed class ExternalMcpService
|
|||||||
return new MergeContinuationResultDto(merged, reloaded.Status.ToString(), conflicts, repoPath, message);
|
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(
|
[McpServerTool, Description(
|
||||||
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
|
"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 " +
|
"Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " +
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ public sealed record RevertResult(
|
|||||||
IReadOnlyList<string> ConflictFiles,
|
IReadOnlyList<string> ConflictFiles,
|
||||||
string? ErrorMessage);
|
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<ListVerifyTaskOutcome> Tasks,
|
||||||
|
string? ErrorMessage);
|
||||||
|
|
||||||
public sealed class TaskMergeService
|
public sealed class TaskMergeService
|
||||||
{
|
{
|
||||||
public const string StatusMerged = "merged";
|
public const string StatusMerged = "merged";
|
||||||
@@ -56,6 +65,10 @@ public sealed class TaskMergeService
|
|||||||
public const string StatusBlocked = "blocked";
|
public const string StatusBlocked = "blocked";
|
||||||
public const string StatusAborted = "aborted";
|
public const string StatusAborted = "aborted";
|
||||||
public const string StatusVerifyFailed = "verify_failed";
|
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 StatusUntrackedCollision = "untracked_collision";
|
||||||
|
|
||||||
public const string StatusReverted = "reverted";
|
public const string StatusReverted = "reverted";
|
||||||
@@ -361,7 +374,8 @@ public sealed class TaskMergeService
|
|||||||
string commitMessage,
|
string commitMessage,
|
||||||
bool leaveConflictsInTree,
|
bool leaveConflictsInTree,
|
||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
IProgress<ProgressNotificationValue>? progress = null,
|
||||||
|
bool skipVerify = false)
|
||||||
{
|
{
|
||||||
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
|
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.
|
// silence here is what makes a working merge look like a dead button.
|
||||||
if (!string.IsNullOrWhiteSpace(verifyCommand))
|
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<string>(), cleanupWarning);
|
||||||
|
}
|
||||||
|
|
||||||
await _broadcaster.OperationProgress(taskId, PhaseVerifying, 0, 0);
|
await _broadcaster.OperationProgress(taskId, PhaseVerifying, 0, 0);
|
||||||
await _broadcaster.WorkerLog(
|
await _broadcaster.WorkerLog(
|
||||||
$"Verify command running after merging #{task.Number} \"{task.Title}\" into {targetBranch}",
|
$"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);
|
=> MergeAsync(taskId, targetBranch, removeWorktree, commitMessage, leaveConflictsInTree: false, ct);
|
||||||
|
|
||||||
public async Task<MergeResult> ContinueMergeAsync(
|
public async Task<MergeResult> ContinueMergeAsync(
|
||||||
string taskId, CancellationToken ct, IProgress<ProgressNotificationValue>? progress = null)
|
string taskId, CancellationToken ct, IProgress<ProgressNotificationValue>? progress = null,
|
||||||
|
bool skipVerify = false)
|
||||||
{
|
{
|
||||||
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
|
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);
|
var targetBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
|
||||||
await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, 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<string>(), null);
|
||||||
|
}
|
||||||
|
|
||||||
var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct, progress);
|
var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct, progress);
|
||||||
if (verifyFailure is not null)
|
if (verifyFailure is not null)
|
||||||
{
|
{
|
||||||
@@ -865,7 +898,8 @@ public sealed class TaskMergeService
|
|||||||
|
|
||||||
public async Task<MergeResult> ApproveAndMergeAsync(
|
public async Task<MergeResult> ApproveAndMergeAsync(
|
||||||
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct,
|
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct,
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
IProgress<ProgressNotificationValue>? progress = null,
|
||||||
|
bool skipVerify = false)
|
||||||
{
|
{
|
||||||
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
|
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.
|
// 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 (!string.IsNullOrWhiteSpace(verifyCommand) && !string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
{
|
{
|
||||||
|
if (skipVerify)
|
||||||
|
return new MergeResult(StatusMergedVerifyPending, Array.Empty<string>(), null);
|
||||||
|
|
||||||
var verifyGate = GetMergeGate(list.WorkingDir!);
|
var verifyGate = GetMergeGate(list.WorkingDir!);
|
||||||
await _broadcaster.OperationProgress(taskId, PhaseVerifying, 0, 0);
|
await _broadcaster.OperationProgress(taskId, PhaseVerifying, 0, 0);
|
||||||
await verifyGate.WaitAsync(ct);
|
await verifyGate.WaitAsync(ct);
|
||||||
@@ -911,7 +948,102 @@ public sealed class TaskMergeService
|
|||||||
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
|
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
|
||||||
// Remove the worktree on approve (matching the unit-merge path) so merged
|
// 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.
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ListVerifyResult> RunListVerifyAsync(
|
||||||
|
string listId, IReadOnlyList<string> taskIds, CancellationToken ct,
|
||||||
|
IProgress<ProgressNotificationValue>? progress = null)
|
||||||
|
{
|
||||||
|
if (taskIds.Count == 0)
|
||||||
|
return new ListVerifyResult(StatusBlocked, Array.Empty<ListVerifyTaskOutcome>(), "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<ListVerifyTaskOutcome>(), $"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<ListVerifyTaskOutcome>(), $"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<ListVerifyTaskOutcome>(), "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<ListVerifyTaskOutcome>(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) =>
|
private static MergeResult Blocked(string reason) =>
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
/// from a running Claude session) rather than a direct UI action. Conflicts on such a
|
/// 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>
|
/// merge must not auto-pop the in-app resolver — the driving session owns resolution.</summary>
|
||||||
public required bool ExternallyDriven { get; init; }
|
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; }
|
public string? CurrentSubtaskId { get; set; }
|
||||||
/// <summary>True from the moment the last child has merged until FinalizeParentDoneAsync
|
/// <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),
|
/// 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(
|
public async Task<PlanningMergeResult> StartAsync(
|
||||||
string parentTaskId, string targetBranch, CancellationToken ct, bool externallyDriven = false,
|
string parentTaskId, string targetBranch, CancellationToken ct, bool externallyDriven = false,
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
IProgress<ProgressNotificationValue>? progress = null, bool skipVerify = false)
|
||||||
{
|
{
|
||||||
string workingDir;
|
string workingDir;
|
||||||
List<TaskEntity> children;
|
List<TaskEntity> children;
|
||||||
@@ -143,6 +150,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
IsPlanning = isPlanning,
|
IsPlanning = isPlanning,
|
||||||
WorkingDir = workingDir,
|
WorkingDir = workingDir,
|
||||||
ExternallyDriven = externallyDriven,
|
ExternallyDriven = externallyDriven,
|
||||||
|
SkipVerify = skipVerify,
|
||||||
};
|
};
|
||||||
if (!_states.TryAdd(parentTaskId, state))
|
if (!_states.TryAdd(parentTaskId, state))
|
||||||
throw new InvalidOperationException($"Merge already in progress for {parentTaskId}.");
|
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");
|
"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 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)
|
if (result.Status == TaskMergeService.StatusConflict)
|
||||||
{
|
{
|
||||||
@@ -189,7 +197,11 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
return PlanningMergeResult.Conflict;
|
return PlanningMergeResult.Conflict;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.Status != TaskMergeService.StatusMerged)
|
if (result.Status == TaskMergeService.StatusMergedVerifyPending)
|
||||||
|
{
|
||||||
|
state.AnyVerifyPending = true;
|
||||||
|
}
|
||||||
|
else if (result.Status != TaskMergeService.StatusMerged)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Planning continue blocked on subtask {Subtask}: {Msg}",
|
"Planning continue blocked on subtask {Subtask}: {Msg}",
|
||||||
@@ -262,7 +274,8 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
commitMessage: "", // blank -> TaskMergeService builds the conventional default
|
commitMessage: "", // blank -> TaskMergeService builds the conventional default
|
||||||
leaveConflictsInTree: true,
|
leaveConflictsInTree: true,
|
||||||
ct,
|
ct,
|
||||||
progress);
|
progress,
|
||||||
|
state.SkipVerify);
|
||||||
|
|
||||||
if (result.Status == TaskMergeService.StatusConflict)
|
if (result.Status == TaskMergeService.StatusConflict)
|
||||||
{
|
{
|
||||||
@@ -272,7 +285,11 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
return PlanningMergeResult.Conflict;
|
return PlanningMergeResult.Conflict;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.Status != TaskMergeService.StatusMerged)
|
if (result.Status == TaskMergeService.StatusMergedVerifyPending)
|
||||||
|
{
|
||||||
|
state.AnyVerifyPending = true;
|
||||||
|
}
|
||||||
|
else if (result.Status != TaskMergeService.StatusMerged)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Planning merge blocked on subtask {Subtask}: {Msg}",
|
"Planning merge blocked on subtask {Subtask}: {Msg}",
|
||||||
@@ -284,10 +301,21 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
await _broadcaster.PlanningSubtaskMerged(planningTaskId, subtaskId);
|
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
|
// 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
|
// reporting true through this window (CurrentSubtaskId is already null) so a Cancel
|
||||||
// racing FinalizeParentDoneAsync's ApproveReviewAsync call is still refused.
|
// racing FinalizeParentDoneAsync's ApproveReviewAsync call is still refused.
|
||||||
state.CurrentSubtaskId = null;
|
|
||||||
state.IsFinalizing = true;
|
state.IsFinalizing = true;
|
||||||
var (finalized, reason) = await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
|
var (finalized, reason) = await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
|
||||||
if (finalized)
|
if (finalized)
|
||||||
|
|||||||
+18
-18
@@ -1757,7 +1757,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
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.False(result.Merged);
|
||||||
Assert.True(result.ConflictsInTree);
|
Assert.True(result.ConflictsInTree);
|
||||||
@@ -1781,7 +1781,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
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.Merged);
|
||||||
Assert.False(result.ConflictsInTree);
|
Assert.False(result.ConflictsInTree);
|
||||||
@@ -1800,7 +1800,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
var result = await sut.ReviewTask(task.Id, "approve", null, null,
|
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(TaskMergeService.StatusConflict, result.MergeStatus);
|
||||||
Assert.Equal(list.WorkingDir, result.RepoPath);
|
Assert.Equal(list.WorkingDir, result.RepoPath);
|
||||||
@@ -1840,13 +1840,13 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
await sut.ReviewTask(task.Id, "approve", null, null,
|
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.
|
// Resolve the conflict on disk and stage it.
|
||||||
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
|
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
|
||||||
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
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.True(result.Merged);
|
||||||
Assert.Equal("Done", result.TaskStatus);
|
Assert.Equal("Done", result.TaskStatus);
|
||||||
@@ -1869,7 +1869,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
var plainSut = BuildSut(CreateQueue());
|
var plainSut = BuildSut(CreateQueue());
|
||||||
await plainSut.ReviewTask(task.Id, "approve", null, null,
|
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");
|
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
|
||||||
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
||||||
@@ -1887,7 +1887,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var reports = new List<ProgressNotificationValue>();
|
var reports = new List<ProgressNotificationValue>();
|
||||||
var progress = new Progress<ProgressNotificationValue>(reports.Add);
|
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.True(result.Merged);
|
||||||
Assert.Equal("Done", result.TaskStatus);
|
Assert.Equal("Done", result.TaskStatus);
|
||||||
@@ -1908,10 +1908,10 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
await sut.ReviewTask(task.Id, "approve", null, null,
|
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.
|
// 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.False(result.Merged);
|
||||||
Assert.Contains("README.md", result.Conflicts);
|
Assert.Contains("README.md", result.Conflicts);
|
||||||
@@ -1930,7 +1930,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
await sut.ReviewTask(task.Id, "approve", null, null,
|
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);
|
var dto = await sut.AbortMerge(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
@@ -1948,7 +1948,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
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);
|
Assert.Contains("mid-merge", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1974,7 +1974,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
|
|
||||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
var sut = BuildSut(CreateQueue());
|
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);
|
var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
@@ -2016,7 +2016,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
var merge = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
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);
|
Assert.True(merge.ConflictsInTree);
|
||||||
|
|
||||||
var before = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
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);
|
var finalConflicts = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
||||||
Assert.Equal(0, finalConflicts.RemainingHunks);
|
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.True(continueResult.Merged);
|
||||||
Assert.Equal("Done", continueResult.TaskStatus);
|
Assert.Equal("Done", continueResult.TaskStatus);
|
||||||
|
|
||||||
@@ -2057,13 +2057,13 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
await sut.MergeTask(task.Id, target, true, dryRun: false,
|
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.
|
// Resolve only one of the three hunks.
|
||||||
await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
|
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.
|
// 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.False(result.Merged);
|
||||||
Assert.Contains("README.md", result.Conflicts);
|
Assert.Contains("README.md", result.Conflicts);
|
||||||
@@ -2079,7 +2079,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
|
|
||||||
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
var sut = BuildSut(CreateQueue());
|
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>(
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None));
|
() => 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.
|
// Resolve the conflict on disk.
|
||||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
|
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.True(result.Merged);
|
||||||
Assert.Equal("Done", result.TaskStatus);
|
Assert.Equal("Done", result.TaskStatus);
|
||||||
|
|||||||
@@ -1742,6 +1742,178 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
|
Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
|
||||||
&& c.Args[0] is string s && s.Contains("Auto-rebase failed") && s.Contains("task-b"));
|
&& 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
|
#region Test doubles
|
||||||
@@ -1749,6 +1921,7 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
|
internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
|
||||||
{
|
{
|
||||||
public VerifyCommandResult Result { get; set; } = new(0, false, "");
|
public VerifyCommandResult Result { get; set; } = new(0, false, "");
|
||||||
|
public int CallCount { get; private set; }
|
||||||
public string? CapturedWorkingDir { get; private set; }
|
public string? CapturedWorkingDir { get; private set; }
|
||||||
public string? CapturedCommand { 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)
|
public async Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
CallCount++;
|
||||||
CapturedWorkingDir = workingDir;
|
CapturedWorkingDir = workingDir;
|
||||||
CapturedCommand = command;
|
CapturedCommand = command;
|
||||||
if (FileToCheck is not null)
|
if (FileToCheck is not null)
|
||||||
|
|||||||
Reference in New Issue
Block a user