Merge claudedo/973ea49ec0a642ea9b1ddcff9b71b6fe
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# Review, merge & conflict resolution
|
# Review, merge & conflict resolution
|
||||||
|
|
||||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||||
> Last verified against commit `20bce9b` (2026-08-06).
|
> Last verified against commit `0d1e3b9` (2026-08-06).
|
||||||
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External`
|
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External`
|
||||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||||
|
|
||||||
@@ -56,6 +56,31 @@ like any other `Done` child once the parent is approved. The MCP surface has no
|
|||||||
An empty branch is still mergeable by design (some tasks — e.g. an audit — legitimately produce
|
An empty branch is still mergeable by design (some tasks — e.g. an audit — legitimately produce
|
||||||
no diff); this is a visibility fix, not a merge gate.
|
no diff); this is a visibility fix, not a merge gate.
|
||||||
|
|
||||||
|
## Cancel is blocked while a unit merge is draining
|
||||||
|
|
||||||
|
`ApproveReview` on a task with children awaits `PlanningMergeOrchestrator.StartAsync` /
|
||||||
|
`DrainAsync` synchronously — the parent sits in `WaitingForReview` for the whole (potentially
|
||||||
|
minutes-long, one-child-at-a-time) drain. Without a guard, a concurrent `CancelReview` (UI or
|
||||||
|
`update_task_status`/`cancel_task` via MCP) could flip the parent to `Cancelled` mid-drain while
|
||||||
|
the orchestrator kept merging children's worktrees onto the target branch; `FinalizeParentDoneAsync`
|
||||||
|
then finds the parent no longer `WaitingForReview` and gives up, leaving the merged children's
|
||||||
|
diffs stranded with no rollback.
|
||||||
|
|
||||||
|
`TaskStateService.CancelAsync` now rejects with a `TransitionResult` reason whenever
|
||||||
|
`PlanningMergeOrchestrator.HasActiveMerge(taskId)` is true for the task being cancelled, before any
|
||||||
|
DB write. Cycle note: `TaskStateService` can't take a direct constructor dependency on
|
||||||
|
`PlanningMergeOrchestrator` (which itself depends on `ITaskStateService`), so it takes a lazily-resolved
|
||||||
|
`Func<IActiveMergeState>` instead — same cycle-breaking shape as the existing `Func<ITaskStateService>`
|
||||||
|
handed to `PlanningChainCoordinator`. `IActiveMergeState` (`Planning/Interfaces/`) is `PlanningMergeOrchestrator`'s
|
||||||
|
only public surface `TaskStateService` needs.
|
||||||
|
|
||||||
|
The UI mirrors this as polish: `DetailsIslandViewModel.IsMergeDraining` (set on
|
||||||
|
`PlanningMergeStartedEvent`, cleared on `PlanningMergeAborted`/`PlanningCompleted` for the bound
|
||||||
|
task) gates `CancelReviewCommand`'s `CanExecute` — same shape as `WorktreesOverviewModalViewModel.IsMerging`
|
||||||
|
gating `CanMergeAll`. The worker-side guard remains the actual correctness fix; the button gate
|
||||||
|
just avoids inviting a click the worker would reject. `CancelReviewAsync`'s catch now raises
|
||||||
|
`ErrorReported` (→ shell `FlashFooterError`) instead of swallowing the rejection silently.
|
||||||
|
|
||||||
## Post-merge verify gate
|
## Post-merge verify gate
|
||||||
|
|
||||||
A list can set `ListConfigEntity.VerifyCommand` (List Settings modal → Verification).
|
A list can set `ListConfigEntity.VerifyCommand` (List Settings modal → Verification).
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private readonly Action<string, string, string, DateTime> _workerTaskFinishedHandler;
|
private readonly Action<string, string, string, DateTime> _workerTaskFinishedHandler;
|
||||||
private readonly Action<string> _workerWorktreeUpdatedHandler;
|
private readonly Action<string> _workerWorktreeUpdatedHandler;
|
||||||
private readonly Action<string> _workerTaskUpdatedHandler;
|
private readonly Action<string> _workerTaskUpdatedHandler;
|
||||||
|
private readonly Action<string, string> _workerPlanningMergeStartedHandler;
|
||||||
|
private readonly Action<string> _workerPlanningMergeAbortedHandler;
|
||||||
|
private readonly Action<string> _workerPlanningCompletedHandler;
|
||||||
|
|
||||||
[ObservableProperty] private bool _isNotesMode;
|
[ObservableProperty] private bool _isNotesMode;
|
||||||
[ObservableProperty] private bool _isPrepMode;
|
[ObservableProperty] private bool _isPrepMode;
|
||||||
@@ -351,6 +354,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||||
ContinueCommand.NotifyCanExecuteChanged();
|
ContinueCommand.NotifyCanExecuteChanged();
|
||||||
SendRoadblockReplyCommand.NotifyCanExecuteChanged();
|
SendRoadblockReplyCommand.NotifyCanExecuteChanged();
|
||||||
|
CancelReviewCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
_worker.PropertyChanged += _workerPropertyChangedHandler;
|
_worker.PropertyChanged += _workerPropertyChangedHandler;
|
||||||
@@ -392,6 +396,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
};
|
};
|
||||||
_worker.TaskUpdatedEvent += _workerTaskUpdatedHandler;
|
_worker.TaskUpdatedEvent += _workerTaskUpdatedHandler;
|
||||||
|
|
||||||
|
_workerPlanningMergeStartedHandler = (planningTaskId, _) =>
|
||||||
|
{
|
||||||
|
if (Task?.Id == planningTaskId) IsMergeDraining = true;
|
||||||
|
};
|
||||||
|
_worker.PlanningMergeStartedEvent += _workerPlanningMergeStartedHandler;
|
||||||
|
|
||||||
|
_workerPlanningMergeAbortedHandler = planningTaskId =>
|
||||||
|
{
|
||||||
|
if (Task?.Id == planningTaskId) IsMergeDraining = false;
|
||||||
|
};
|
||||||
|
_worker.PlanningMergeAbortedEvent += _workerPlanningMergeAbortedHandler;
|
||||||
|
|
||||||
|
_workerPlanningCompletedHandler = planningTaskId =>
|
||||||
|
{
|
||||||
|
if (Task?.Id == planningTaskId) IsMergeDraining = false;
|
||||||
|
};
|
||||||
|
_worker.PlanningCompletedEvent += _workerPlanningCompletedHandler;
|
||||||
|
|
||||||
ChildOutcomes.CollectionChanged += (_, _) =>
|
ChildOutcomes.CollectionChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
|
Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
|
||||||
@@ -409,6 +431,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
|
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
|
||||||
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
|
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
|
||||||
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
|
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
|
||||||
|
_worker.PlanningMergeStartedEvent -= _workerPlanningMergeStartedHandler;
|
||||||
|
_worker.PlanningMergeAbortedEvent -= _workerPlanningMergeAbortedHandler;
|
||||||
|
_worker.PlanningCompletedEvent -= _workerPlanningCompletedHandler;
|
||||||
AgentSettings.Dispose();
|
AgentSettings.Dispose();
|
||||||
Prep.Dispose();
|
Prep.Dispose();
|
||||||
}
|
}
|
||||||
@@ -438,6 +463,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
DequeueCommand.NotifyCanExecuteChanged();
|
DequeueCommand.NotifyCanExecuteChanged();
|
||||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||||
ContinueCommand.NotifyCanExecuteChanged();
|
ContinueCommand.NotifyCanExecuteChanged();
|
||||||
|
CancelReviewCommand.NotifyCanExecuteChanged();
|
||||||
// A state change means a new run/review cycle: the diff must be
|
// A state change means a new run/review cycle: the diff must be
|
||||||
// re-inspected before merge can be approved again.
|
// re-inspected before merge can be approved again.
|
||||||
ReviewDiffViewed = false;
|
ReviewDiffViewed = false;
|
||||||
@@ -547,6 +573,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
OnPropertyChanged(nameof(TaskIdBadge));
|
OnPropertyChanged(nameof(TaskIdBadge));
|
||||||
Monitor.Reset();
|
Monitor.Reset();
|
||||||
RoadblockReplyDraft = string.Empty;
|
RoadblockReplyDraft = string.Empty;
|
||||||
|
IsMergeDraining = false;
|
||||||
Subtasks.Clear();
|
Subtasks.Clear();
|
||||||
ChildOutcomes.Clear();
|
ChildOutcomes.Clear();
|
||||||
Attachments.Clear();
|
Attachments.Clear();
|
||||||
@@ -1200,13 +1227,27 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
catch { /* stale review action; broadcast reconciles */ }
|
catch { /* stale review action; broadcast reconciles */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
// True while a unit merge is actively draining this task's subtasks onto the target
|
||||||
|
// branch — set from PlanningMergeStarted, cleared on PlanningMergeAborted/Completed.
|
||||||
|
// Mirrors the worker-side guard in TaskStateService.CancelAsync (the real correctness
|
||||||
|
// fix); this just keeps the button from inviting a click the worker will reject anyway.
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(CancelReviewCommand))]
|
||||||
|
private bool _isMergeDraining;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanCancelReview))]
|
||||||
private async System.Threading.Tasks.Task CancelReviewAsync()
|
private async System.Threading.Tasks.Task CancelReviewAsync()
|
||||||
{
|
{
|
||||||
if (Task is null || !_worker.IsConnected) return;
|
if (Task is null || !_worker.IsConnected) return;
|
||||||
try { await _worker.CancelReviewAsync(Task.Id); }
|
try { await _worker.CancelReviewAsync(Task.Id); }
|
||||||
catch { /* stale review action; broadcast reconciles */ }
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ErrorReported?.Invoke(ex.Message);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanCancelReview() =>
|
||||||
|
Task != null && _worker.IsConnected && !IsMergeDraining;
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task ReloadAttachmentsAsync()
|
private async System.Threading.Tasks.Task ReloadAttachmentsAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
// Lets TaskStateService guard CancelAsync against an in-progress unit merge without a
|
||||||
|
// hard constructor cycle back to PlanningMergeOrchestrator (which itself depends on
|
||||||
|
// ITaskStateService) — TaskStateService takes a Func<IActiveMergeState> instead, resolved
|
||||||
|
// lazily so both singletons can finish constructing before either calls into the other.
|
||||||
|
public interface IActiveMergeState
|
||||||
|
{
|
||||||
|
bool HasActiveMerge(string taskId);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ namespace ClaudeDo.Worker.Planning;
|
|||||||
/// <summary>A unit-merge conflict currently paused, driven by an MCP session rather than the UI.</summary>
|
/// <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 record ExternalPlanningMergeConflict(string PlanningTaskId, string SubtaskId);
|
||||||
|
|
||||||
public sealed class PlanningMergeOrchestrator
|
public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly TaskMergeService _merge;
|
private readonly TaskMergeService _merge;
|
||||||
|
|||||||
@@ -101,12 +101,16 @@ builder.Services.AddSingleton<IQueuePicker, QueuePicker>();
|
|||||||
builder.Services.AddSingleton<RunCancellationRegistry>();
|
builder.Services.AddSingleton<RunCancellationRegistry>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<Func<ITaskStateService>>(sp => () => sp.GetRequiredService<ITaskStateService>());
|
builder.Services.AddSingleton<Func<ITaskStateService>>(sp => () => sp.GetRequiredService<ITaskStateService>());
|
||||||
|
// PlanningMergeOrchestrator itself depends on ITaskStateService, so TaskStateService can only
|
||||||
|
// reach it lazily (Func<IActiveMergeState>) — same cycle-breaking shape as the Func above.
|
||||||
|
builder.Services.AddSingleton<Func<IActiveMergeState>>(sp => () => sp.GetRequiredService<PlanningMergeOrchestrator>());
|
||||||
builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
sp.GetRequiredService<HubBroadcaster>(),
|
sp.GetRequiredService<HubBroadcaster>(),
|
||||||
sp.GetRequiredService<IQueueWaker>(),
|
sp.GetRequiredService<IQueueWaker>(),
|
||||||
sp.GetRequiredService<PlanningChainCoordinator>(),
|
sp.GetRequiredService<PlanningChainCoordinator>(),
|
||||||
sp.GetRequiredService<RunCancellationRegistry>(),
|
sp.GetRequiredService<RunCancellationRegistry>(),
|
||||||
|
sp.GetRequiredService<Func<IActiveMergeState>>(),
|
||||||
sp.GetRequiredService<ILogger<TaskStateService>>()));
|
sp.GetRequiredService<ILogger<TaskStateService>>()));
|
||||||
|
|
||||||
// Agent file management.
|
// Agent file management.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
private readonly IQueueWaker _waker;
|
private readonly IQueueWaker _waker;
|
||||||
private readonly PlanningChainCoordinator _chain;
|
private readonly PlanningChainCoordinator _chain;
|
||||||
private readonly RunCancellationRegistry _runCancels;
|
private readonly RunCancellationRegistry _runCancels;
|
||||||
|
private readonly Func<IActiveMergeState> _mergeState;
|
||||||
private readonly ILogger<TaskStateService> _logger;
|
private readonly ILogger<TaskStateService> _logger;
|
||||||
|
|
||||||
public TaskStateService(
|
public TaskStateService(
|
||||||
@@ -24,6 +25,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
IQueueWaker waker,
|
IQueueWaker waker,
|
||||||
PlanningChainCoordinator chain,
|
PlanningChainCoordinator chain,
|
||||||
RunCancellationRegistry runCancels,
|
RunCancellationRegistry runCancels,
|
||||||
|
Func<IActiveMergeState> mergeState,
|
||||||
ILogger<TaskStateService> logger)
|
ILogger<TaskStateService> logger)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -31,6 +33,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
_waker = waker;
|
_waker = waker;
|
||||||
_chain = chain;
|
_chain = chain;
|
||||||
_runCancels = runCancels;
|
_runCancels = runCancels;
|
||||||
|
_mergeState = mergeState;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +258,14 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
public async Task<TransitionResult> CancelAsync(
|
public async Task<TransitionResult> CancelAsync(
|
||||||
string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false)
|
string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false)
|
||||||
{
|
{
|
||||||
|
// A unit merge drains its children's worktrees onto the target branch while the parent
|
||||||
|
// sits in WaitingForReview for the whole (potentially minutes-long) drain. Cancelling out
|
||||||
|
// from under it would flip the parent to Cancelled while the orchestrator keeps merging —
|
||||||
|
// FinalizeParentDoneAsync then finds the parent no longer WaitingForReview and gives up,
|
||||||
|
// leaving the merged children's diffs stranded with no way to roll them back.
|
||||||
|
if (_mergeState().HasActiveMerge(taskId))
|
||||||
|
return new TransitionResult(false, "A merge is in progress for this task; wait for it to finish before cancelling.");
|
||||||
|
|
||||||
List<string> cancelledChildIds;
|
List<string> cancelledChildIds;
|
||||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
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)
|
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
|
||||||
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
|
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
|
||||||
|
public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch);
|
||||||
public void RaisePlanningMergeAborted(string planningTaskId) => PlanningMergeAbortedEvent?.Invoke(planningTaskId);
|
public void RaisePlanningMergeAborted(string planningTaskId) => PlanningMergeAbortedEvent?.Invoke(planningTaskId);
|
||||||
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
|
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
|
||||||
|
|
||||||
|
|||||||
@@ -161,4 +161,69 @@ public class DetailsIslandReviewActionsTests : IDisposable
|
|||||||
|
|
||||||
Assert.Equal(worker.ExceptionMessage, reportedError);
|
Assert.Equal(worker.ExceptionMessage, reportedError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancelReview_IsDisabled_WhileUnitMergeIsDraining()
|
||||||
|
{
|
||||||
|
var worker = new RecordingWorkerClient();
|
||||||
|
var vm = BuildVm(worker);
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = "task-drain-1", Status = TaskStatus.WaitingForReview });
|
||||||
|
|
||||||
|
Assert.True(vm.CancelReviewCommand.CanExecute(null));
|
||||||
|
|
||||||
|
worker.RaisePlanningMergeStarted("task-drain-1", "main");
|
||||||
|
Assert.False(vm.CancelReviewCommand.CanExecute(null));
|
||||||
|
|
||||||
|
worker.RaisePlanningCompleted("task-drain-1");
|
||||||
|
Assert.True(vm.CancelReviewCommand.CanExecute(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancelReview_ReDisables_UntilMergeAborted()
|
||||||
|
{
|
||||||
|
var worker = new RecordingWorkerClient();
|
||||||
|
var vm = BuildVm(worker);
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = "task-drain-2", Status = TaskStatus.WaitingForReview });
|
||||||
|
|
||||||
|
worker.RaisePlanningMergeStarted("task-drain-2", "main");
|
||||||
|
Assert.False(vm.CancelReviewCommand.CanExecute(null));
|
||||||
|
|
||||||
|
worker.RaisePlanningMergeAborted("task-drain-2");
|
||||||
|
Assert.True(vm.CancelReviewCommand.CanExecute(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancelReview_UnaffectedByMergeEvents_ForADifferentTask()
|
||||||
|
{
|
||||||
|
var worker = new RecordingWorkerClient();
|
||||||
|
var vm = BuildVm(worker);
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = "task-drain-3", Status = TaskStatus.WaitingForReview });
|
||||||
|
|
||||||
|
worker.RaisePlanningMergeStarted("some-other-parent", "main");
|
||||||
|
|
||||||
|
Assert.True(vm.CancelReviewCommand.CanExecute(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingCancelWorkerClient : StubWorkerClient
|
||||||
|
{
|
||||||
|
public override bool IsConnected => true;
|
||||||
|
public string ExceptionMessage { get; init; } =
|
||||||
|
"A merge is in progress for this task; wait for it to finish before cancelling.";
|
||||||
|
public override Task CancelReviewAsync(string taskId) => throw new Exception(ExceptionMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancelReview_WhenWorkerThrows_RaisesErrorReported()
|
||||||
|
{
|
||||||
|
var worker = new ThrowingCancelWorkerClient();
|
||||||
|
var vm = BuildVm(worker);
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = "task-cancel-err-1", Status = TaskStatus.WaitingForReview });
|
||||||
|
|
||||||
|
string? reportedError = null;
|
||||||
|
vm.ErrorReported += msg => reportedError = msg;
|
||||||
|
|
||||||
|
await vm.CancelReviewCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal(worker.ExceptionMessage, reportedError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ public static class TaskStateServiceBuilder
|
|||||||
CountingQueueWaker Waker,
|
CountingQueueWaker Waker,
|
||||||
RunCancellationRegistry RunCancels);
|
RunCancellationRegistry RunCancels);
|
||||||
|
|
||||||
public static Built Build(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
public static Built Build(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory, Func<IActiveMergeState>? mergeState = null)
|
||||||
{
|
{
|
||||||
var hub = new CapturingHubContext();
|
var hub = new CapturingHubContext();
|
||||||
var broadcaster = new HubBroadcaster(hub);
|
var broadcaster = new HubBroadcaster(hub);
|
||||||
@@ -35,12 +36,19 @@ public static class TaskStateServiceBuilder
|
|||||||
waker,
|
waker,
|
||||||
chain,
|
chain,
|
||||||
runCancels,
|
runCancels,
|
||||||
|
mergeState ?? (() => NoActiveMergeState.Instance),
|
||||||
NullLogger<TaskStateService>.Instance);
|
NullLogger<TaskStateService>.Instance);
|
||||||
|
|
||||||
return new Built(state, chain, hub, () => waker.Count, waker, runCancels);
|
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
|
public sealed class CountingQueueWaker : IQueueWaker
|
||||||
{
|
{
|
||||||
private int _count;
|
private int _count;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
using ClaudeDo.Worker.State;
|
using ClaudeDo.Worker.State;
|
||||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -8,6 +9,12 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|||||||
|
|
||||||
namespace ClaudeDo.Worker.Tests.State;
|
namespace ClaudeDo.Worker.Tests.State;
|
||||||
|
|
||||||
|
file sealed class FakeActiveMergeState : IActiveMergeState
|
||||||
|
{
|
||||||
|
public HashSet<string> ActiveTaskIds { get; } = new();
|
||||||
|
public bool HasActiveMerge(string taskId) => ActiveTaskIds.Contains(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class TaskStateServiceTests : IDisposable
|
public sealed class TaskStateServiceTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly DbFixture _db = new();
|
private readonly DbFixture _db = new();
|
||||||
@@ -319,6 +326,34 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
Assert.Equal(TaskStatus.Done, await GetStatusAsync(id));
|
Assert.Equal(TaskStatus.Done, await GetStatusAsync(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancelAsync_WhileUnitMergeDraining_Rejects_AndDoesNotMutate()
|
||||||
|
{
|
||||||
|
var mergeState = new FakeActiveMergeState();
|
||||||
|
var built = TaskStateServiceBuilder.Build(_factory, () => mergeState);
|
||||||
|
var id = await SeedTaskAsync(TaskStatus.WaitingForReview);
|
||||||
|
mergeState.ActiveTaskIds.Add(id);
|
||||||
|
|
||||||
|
var result = await built.State.CancelAsync(id, DateTime.UtcNow, default);
|
||||||
|
|
||||||
|
Assert.False(result.Ok);
|
||||||
|
Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancelAsync_AfterUnitMergeDrainCompletes_Succeeds()
|
||||||
|
{
|
||||||
|
var mergeState = new FakeActiveMergeState();
|
||||||
|
var built = TaskStateServiceBuilder.Build(_factory, () => mergeState);
|
||||||
|
var id = await SeedTaskAsync(TaskStatus.WaitingForReview);
|
||||||
|
// Merge drain finished (or never started) for this task — cancel behaves as before.
|
||||||
|
|
||||||
|
var result = await built.State.CancelAsync(id, DateTime.UtcNow, default);
|
||||||
|
|
||||||
|
Assert.True(result.Ok);
|
||||||
|
Assert.Equal(TaskStatus.Cancelled, await GetStatusAsync(id));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── ResetToIdleAsync ─────────────────────────────────────────────────
|
// ─── ResetToIdleAsync ─────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user