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;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
// Lets TaskStateService guard CancelAsync against an in-progress unit merge without a
|
||||
// hard constructor cycle back to PlanningMergeOrchestrator (which itself depends on
|
||||
// ITaskStateService) — TaskStateService takes a Func<IActiveMergeState> instead, resolved
|
||||
// lazily so both singletons can finish constructing before either calls into the other.
|
||||
public interface IActiveMergeState
|
||||
{
|
||||
bool HasActiveMerge(string taskId);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ 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);
|
||||
|
||||
public sealed class PlanningMergeOrchestrator
|
||||
public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly TaskMergeService _merge;
|
||||
|
||||
@@ -101,12 +101,16 @@ builder.Services.AddSingleton<IQueuePicker, QueuePicker>();
|
||||
builder.Services.AddSingleton<RunCancellationRegistry>();
|
||||
|
||||
builder.Services.AddSingleton<Func<ITaskStateService>>(sp => () => sp.GetRequiredService<ITaskStateService>());
|
||||
// PlanningMergeOrchestrator itself depends on ITaskStateService, so TaskStateService can only
|
||||
// reach it lazily (Func<IActiveMergeState>) — same cycle-breaking shape as the Func above.
|
||||
builder.Services.AddSingleton<Func<IActiveMergeState>>(sp => () => sp.GetRequiredService<PlanningMergeOrchestrator>());
|
||||
builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<HubBroadcaster>(),
|
||||
sp.GetRequiredService<IQueueWaker>(),
|
||||
sp.GetRequiredService<PlanningChainCoordinator>(),
|
||||
sp.GetRequiredService<RunCancellationRegistry>(),
|
||||
sp.GetRequiredService<Func<IActiveMergeState>>(),
|
||||
sp.GetRequiredService<ILogger<TaskStateService>>()));
|
||||
|
||||
// Agent file management.
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
private readonly IQueueWaker _waker;
|
||||
private readonly PlanningChainCoordinator _chain;
|
||||
private readonly RunCancellationRegistry _runCancels;
|
||||
private readonly Func<IActiveMergeState> _mergeState;
|
||||
private readonly ILogger<TaskStateService> _logger;
|
||||
|
||||
public TaskStateService(
|
||||
@@ -24,6 +25,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
IQueueWaker waker,
|
||||
PlanningChainCoordinator chain,
|
||||
RunCancellationRegistry runCancels,
|
||||
Func<IActiveMergeState> mergeState,
|
||||
ILogger<TaskStateService> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
@@ -31,6 +33,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
_waker = waker;
|
||||
_chain = chain;
|
||||
_runCancels = runCancels;
|
||||
_mergeState = mergeState;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -255,6 +258,14 @@ public sealed class TaskStateService : ITaskStateService
|
||||
public async Task<TransitionResult> CancelAsync(
|
||||
string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false)
|
||||
{
|
||||
// A unit merge drains its children's worktrees onto the target branch while the parent
|
||||
// sits in WaitingForReview for the whole (potentially minutes-long) drain. Cancelling out
|
||||
// from under it would flip the parent to Cancelled while the orchestrator keeps merging —
|
||||
// FinalizeParentDoneAsync then finds the parent no longer WaitingForReview and gives up,
|
||||
// leaving the merged children's diffs stranded with no way to roll them back.
|
||||
if (_mergeState().HasActiveMerge(taskId))
|
||||
return new TransitionResult(false, "A merge is in progress for this task; wait for it to finish before cancelling.");
|
||||
|
||||
List<string> cancelledChildIds;
|
||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user