Merge branch 'claudedo/75514ee68371402b814638290af47cca'
This commit is contained in:
@@ -138,6 +138,23 @@ mid-merge conflicts **without re-starting the merge** and routes continue/abort
|
|||||||
`ContinuePlanningMerge` / `AbortPlanningMerge`, so a unit-merge conflict re-opens the editor
|
`ContinuePlanningMerge` / `AbortPlanningMerge`, so a unit-merge conflict re-opens the editor
|
||||||
per subtask via the `PlanningMergeConflict` broadcast.
|
per subtask via the `PlanningMergeConflict` broadcast.
|
||||||
|
|
||||||
|
**Except when an MCP session is driving the merge.** `PlanningMergeOrchestrator.StartAsync` takes
|
||||||
|
an `externallyDriven` bool (default `false`; `ExternalMcpService.review_task`'s parent-with-children
|
||||||
|
path passes `true`, since a running Claude session — not the UI — will resolve conflicts via
|
||||||
|
`continue_merge`/`abort_merge`). The flag rides along on the `PlanningMergeConflict` broadcast
|
||||||
|
(4th arg); `IslandsShellViewModel.OnPlanningMergeConflict` only auto-opens the resolver when it's
|
||||||
|
`false` — otherwise it shows a persistent, non-auto-dismissing banner
|
||||||
|
(`IsExternalMergeBannerVisible`) with a manual "Open resolver" button, cleared on
|
||||||
|
`PlanningMergeAborted`/`PlanningCompleted`. This exists because two parties (a human and the
|
||||||
|
driving session) could otherwise end up editing the same shared checkout at once.
|
||||||
|
`PlanningMergeOrchestrator.GetActiveExternalConflictsAsync` (hub: `GetActiveExternalPlanningMergeConflicts`)
|
||||||
|
re-derives this state by checking `GitService.IsMidMergeAsync` rather than trusting the in-memory
|
||||||
|
flag alone, so a UI restart mid-merge (or a stale entry left behind if something resolved the
|
||||||
|
repo outside the normal Continue/Abort path) can't show a phantom banner — the Ui calls it on
|
||||||
|
`ConnectionRestoredEvent`. The childless single-task conflict path
|
||||||
|
(`TaskMergeService.ApproveAndMergeAsync`) has no equivalent broadcast — it only sends the generic
|
||||||
|
`TaskUpdated` — so it never auto-opened the resolver and needed no change.
|
||||||
|
|
||||||
### View
|
### View
|
||||||
|
|
||||||
Three **AvaloniaEdit** panes showing the whole file: MAIN/ours (read-only) | editable Result |
|
Three **AvaloniaEdit** panes showing the whole file: MAIN/ours (read-only) | editable Result |
|
||||||
|
|||||||
@@ -632,6 +632,10 @@
|
|||||||
},
|
},
|
||||||
"staleWorker": {
|
"staleWorker": {
|
||||||
"message": "Der Worker läuft auf einem älteren Stand als der gemergte main-Branch dieses Repos — Neustart nötig, damit gemergte Änderungen wirken."
|
"message": "Der Worker läuft auf einem älteren Stand als der gemergte main-Branch dieses Repos — Neustart nötig, damit gemergte Änderungen wirken."
|
||||||
|
},
|
||||||
|
"externalMerge": {
|
||||||
|
"banner": "Eine Claude-Session löst gerade einen Merge-Konflikt auf — bitte keine Dateien im Repository bearbeiten.",
|
||||||
|
"open": "Resolver öffnen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vm": {
|
"vm": {
|
||||||
|
|||||||
@@ -632,6 +632,10 @@
|
|||||||
},
|
},
|
||||||
"staleWorker": {
|
"staleWorker": {
|
||||||
"message": "The worker is running an older build than this repo's merged main — restart it so your merged changes take effect."
|
"message": "The worker is running an older build than this repo's merged main — restart it so your merged changes take effect."
|
||||||
|
},
|
||||||
|
"externalMerge": {
|
||||||
|
"banner": "A Claude session is resolving a merge conflict — don't edit files in the repository.",
|
||||||
|
"open": "Open resolver"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vm": {
|
"vm": {
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
|
|
||||||
event Action<string, string>? PlanningMergeStartedEvent;
|
event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
event Action<string, string>? PlanningSubtaskMergedEvent;
|
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>? PlanningMergeAbortedEvent;
|
||||||
event Action<string>? PlanningCompletedEvent;
|
event Action<string>? PlanningCompletedEvent;
|
||||||
|
|
||||||
@@ -117,6 +120,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
|
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
|
||||||
Task ContinuePlanningMergeAsync(string planningTaskId);
|
Task ContinuePlanningMergeAsync(string planningTaskId);
|
||||||
Task AbortPlanningMergeAsync(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 QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
|
||||||
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
|
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
|
||||||
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
|
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
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>? PlanningMergeAbortedEvent;
|
||||||
public event Action<string>? PlanningCompletedEvent;
|
public event Action<string>? PlanningCompletedEvent;
|
||||||
|
|
||||||
@@ -182,9 +182,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
Dispatcher.UIThread.Post(() => PlanningSubtaskMergedEvent?.Invoke(planningTaskId, subtaskId));
|
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 =>
|
_hub.On<string>("PlanningMergeAborted", planningTaskId =>
|
||||||
@@ -581,6 +581,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
|
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)
|
public async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
|
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
|
||||||
@@ -695,6 +699,7 @@ public sealed record LaunchSpec(
|
|||||||
IReadOnlyDictionary<string, string> Env);
|
IReadOnlyDictionary<string, string> Env);
|
||||||
|
|
||||||
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
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 PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||||
public sealed record WorkerBuildInfoDto(string? BuildSha);
|
public sealed record WorkerBuildInfoDto(string? BuildSha);
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,13 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
// only — see RefreshStaleWorkerCheckAsync.
|
// only — see RefreshStaleWorkerCheckAsync.
|
||||||
[ObservableProperty] private bool _isStaleWorkerBannerVisible;
|
[ObservableProperty] private bool _isStaleWorkerBannerVisible;
|
||||||
|
|
||||||
|
// 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]
|
[ObservableProperty]
|
||||||
private double _windowWidth = 1280;
|
private double _windowWidth = 1280;
|
||||||
|
|
||||||
@@ -185,13 +192,59 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
_primeStatusTimer.Start();
|
_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).
|
// 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.
|
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
|
||||||
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
|
_ = 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)
|
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
|
||||||
{
|
{
|
||||||
if (ConflictResolverFactory is null || Dialogs is null) return;
|
if (ConflictResolverFactory is null || Dialogs is null) return;
|
||||||
@@ -298,6 +351,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
||||||
Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync();
|
Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync();
|
||||||
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
|
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
|
||||||
|
Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted;
|
||||||
|
Worker.PlanningCompletedEvent += OnPlanningMergeCompleted;
|
||||||
|
Worker.ConnectionRestoredEvent += () => _ = RefreshExternalMergeConflictsAsync();
|
||||||
Worker.PrimeFired += OnPrimeFired;
|
Worker.PrimeFired += OnPrimeFired;
|
||||||
_clearTimer.Elapsed += (_, _) =>
|
_clearTimer.Elapsed += (_, _) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -281,5 +281,27 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
+4
-1
@@ -529,7 +529,10 @@ public sealed class ExternalMcpService
|
|||||||
// pre-merge worktree/commit range at this point, so "did it contribute anything"
|
// pre-merge worktree/commit range at this point, so "did it contribute anything"
|
||||||
// reflects the review range the reviewer is about to approve.
|
// reflects the review range the reviewer is about to approve.
|
||||||
emptyChildren = await GetEmptyDoneChildrenAsync(taskId, cancellationToken);
|
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;
|
var parentDone = (await _tasks.GetByIdAsync(taskId, cancellationToken))!.Status == TaskStatus.Done;
|
||||||
mergeStatus = parentDone ? TaskMergeService.StatusMerged : TaskMergeService.StatusConflict;
|
mergeStatus = parentDone ? TaskMergeService.StatusMerged : TaskMergeService.StatusConflict;
|
||||||
if (!parentDone)
|
if (!parentDone)
|
||||||
|
|||||||
@@ -55,8 +55,9 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
|||||||
public Task PlanningSubtaskMerged(string planningTaskId, string subtaskId) =>
|
public Task PlanningSubtaskMerged(string planningTaskId, string subtaskId) =>
|
||||||
_hub.Clients.All.SendAsync("PlanningSubtaskMerged", planningTaskId, subtaskId);
|
_hub.Clients.All.SendAsync("PlanningSubtaskMerged", planningTaskId, subtaskId);
|
||||||
|
|
||||||
public Task PlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files) =>
|
public Task PlanningMergeConflict(
|
||||||
_hub.Clients.All.SendAsync("PlanningMergeConflict", planningTaskId, subtaskId, files);
|
string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven) =>
|
||||||
|
_hub.Clients.All.SendAsync("PlanningMergeConflict", planningTaskId, subtaskId, files, externallyDriven);
|
||||||
|
|
||||||
public Task PlanningMergeAborted(string planningTaskId) =>
|
public Task PlanningMergeAborted(string planningTaskId) =>
|
||||||
_hub.Clients.All.SendAsync("PlanningMergeAborted", planningTaskId);
|
_hub.Clients.All.SendAsync("PlanningMergeAborted", planningTaskId);
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ public record WorktreeOverviewDto(
|
|||||||
bool PathExistsOnDisk);
|
bool PathExistsOnDisk);
|
||||||
|
|
||||||
public record ForceRemoveResultDto(bool Removed, string? Reason);
|
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 PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
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); }
|
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()
|
public async Task<List<PrimeScheduleDto>> ListPrimeSchedules()
|
||||||
{
|
{
|
||||||
using var ctx = _dbFactory.CreateDbContext();
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|||||||
|
|
||||||
namespace ClaudeDo.Worker.Planning;
|
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
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
@@ -25,6 +28,11 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
public required string TargetBranch { get; init; }
|
public required string TargetBranch { get; init; }
|
||||||
public required Queue<string> RemainingSubtaskIds { get; init; }
|
public required Queue<string> RemainingSubtaskIds { get; init; }
|
||||||
public required bool IsPlanning { 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; }
|
public string? CurrentSubtaskId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +56,8 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
_logger = logger;
|
_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;
|
string workingDir;
|
||||||
List<TaskEntity> children;
|
List<TaskEntity> children;
|
||||||
@@ -100,7 +109,14 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
|
|
||||||
var queue = new Queue<string>(idsToMerge);
|
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))
|
if (!_states.TryAdd(parentTaskId, state))
|
||||||
throw new InvalidOperationException($"Merge already in progress for {parentTaskId}.");
|
throw new InvalidOperationException($"Merge already in progress for {parentTaskId}.");
|
||||||
|
|
||||||
@@ -112,6 +128,23 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
public bool HasActiveMerge(string parentTaskId) =>
|
public bool HasActiveMerge(string parentTaskId) =>
|
||||||
_states.TryGetValue(parentTaskId, out var s) && s.CurrentSubtaskId is not null;
|
_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)
|
public async Task ContinueAsync(string planningTaskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
|
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
|
||||||
@@ -123,7 +156,7 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
|
|
||||||
if (result.Status == TaskMergeService.StatusConflict)
|
if (result.Status == TaskMergeService.StatusConflict)
|
||||||
{
|
{
|
||||||
await _broadcaster.PlanningMergeConflict(planningTaskId, current, result.ConflictFiles);
|
await _broadcaster.PlanningMergeConflict(planningTaskId, current, result.ConflictFiles, state.ExternallyDriven);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +233,8 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
|
|
||||||
if (result.Status == TaskMergeService.StatusConflict)
|
if (result.Status == TaskMergeService.StatusConflict)
|
||||||
{
|
{
|
||||||
await _broadcaster.PlanningMergeConflict(planningTaskId, subtaskId, result.ConflictFiles);
|
await _broadcaster.PlanningMergeConflict(
|
||||||
|
planningTaskId, subtaskId, result.ConflictFiles, state.ExternallyDriven);
|
||||||
keepState = true;
|
keepState = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests;
|
||||||
|
|
||||||
|
// Covers the MCP-driven-merge popup fix: a unit-merge conflict started by an MCP session
|
||||||
|
// (review_task/continue_merge) must not auto-open the in-app resolver — two parties editing the
|
||||||
|
// same shared checkout at once is how the original bug manifested. The UI-driven path (a human
|
||||||
|
// clicking Approve) keeps opening the resolver as before.
|
||||||
|
public class IslandsShellViewModelExternalMergeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ExternallyDriven_ShowsBanner()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
|
||||||
|
vm.OnPlanningMergeConflict("plan1", "sub1", new[] { "file.txt" }, externallyDriven: true);
|
||||||
|
|
||||||
|
Assert.True(vm.IsExternalMergeBannerVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UiDriven_DoesNotShowExternalBanner()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
|
||||||
|
vm.OnPlanningMergeConflict("plan1", "sub1", new[] { "file.txt" }, externallyDriven: false);
|
||||||
|
|
||||||
|
Assert.False(vm.IsExternalMergeBannerVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Aborted_ClearsBanner()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), externallyDriven: true);
|
||||||
|
Assert.True(vm.IsExternalMergeBannerVisible);
|
||||||
|
|
||||||
|
vm.OnPlanningMergeAborted("plan1");
|
||||||
|
|
||||||
|
Assert.False(vm.IsExternalMergeBannerVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Completed_ClearsBanner()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), externallyDriven: true);
|
||||||
|
|
||||||
|
vm.OnPlanningMergeCompleted("plan1");
|
||||||
|
|
||||||
|
Assert.False(vm.IsExternalMergeBannerVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnrelatedPlanningTaskEnding_LeavesBannerVisible()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), externallyDriven: true);
|
||||||
|
|
||||||
|
vm.OnPlanningMergeCompleted("some-other-plan");
|
||||||
|
|
||||||
|
Assert.True(vm.IsExternalMergeBannerVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LaterConflictOnSamePlanningTask_UpdatesInPlaceWithoutDuplicating()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
vm.OnPlanningMergeConflict("plan1", "subA", Array.Empty<string>(), externallyDriven: true);
|
||||||
|
vm.OnPlanningMergeConflict("plan1", "subB", Array.Empty<string>(), externallyDriven: true);
|
||||||
|
Assert.True(vm.IsExternalMergeBannerVisible);
|
||||||
|
|
||||||
|
// A single terminal event for that planning task clears it entirely — proves the second
|
||||||
|
// conflict updated the same entry rather than piling up a second one.
|
||||||
|
vm.OnPlanningMergeCompleted("plan1");
|
||||||
|
|
||||||
|
Assert.False(vm.IsExternalMergeBannerVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenExternalMergeConflictCommand_NoPendingConflict_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
|
||||||
|
await vm.OpenExternalMergeConflictCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenExternalMergeConflictCommand_NoDialogsWired_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), externallyDriven: true);
|
||||||
|
|
||||||
|
// Dialogs/ConflictResolverFactory aren't wired in the test-only ctor — a deliberate
|
||||||
|
// click must degrade gracefully rather than throw, same as the existing UI-driven path.
|
||||||
|
await vm.OpenExternalMergeConflictCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
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>? PlanningMergeAbortedEvent;
|
||||||
public event Action<string>? PlanningCompletedEvent;
|
public event Action<string>? PlanningCompletedEvent;
|
||||||
public event Action<PrimeFiredEvent>? PrimeFired;
|
public event Action<PrimeFiredEvent>? PrimeFired;
|
||||||
@@ -53,6 +53,10 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public void RaiseTaskQuestionAsked(string taskId, string questionId, string question) => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question);
|
public void RaiseTaskQuestionAsked(string taskId, string questionId, string question) => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question);
|
||||||
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
|
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
|
||||||
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds) => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds);
|
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds) => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds);
|
||||||
|
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
|
||||||
|
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
|
||||||
|
public void RaisePlanningMergeAborted(string planningTaskId) => PlanningMergeAbortedEvent?.Invoke(planningTaskId);
|
||||||
|
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
|
||||||
|
|
||||||
public void RaisePrepStarted() => PrepStartedEvent?.Invoke();
|
public void RaisePrepStarted() => PrepStartedEvent?.Invoke();
|
||||||
public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line);
|
public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line);
|
||||||
@@ -129,6 +133,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
=> Task.FromResult<CombinedDiffResultDto?>(null);
|
=> Task.FromResult<CombinedDiffResultDto?>(null);
|
||||||
public virtual Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
public virtual Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||||
public virtual Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
public virtual Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||||
|
public virtual Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
|
||||||
|
=> Task.FromResult<IReadOnlyList<PlanningMergeConflictStateDto>>(Array.Empty<PlanningMergeConflictStateDto>());
|
||||||
public virtual Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
public virtual Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public virtual Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end) => Task.FromResult<string?>(null);
|
public virtual Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end) => Task.FromResult<string?>(null);
|
||||||
public virtual Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end) => Task.FromResult("");
|
public virtual Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end) => Task.FromResult("");
|
||||||
|
|||||||
@@ -245,6 +245,98 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
|
|||||||
Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None));
|
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) ───────────────────────────
|
// ─── Stateless abort (post-restart recovery) ───────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
|||||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
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>? PlanningMergeAbortedEvent;
|
||||||
public event Action<string>? PlanningCompletedEvent;
|
public event Action<string>? PlanningCompletedEvent;
|
||||||
public event Action<PrimeFiredEvent>? PrimeFired;
|
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<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) => Task.FromResult<CombinedDiffResultDto?>(null);
|
||||||
public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||||
public Task AbortPlanningMergeAsync(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<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult<AppSettingsDto?>(null);
|
||||||
public Task UpdateAppSettingsAsync(AppSettingsDto dto) => Task.CompletedTask;
|
public Task UpdateAppSettingsAsync(AppSettingsDto dto) => Task.CompletedTask;
|
||||||
|
|||||||
Reference in New Issue
Block a user