fix(ui): suppress auto-open conflict resolver during MCP-driven merges

review_task/continue_merge on a planning parent always leaves conflicts in
the tree, and the UI auto-opened the resolver on every PlanningMergeConflict
broadcast regardless of who started the merge -- so a running Claude session
resolving a unit-merge conflict could race a human editing the same shared
checkout in a resolver window neither of them asked for.

PlanningMergeOrchestrator.StartAsync now takes an externallyDriven flag (set
by ExternalMcpService's MCP-driven review_task path, left false for the UI's
ApproveReview) that rides along on the PlanningMergeConflict broadcast. The
UI only auto-opens the resolver when it's false; otherwise it shows a
persistent banner with a manual "Open resolver" button, cleared on
PlanningMergeAborted/PlanningCompleted. A new GetActiveExternalConflictsAsync
query (checked against GitService.IsMidMergeAsync rather than the in-memory
flag alone) lets the UI resync the banner on reconnect instead of trusting a
one-shot broadcast that isn't replayed after a restart.

The childless single-task conflict path was checked and needed no change --
it only broadcasts the generic TaskUpdated, never PlanningMergeConflict.
This commit is contained in:
mika kuns
2026-08-06 12:04:52 +02:00
parent 8247a749a0
commit 5d1d2d89d0
15 changed files with 379 additions and 15 deletions
@@ -629,6 +629,10 @@
"available": "Update verfügbar: v",
"updateNow": "Jetzt aktualisieren",
"dismiss": "Ausblenden"
},
"externalMerge": {
"banner": "Eine Claude-Session löst gerade einen Merge-Konflikt auf — bitte keine Dateien im Repository bearbeiten.",
"open": "Resolver öffnen"
}
},
"vm": {
@@ -629,6 +629,10 @@
"available": "Update available: v",
"updateNow": "Update now",
"dismiss": "Dismiss"
},
"externalMerge": {
"banner": "A Claude session is resolving a merge conflict — don't edit files in the repository.",
"open": "Open resolver"
}
},
"vm": {
@@ -35,7 +35,10 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string, string>? PlanningMergeStartedEvent;
event Action<string, string>? PlanningSubtaskMergedEvent;
event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
/// 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;
event Action<string>? PlanningCompletedEvent;
@@ -117,6 +120,9 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
Task ContinuePlanningMergeAsync(string planningTaskId);
Task AbortPlanningMergeAsync(string planningTaskId);
/// <summary>Unit merges currently paused on a conflict that an MCP session started. Called
/// on (re)connect to recover the "don't auto-open" banner state after a UI restart.</summary>
Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync();
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
+8 -3
View File
@@ -66,7 +66,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>>? PlanningMergeConflictEvent;
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
public event Action<string>? PlanningMergeAbortedEvent;
public event Action<string>? PlanningCompletedEvent;
@@ -182,9 +182,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => PlanningSubtaskMergedEvent?.Invoke(planningTaskId, subtaskId));
});
_hub.On<string, string, IReadOnlyList<string>>("PlanningMergeConflict", (planningTaskId, subtaskId, conflictedFiles) =>
_hub.On<string, string, IReadOnlyList<string>, bool>("PlanningMergeConflict", (planningTaskId, subtaskId, conflictedFiles, externallyDriven) =>
{
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles));
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles, externallyDriven));
});
_hub.On<string>("PlanningMergeAborted", planningTaskId =>
@@ -578,6 +578,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
}
public async Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
=> await TryInvokeAsync<List<PlanningMergeConflictStateDto>>("GetActiveExternalPlanningMergeConflicts")
?? [];
public async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
{
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
@@ -692,6 +696,7 @@ public sealed record LaunchSpec(
IReadOnlyDictionary<string, string> Env);
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
public sealed record PlanningMergeConflictStateDto(string PlanningTaskId, string SubtaskId);
public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question);
public sealed record OnlineInboxStateDto(
@@ -102,6 +102,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string? _updateBannerLatestVersion;
private bool _bannerDismissedThisSession;
// planningTaskId -> subtaskId, for unit-merge conflicts an MCP session (not the UI) started.
// Kept as a dictionary so a later conflict on the same planning task updates in place instead
// of piling up duplicate entries.
private readonly Dictionary<string, string> _externalMergeConflicts = new();
[ObservableProperty] private bool _isExternalMergeBannerVisible;
[ObservableProperty]
private double _windowWidth = 1280;
@@ -178,13 +184,59 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_primeStatusTimer.Start();
}
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
public void OnPlanningMergeConflict(
string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles, bool externallyDriven)
{
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
if (externallyDriven)
{
// An MCP session (review_task/continue_merge) is driving this merge — it owns
// resolution. Auto-opening the resolver here raced with the session's own writes
// (two parties editing the same shared checkout at once); show a banner instead and
// leave the resolver reachable only via a deliberate click.
_externalMergeConflicts[planningTaskId] = subtaskId;
IsExternalMergeBannerVisible = true;
return;
}
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
}
public void OnPlanningMergeAborted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
private void ClearExternalMergeConflict(string planningTaskId)
{
_externalMergeConflicts.Remove(planningTaskId);
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
}
/// <summary>Re-syncs the external-merge banner from the worker on (re)connect — the
/// one-shot PlanningMergeConflict broadcast isn't replayed after a UI restart, so this is
/// the recovery path. The worker checks MERGE_HEAD before reporting a conflict as active,
/// so a session that died mid-merge without cleaning up doesn't leave a stale banner up.</summary>
private async Task RefreshExternalMergeConflictsAsync()
{
if (Worker is null) return;
IReadOnlyList<PlanningMergeConflictStateDto> active;
try { active = await Worker.GetActiveExternalPlanningMergeConflictsAsync(); }
catch { return; }
_externalMergeConflicts.Clear();
foreach (var c in active)
_externalMergeConflicts[c.PlanningTaskId] = c.SubtaskId;
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
}
[RelayCommand]
private Task OpenExternalMergeConflictAsync()
{
if (_externalMergeConflicts.Count == 0) return Task.CompletedTask;
var (planningTaskId, subtaskId) = _externalMergeConflicts.First();
return OpenPlanningConflictAsync(planningTaskId, subtaskId);
}
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
{
if (ConflictResolverFactory is null || Dialogs is null) return;
@@ -287,6 +339,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
};
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted;
Worker.PlanningCompletedEvent += OnPlanningMergeCompleted;
Worker.ConnectionRestoredEvent += () => _ = RefreshExternalMergeConflictsAsync();
Worker.PrimeFired += OnPrimeFired;
_clearTimer.Elapsed += (_, _) =>
{
+22
View File
@@ -258,5 +258,27 @@
</StackPanel>
</Border>
<!-- External merge popup card — bottom-left corner, while an MCP session owns an
unresolved unit-merge conflict. Does not auto-dismiss; stays until the merge ends. -->
<Border Grid.Row="0" Grid.RowSpan="3"
VerticalAlignment="Bottom" HorizontalAlignment="Left"
Margin="16,0,0,30"
ZIndex="100"
IsVisible="{Binding IsExternalMergeBannerVisible}"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource PeatBrush}"
BorderThickness="1"
CornerRadius="6"
Padding="14,10">
<StackPanel Spacing="8" Width="300">
<TextBlock Classes="body" Text="{loc:Tr shell.externalMerge.banner}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Classes="btn"
Content="{loc:Tr shell.externalMerge.open}"
Command="{Binding OpenExternalMergeConflictCommand}"/>
</StackPanel>
</StackPanel>
</Border>
</Grid>
</Window>
+4 -1
View File
@@ -423,7 +423,10 @@ public sealed class ExternalMcpService
if (hasChildren)
{
await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken);
// externallyDriven: true — this call came from an MCP session, not the UI's
// Approve button. A unit-merge conflict must not auto-open the in-app resolver;
// the driving session resolves it via continue_merge/abort_merge instead.
await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken, externallyDriven: true);
var parentDone = (await _tasks.GetByIdAsync(taskId, cancellationToken))!.Status == TaskStatus.Done;
mergeStatus = parentDone ? TaskMergeService.StatusMerged : TaskMergeService.StatusConflict;
if (!parentDone)
+3 -2
View File
@@ -55,8 +55,9 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
public Task PlanningSubtaskMerged(string planningTaskId, string subtaskId) =>
_hub.Clients.All.SendAsync("PlanningSubtaskMerged", planningTaskId, subtaskId);
public Task PlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files) =>
_hub.Clients.All.SendAsync("PlanningMergeConflict", planningTaskId, subtaskId, files);
public Task PlanningMergeConflict(
string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven) =>
_hub.Clients.All.SendAsync("PlanningMergeConflict", planningTaskId, subtaskId, files, externallyDriven);
public Task PlanningMergeAborted(string planningTaskId) =>
_hub.Clients.All.SendAsync("PlanningMergeAborted", planningTaskId);
+12
View File
@@ -76,6 +76,7 @@ public record WorktreeOverviewDto(
bool PathExistsOnDisk);
public record ForceRemoveResultDto(bool Removed, string? Reason);
public record PlanningMergeConflictStateDto(string PlanningTaskId, string SubtaskId);
public record PendingQuestionDto(string TaskId, string QuestionId, string Question);
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
@@ -916,6 +917,17 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
}
/// <summary>Unit merges currently paused on a conflict that an MCP session (not the UI)
/// started. The UI calls this on (re)connect to recover the "don't auto-open the resolver"
/// banner after a restart, since the one-shot PlanningMergeConflict broadcast isn't replayed.</summary>
public async Task<List<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflicts()
{
var conflicts = await _planningMergeOrchestrator.GetActiveExternalConflictsAsync(CancellationToken.None);
return conflicts
.Select(c => new PlanningMergeConflictStateDto(c.PlanningTaskId, c.SubtaskId))
.ToList();
}
public async Task<List<PrimeScheduleDto>> ListPrimeSchedules()
{
using var ctx = _dbFactory.CreateDbContext();
@@ -10,6 +10,9 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
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
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
@@ -25,6 +28,11 @@ public sealed class PlanningMergeOrchestrator
public required string TargetBranch { get; init; }
public required Queue<string> RemainingSubtaskIds { get; init; }
public required bool IsPlanning { get; init; }
public required string WorkingDir { get; init; }
/// <summary>True when this unit merge was started by an MCP tool call (e.g. review_task
/// from a running Claude session) rather than a direct UI action. Conflicts on such a
/// merge must not auto-pop the in-app resolver — the driving session owns resolution.</summary>
public required bool ExternallyDriven { get; init; }
public string? CurrentSubtaskId { get; set; }
}
@@ -48,7 +56,8 @@ public sealed class PlanningMergeOrchestrator
_logger = logger;
}
public async Task StartAsync(string parentTaskId, string targetBranch, CancellationToken ct)
public async Task StartAsync(
string parentTaskId, string targetBranch, CancellationToken ct, bool externallyDriven = false)
{
string workingDir;
List<TaskEntity> children;
@@ -100,7 +109,14 @@ public sealed class PlanningMergeOrchestrator
var queue = new Queue<string>(idsToMerge);
var state = new State { TargetBranch = targetBranch, RemainingSubtaskIds = queue, IsPlanning = isPlanning };
var state = new State
{
TargetBranch = targetBranch,
RemainingSubtaskIds = queue,
IsPlanning = isPlanning,
WorkingDir = workingDir,
ExternallyDriven = externallyDriven,
};
if (!_states.TryAdd(parentTaskId, state))
throw new InvalidOperationException($"Merge already in progress for {parentTaskId}.");
@@ -112,6 +128,23 @@ public sealed class PlanningMergeOrchestrator
public bool HasActiveMerge(string parentTaskId) =>
_states.TryGetValue(parentTaskId, out var s) && s.CurrentSubtaskId is not null;
/// <summary>Externally-driven unit merges currently paused on a conflict, so the UI can
/// recover this on reconnect instead of relying solely on the one-shot broadcast. Checked
/// against <see cref="GitService.IsMidMergeAsync"/> rather than trusting the in-memory flag
/// alone, so a stale entry (e.g. the repo was already resolved through another path) never
/// reports an external merge that no longer exists.</summary>
public async Task<IReadOnlyList<ExternalPlanningMergeConflict>> GetActiveExternalConflictsAsync(CancellationToken ct)
{
var result = new List<ExternalPlanningMergeConflict>();
foreach (var (planningTaskId, state) in _states)
{
if (!state.ExternallyDriven || state.CurrentSubtaskId is null) continue;
if (!await _git.IsMidMergeAsync(state.WorkingDir, ct)) continue;
result.Add(new ExternalPlanningMergeConflict(planningTaskId, state.CurrentSubtaskId));
}
return result;
}
public async Task ContinueAsync(string planningTaskId, CancellationToken ct)
{
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
@@ -123,7 +156,7 @@ public sealed class PlanningMergeOrchestrator
if (result.Status == TaskMergeService.StatusConflict)
{
await _broadcaster.PlanningMergeConflict(planningTaskId, current, result.ConflictFiles);
await _broadcaster.PlanningMergeConflict(planningTaskId, current, result.ConflictFiles, state.ExternallyDriven);
return;
}
@@ -200,7 +233,8 @@ public sealed class PlanningMergeOrchestrator
if (result.Status == TaskMergeService.StatusConflict)
{
await _broadcaster.PlanningMergeConflict(planningTaskId, subtaskId, result.ConflictFiles);
await _broadcaster.PlanningMergeConflict(
planningTaskId, subtaskId, result.ConflictFiles, state.ExternallyDriven);
keepState = true;
return;
}