Merge branch 'claudedo/75514ee68371402b814638290af47cca'

This commit is contained in:
mika kuns
2026-08-06 12:11:38 +02:00
15 changed files with 379 additions and 14 deletions
+4 -1
View File
@@ -529,7 +529,10 @@ public sealed class ExternalMcpService
// pre-merge worktree/commit range at this point, so "did it contribute anything"
// reflects the review range the reviewer is about to approve.
emptyChildren = await GetEmptyDoneChildrenAsync(taskId, cancellationToken);
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
@@ -80,6 +80,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);
@@ -939,6 +940,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;
}