TaskStateService.CancelAsync allowed cancelling a WaitingForReview task even while PlanningMergeOrchestrator was mid-drain on it: ApproveReview awaits the whole multi-subtask merge synchronously, so a concurrent CancelReview (UI or MCP) could flip the parent to Cancelled while the orchestrator kept merging children onto the target branch, then FinalizeParentDoneAsync would find the parent no longer WaitingForReview and give up - leaving the merged diffs stranded with no rollback. CancelAsync now rejects with a clear reason when HasActiveMerge(taskId) is true. TaskStateService can't take PlanningMergeOrchestrator as a direct constructor dependency (circular back to ITaskStateService), so it takes a lazily-resolved Func<IActiveMergeState> instead, mirroring the existing Func<ITaskStateService> cycle-break already used for PlanningChainCoordinator. UI polish: DetailsIslandViewModel.IsMergeDraining gates CancelReviewCommand's CanExecute (same shape as WorktreesOverviewModalViewModel.IsMerging), and the command's catch now raises ErrorReported instead of swallowing the rejection silently.
58 lines
1.9 KiB
C#
58 lines
1.9 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Queue;
|
|
using ClaudeDo.Worker.State;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Infrastructure;
|
|
|
|
/// Test-only helper that wires TaskStateService and PlanningChainCoordinator
|
|
/// against a shared DB factory, breaking the Func cycle between them.
|
|
public static class TaskStateServiceBuilder
|
|
{
|
|
public sealed record Built(
|
|
TaskStateService State,
|
|
PlanningChainCoordinator Chain,
|
|
CapturingHubContext Hub,
|
|
Func<int> WakeCount,
|
|
CountingQueueWaker Waker,
|
|
RunCancellationRegistry RunCancels);
|
|
|
|
public static Built Build(
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory, Func<IActiveMergeState>? mergeState = null)
|
|
{
|
|
var hub = new CapturingHubContext();
|
|
var broadcaster = new HubBroadcaster(hub);
|
|
var waker = new CountingQueueWaker();
|
|
var runCancels = new RunCancellationRegistry();
|
|
|
|
TaskStateService? state = null;
|
|
var chain = new PlanningChainCoordinator(dbFactory, () => state!);
|
|
state = new TaskStateService(
|
|
dbFactory,
|
|
broadcaster,
|
|
waker,
|
|
chain,
|
|
runCancels,
|
|
mergeState ?? (() => NoActiveMergeState.Instance),
|
|
NullLogger<TaskStateService>.Instance);
|
|
|
|
return new Built(state, chain, hub, () => waker.Count, waker, runCancels);
|
|
}
|
|
}
|
|
|
|
file sealed class NoActiveMergeState : IActiveMergeState
|
|
{
|
|
public static readonly NoActiveMergeState Instance = new();
|
|
public bool HasActiveMerge(string taskId) => false;
|
|
}
|
|
|
|
public sealed class CountingQueueWaker : IQueueWaker
|
|
{
|
|
private int _count;
|
|
public int Count => Volatile.Read(ref _count);
|
|
public void Wake() => Interlocked.Increment(ref _count);
|
|
}
|