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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user