fix(worker,ui): block cancelling a task while its unit merge is draining
TaskStateService.CancelAsync allowed cancelling a WaitingForReview task even while PlanningMergeOrchestrator was mid-drain on it: ApproveReview awaits the whole multi-subtask merge synchronously, so a concurrent CancelReview (UI or MCP) could flip the parent to Cancelled while the orchestrator kept merging children onto the target branch, then FinalizeParentDoneAsync would find the parent no longer WaitingForReview and give up - leaving the merged diffs stranded with no rollback. CancelAsync now rejects with a clear reason when HasActiveMerge(taskId) is true. TaskStateService can't take PlanningMergeOrchestrator as a direct constructor dependency (circular back to ITaskStateService), so it takes a lazily-resolved Func<IActiveMergeState> instead, mirroring the existing Func<ITaskStateService> cycle-break already used for PlanningChainCoordinator. UI polish: DetailsIslandViewModel.IsMergeDraining gates CancelReviewCommand's CanExecute (same shape as WorktreesOverviewModalViewModel.IsMerging), and the command's catch now raises ErrorReported instead of swallowing the rejection silently.
This commit is contained in:
@@ -35,6 +35,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
private readonly Action<string, string, string, DateTime> _workerTaskFinishedHandler;
|
||||
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> _workerPlanningCompletedHandler;
|
||||
|
||||
[ObservableProperty] private bool _isNotesMode;
|
||||
[ObservableProperty] private bool _isPrepMode;
|
||||
@@ -351,6 +354,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||
ContinueCommand.NotifyCanExecuteChanged();
|
||||
SendRoadblockReplyCommand.NotifyCanExecuteChanged();
|
||||
CancelReviewCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
};
|
||||
_worker.PropertyChanged += _workerPropertyChangedHandler;
|
||||
@@ -392,6 +396,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
};
|
||||
_worker.TaskUpdatedEvent += _workerTaskUpdatedHandler;
|
||||
|
||||
_workerPlanningMergeStartedHandler = (planningTaskId, _) =>
|
||||
{
|
||||
if (Task?.Id == planningTaskId) IsMergeDraining = true;
|
||||
};
|
||||
_worker.PlanningMergeStartedEvent += _workerPlanningMergeStartedHandler;
|
||||
|
||||
_workerPlanningMergeAbortedHandler = planningTaskId =>
|
||||
{
|
||||
if (Task?.Id == planningTaskId) IsMergeDraining = false;
|
||||
};
|
||||
_worker.PlanningMergeAbortedEvent += _workerPlanningMergeAbortedHandler;
|
||||
|
||||
_workerPlanningCompletedHandler = planningTaskId =>
|
||||
{
|
||||
if (Task?.Id == planningTaskId) IsMergeDraining = false;
|
||||
};
|
||||
_worker.PlanningCompletedEvent += _workerPlanningCompletedHandler;
|
||||
|
||||
ChildOutcomes.CollectionChanged += (_, _) =>
|
||||
{
|
||||
Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
|
||||
@@ -409,6 +431,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
|
||||
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
|
||||
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
|
||||
_worker.PlanningMergeStartedEvent -= _workerPlanningMergeStartedHandler;
|
||||
_worker.PlanningMergeAbortedEvent -= _workerPlanningMergeAbortedHandler;
|
||||
_worker.PlanningCompletedEvent -= _workerPlanningCompletedHandler;
|
||||
AgentSettings.Dispose();
|
||||
Prep.Dispose();
|
||||
}
|
||||
@@ -438,6 +463,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
DequeueCommand.NotifyCanExecuteChanged();
|
||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||
ContinueCommand.NotifyCanExecuteChanged();
|
||||
CancelReviewCommand.NotifyCanExecuteChanged();
|
||||
// A state change means a new run/review cycle: the diff must be
|
||||
// re-inspected before merge can be approved again.
|
||||
ReviewDiffViewed = false;
|
||||
@@ -547,6 +573,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
OnPropertyChanged(nameof(TaskIdBadge));
|
||||
Monitor.Reset();
|
||||
RoadblockReplyDraft = string.Empty;
|
||||
IsMergeDraining = false;
|
||||
Subtasks.Clear();
|
||||
ChildOutcomes.Clear();
|
||||
Attachments.Clear();
|
||||
@@ -1204,14 +1231,28 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
catch { /* stale review action; broadcast reconciles */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
// True while a unit merge is actively draining this task's subtasks onto the target
|
||||
// branch — set from PlanningMergeStarted, cleared on PlanningMergeAborted/Completed.
|
||||
// Mirrors the worker-side guard in TaskStateService.CancelAsync (the real correctness
|
||||
// fix); this just keeps the button from inviting a click the worker will reject anyway.
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(CancelReviewCommand))]
|
||||
private bool _isMergeDraining;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanCancelReview))]
|
||||
private async System.Threading.Tasks.Task CancelReviewAsync()
|
||||
{
|
||||
if (Task is null || !_worker.IsConnected) return;
|
||||
try { await _worker.CancelReviewAsync(Task.Id); }
|
||||
catch { /* stale review action; broadcast reconciles */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanCancelReview() =>
|
||||
Task != null && _worker.IsConnected && !IsMergeDraining;
|
||||
|
||||
private async System.Threading.Tasks.Task ReloadAttachmentsAsync()
|
||||
{
|
||||
if (Task is null) return;
|
||||
|
||||
Reference in New Issue
Block a user