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
@@ -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;
}