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
@@ -245,6 +245,98 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None));
}
// ─── externallyDriven (MCP-driven merges must not auto-open the UI resolver) ────────────
[Fact]
public async Task StartAsync_ExternallyDriven_ConflictBroadcastFlagsExternallyDriven()
{
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, _, _, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
var (orch, spy) = BuildOrchestrator(db);
await orch.StartAsync(parentId, "main", CancellationToken.None, externallyDriven: true);
var conflictCall = Assert.Single(spy, c => c.Method == "PlanningMergeConflict");
Assert.True((bool)conflictCall.Args[3]!);
}
[Fact]
public async Task StartAsync_DefaultNotExternallyDriven_ConflictBroadcastFlagsFalse()
{
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, _, _, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
var (orch, spy) = BuildOrchestrator(db);
await orch.StartAsync(parentId, "main", CancellationToken.None);
var conflictCall = Assert.Single(spy, c => c.Method == "PlanningMergeConflict");
Assert.False((bool)conflictCall.Args[3]!);
}
[Fact]
public async Task GetActiveExternalConflictsAsync_ExternallyDrivenAndMidMerge_ReturnsEntry()
{
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, _, subB, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
var (orch, _) = BuildOrchestrator(db);
await orch.StartAsync(parentId, "main", CancellationToken.None, externallyDriven: true);
var active = await orch.GetActiveExternalConflictsAsync(CancellationToken.None);
var entry = Assert.Single(active);
Assert.Equal(parentId, entry.PlanningTaskId);
Assert.Equal(subB, entry.SubtaskId);
}
[Fact]
public async Task GetActiveExternalConflictsAsync_UiDriven_ReturnsEmpty()
{
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, _, _, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
var (orch, _) = BuildOrchestrator(db);
await orch.StartAsync(parentId, "main", CancellationToken.None); // externallyDriven defaults false
var active = await orch.GetActiveExternalConflictsAsync(CancellationToken.None);
Assert.Empty(active);
}
/// <summary>
/// The in-memory ExternallyDriven flag alone must never be trusted: if the repo was already
/// taken out of mid-merge through some other path (e.g. the driving Claude session died and
/// something else ran `git merge --abort` directly, without going through AbortAsync), the
/// query must not report a phantom external conflict. Proves the MERGE_HEAD coupling.
/// </summary>
[Fact]
public async Task GetActiveExternalConflictsAsync_StaleStateRepoNoLongerMidMerge_SelfHeals()
{
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, _, _, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
var (orch, _) = BuildOrchestrator(db);
await orch.StartAsync(parentId, "main", CancellationToken.None, externallyDriven: true);
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
var active = await orch.GetActiveExternalConflictsAsync(CancellationToken.None);
Assert.Empty(active);
}
// ─── Stateless abort (post-restart recovery) ───────────────────────────
/// <summary>
@@ -109,7 +109,7 @@ sealed class FakeWorkerClient : IWorkerClient
public event Action<string, bool, string?>? RefineFinishedEvent;
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;
public event Action<PrimeFiredEvent>? PrimeFired;
@@ -121,6 +121,8 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) => Task.FromResult<CombinedDiffResultDto?>(null);
public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
public Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
=> Task.FromResult<IReadOnlyList<PlanningMergeConflictStateDto>>(Array.Empty<PlanningMergeConflictStateDto>());
public Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult<AppSettingsDto?>(null);
public Task UpdateAppSettingsAsync(AppSettingsDto dto) => Task.CompletedTask;