Merge claudedo/973ea49ec0a642ea9b1ddcff9b71b6fe

This commit is contained in:
mika kuns
2026-08-06 13:47:35 +02:00
10 changed files with 205 additions and 5 deletions
@@ -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;
+4
View File
@@ -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))
{