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
@@ -54,7 +54,9 @@ public interface IWorkerClient : INotifyPropertyChanged
/// is true when an MCP session (not the UI) started the unit merge — the resolver must not
/// auto-open in that case.</summary>
event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
event Action<string>? PlanningMergeAbortedEvent;
/// <summary>(planningTaskId, reason). reason is set when the merge stopped on a real failure
/// (blocked/verify_failed/untracked_collision) rather than a deliberate abort.</summary>
event Action<string, string?>? PlanningMergeAbortedEvent;
event Action<string>? PlanningCompletedEvent;
event Action<PrimeFiredEvent>? PrimeFired;
+3 -3
View File
@@ -70,7 +70,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
public event Action<string>? PlanningMergeAbortedEvent;
public event Action<string, string?>? PlanningMergeAbortedEvent;
public event Action<string>? PlanningCompletedEvent;
public event Action<PrimeFiredEvent>? PrimeFired;
@@ -199,9 +199,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles, externallyDriven));
});
_hub.On<string>("PlanningMergeAborted", planningTaskId =>
_hub.On<string, string?>("PlanningMergeAborted", (planningTaskId, reason) =>
{
Dispatcher.UIThread.Post(() => PlanningMergeAbortedEvent?.Invoke(planningTaskId));
Dispatcher.UIThread.Post(() => PlanningMergeAbortedEvent?.Invoke(planningTaskId, reason));
});
_hub.On<string>("PlanningCompleted", planningTaskId =>
@@ -36,7 +36,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private readonly Action<string> _workerWorktreeUpdatedHandler;
private readonly Action<string> _workerTaskUpdatedHandler;
private readonly Action<string, string> _workerPlanningMergeStartedHandler;
private readonly Action<string> _workerPlanningMergeAbortedHandler;
private readonly Action<string, string?> _workerPlanningMergeAbortedHandler;
private readonly Action<string> _workerPlanningCompletedHandler;
[ObservableProperty] private bool _isNotesMode;
@@ -432,7 +432,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
};
_worker.PlanningMergeStartedEvent += _workerPlanningMergeStartedHandler;
_workerPlanningMergeAbortedHandler = planningTaskId =>
_workerPlanningMergeAbortedHandler = (planningTaskId, _) =>
{
if (Task?.Id == planningTaskId) IsMergeDraining = false;
};
@@ -218,7 +218,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
}
public void OnPlanningMergeAborted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
public void OnPlanningMergeAborted(string planningTaskId, string? reason = null)
{
ClearExternalMergeConflict(planningTaskId);
// A deliberate abort/conflict pause and a real merge failure both flow through this
// event; only the latter carries a reason worth surfacing in the footer strip.
if (!string.IsNullOrWhiteSpace(reason))
FlashFooterError(reason);
}
public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
private void ClearExternalMergeConflict(string planningTaskId)