fix(worker): propagate unit-merge failures instead of reporting success

A child merge that came back blocked/verify_failed/untracked_collision during a
parent/children unit merge used to vanish: DrainAsync only logged it server-side,
PlanningMergeAborted carried no reason, and ApproveReview/review_task always
reported StatusMerged for a task with children regardless of the real outcome,
so a failed unit merge left the parent stuck with no visible error.

- PlanningMergeOrchestrator.StartAsync/ContinueAsync/DrainAsync now return a
  PlanningMergeResult(Status, Reason) instead of void, and PlanningMergeAborted
  carries that reason to the UI.
- WorkerHub.ApproveReview and ExternalMcpService.ReviewTask's approve branch
  propagate the real status/reason for a parent with children instead of
  hardcoding "merged" (or masking a non-conflict failure as "conflict").
- StartAsync now requires the parent to already be WaitingForReview for
  improvement parents too, not just planning ones, so a stale caller can no
  longer trigger a partial child merge.
- HasActiveMerge now also covers the window between the last child merging and
  FinalizeParentDoneAsync completing, closing a gap where a concurrent Cancel
  could race the parent's own approve-to-Done transition.
- IslandsShellViewModel.OnPlanningMergeAborted flashes the reason via
  FlashFooterError instead of only clearing the external-merge banner.
This commit is contained in:
mika kuns
2026-08-20 15:02:17 +02:00
parent 4cf08f8159
commit f205843020
14 changed files with 352 additions and 54 deletions
@@ -14,6 +14,16 @@ namespace ClaudeDo.Worker.Planning;
/// <summary>A unit-merge conflict currently paused, driven by an MCP session rather than the UI.</summary>
public sealed record ExternalPlanningMergeConflict(string PlanningTaskId, string SubtaskId);
/// <summary>Outcome of a unit-merge drive (StartAsync/ContinueAsync). Status mirrors
/// <see cref="TaskMergeService"/>'s status strings — StatusMerged/StatusConflict on the two
/// "everything worked (so far)" paths, or the real blocked/verify_failed/untracked_collision
/// status with its Reason when a child merge (or the final approve) failed outright.</summary>
public sealed record PlanningMergeResult(string Status, string? Reason)
{
public static readonly PlanningMergeResult Merged = new(TaskMergeService.StatusMerged, null);
public static readonly PlanningMergeResult Conflict = new(TaskMergeService.StatusConflict, null);
}
public sealed class PlanningMergeOrchestrator : IActiveMergeState
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
@@ -35,6 +45,12 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
/// merge must not auto-pop the in-app resolver — the driving session owns resolution.</summary>
public required bool ExternallyDriven { get; init; }
public string? CurrentSubtaskId { get; set; }
/// <summary>True from the moment the last child has merged until FinalizeParentDoneAsync
/// returns. CurrentSubtaskId is already null in this window (no subtask left to merge),
/// so HasActiveMerge needs this separate flag — otherwise a Cancel racing the finalize call
/// would slip past TaskStateService.CancelAsync's guard while the parent is still being
/// flipped to Done.</summary>
public bool IsFinalizing { get; set; }
}
private readonly ConcurrentDictionary<string, State> _states = new();
@@ -57,7 +73,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
_logger = logger;
}
public async Task StartAsync(
public async Task<PlanningMergeResult> StartAsync(
string parentTaskId, string targetBranch, CancellationToken ct, bool externallyDriven = false,
IProgress<ProgressNotificationValue>? progress = null)
{
@@ -65,6 +81,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
List<TaskEntity> children;
bool isPlanning;
bool parentHasWorktree;
TaskStatus parentStatus;
using (var ctx = _dbFactory.CreateDbContext())
{
@@ -79,6 +96,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
children = parent.Children.OrderBy(c => c.SortOrder).ToList();
isPlanning = parent.PlanningPhase != PlanningPhase.None;
parentHasWorktree = parent.Worktree is { State: WorktreeState.Active };
parentStatus = parent.Status;
}
if (isPlanning)
@@ -95,6 +113,13 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
}
}
// Applies to planning AND improvement parents alike -- a stale UI click or a second
// caller after the parent already left WaitingForReview (e.g. cancelled, or a previous
// Approve already drove it to Done) must not kick off a partial re-merge of its children.
if (parentStatus != TaskStatus.WaitingForReview)
throw new InvalidOperationException(
$"planning task '{parentTaskId}' is not WaitingForReview (status: {parentStatus})");
if (await _git.IsMidMergeAsync(workingDir, ct))
throw new InvalidOperationException(
"repo is mid-merge; use AbortPlanningMerge to reset the repository, then Approve again");
@@ -123,12 +148,13 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
throw new InvalidOperationException($"Merge already in progress for {parentTaskId}.");
await _broadcaster.PlanningMergeStarted(parentTaskId, targetBranch);
await DrainAsync(parentTaskId, ct, progress);
return await DrainAsync(parentTaskId, ct, progress);
}
/// <summary>True when a unit merge for this parent is paused on a conflict (in-memory state).</summary>
/// <summary>True when a unit merge for this parent is paused on a conflict, or is in the
/// window between the last child merging and FinalizeParentDoneAsync completing.</summary>
public bool HasActiveMerge(string parentTaskId) =>
_states.TryGetValue(parentTaskId, out var s) && s.CurrentSubtaskId is not null;
_states.TryGetValue(parentTaskId, out var s) && (s.CurrentSubtaskId is not null || s.IsFinalizing);
/// <summary>Externally-driven unit merges currently paused on a conflict, so the UI can
/// recover this on reconnect instead of relying solely on the one-shot broadcast. Checked
@@ -147,7 +173,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
return result;
}
public async Task ContinueAsync(
public async Task<PlanningMergeResult> ContinueAsync(
string planningTaskId, CancellationToken ct, IProgress<ProgressNotificationValue>? progress = null)
{
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
@@ -160,7 +186,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
if (result.Status == TaskMergeService.StatusConflict)
{
await _broadcaster.PlanningMergeConflict(planningTaskId, current, result.ConflictFiles, state.ExternallyDriven);
return;
return PlanningMergeResult.Conflict;
}
if (result.Status != TaskMergeService.StatusMerged)
@@ -169,14 +195,14 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
"Planning continue blocked on subtask {Subtask}: {Msg}",
current, result.ErrorMessage);
_states.TryRemove(planningTaskId, out _);
await _broadcaster.PlanningMergeAborted(planningTaskId);
return;
await _broadcaster.PlanningMergeAborted(planningTaskId, result.ErrorMessage);
return new PlanningMergeResult(result.Status, result.ErrorMessage);
}
await _broadcaster.PlanningSubtaskMerged(planningTaskId, current);
state.CurrentSubtaskId = null;
await DrainAsync(planningTaskId, ct, progress);
return await DrainAsync(planningTaskId, ct, progress);
}
public async Task AbortAsync(string planningTaskId, CancellationToken ct)
@@ -191,7 +217,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
await _merge.AbortMergeAsync(state.CurrentSubtaskId, ct);
_states.TryRemove(planningTaskId, out _);
await _broadcaster.PlanningMergeAborted(planningTaskId);
await _broadcaster.PlanningMergeAborted(planningTaskId, "Merge aborted.");
}
private async Task AbortStatelessAsync(string planningTaskId, CancellationToken ct)
@@ -212,14 +238,16 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
_logger.LogInformation(
"Stateless abort of mid-merge for planning task {ParentId} (post-restart recovery)",
planningTaskId);
await _broadcaster.PlanningMergeAborted(planningTaskId);
await _broadcaster.PlanningMergeAborted(
planningTaskId, "Merge aborted after a worker restart. Approve again to restart the merge.");
// Parent remains WaitingForReview — Approve will restart the unit merge from scratch.
}
private async Task DrainAsync(
private async Task<PlanningMergeResult> DrainAsync(
string planningTaskId, CancellationToken ct, IProgress<ProgressNotificationValue>? progress = null)
{
if (!_states.TryGetValue(planningTaskId, out var state)) return;
if (!_states.TryGetValue(planningTaskId, out var state))
return new PlanningMergeResult(TaskMergeService.StatusBlocked, "no merge state found for this planning task");
var keepState = false;
try
@@ -241,7 +269,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
await _broadcaster.PlanningMergeConflict(
planningTaskId, subtaskId, result.ConflictFiles, state.ExternallyDriven);
keepState = true;
return;
return PlanningMergeResult.Conflict;
}
if (result.Status != TaskMergeService.StatusMerged)
@@ -249,17 +277,25 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
_logger.LogWarning(
"Planning merge blocked on subtask {Subtask}: {Msg}",
subtaskId, result.ErrorMessage);
await _broadcaster.PlanningMergeAborted(planningTaskId);
return; // keepState stays false → finally removes the state entry
await _broadcaster.PlanningMergeAborted(planningTaskId, result.ErrorMessage);
return new PlanningMergeResult(result.Status, result.ErrorMessage); // keepState stays false → finally removes the state entry
}
await _broadcaster.PlanningSubtaskMerged(planningTaskId, subtaskId);
}
// 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;
var finalized = await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
state.IsFinalizing = true;
var (finalized, reason) = await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
if (finalized)
{
await _broadcaster.PlanningCompleted(planningTaskId);
return PlanningMergeResult.Merged;
}
return new PlanningMergeResult(TaskMergeService.StatusBlocked, reason);
}
finally
{
@@ -267,7 +303,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
}
}
private async Task<bool> FinalizeParentDoneAsync(string parentTaskId, bool isPlanning, CancellationToken ct)
private async Task<(bool Ok, string? Reason)> FinalizeParentDoneAsync(string parentTaskId, bool isPlanning, CancellationToken ct)
{
var result = await _state.ApproveReviewAsync(parentTaskId, ct);
if (!result.Ok)
@@ -288,7 +324,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
_logger.LogWarning(
"Unit-merge drain completed but parent {ParentTaskId} could not be finalized (status: {Status}): {Reason}",
parentTaskId, current, result.Reason);
return false;
return (false, result.Reason);
}
}
@@ -299,6 +335,6 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); }
}
return true;
return (true, null);
}
}