Merge claudedo/973ea49ec0a642ea9b1ddcff9b71b6fe
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();
|
||||
@@ -1200,14 +1227,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