diff --git a/src/ClaudeDo.Worker/Planning/Interfaces/IActiveMergeState.cs b/src/ClaudeDo.Worker/Planning/Interfaces/IActiveMergeState.cs index c156fe40..0adda19c 100644 --- a/src/ClaudeDo.Worker/Planning/Interfaces/IActiveMergeState.cs +++ b/src/ClaudeDo.Worker/Planning/Interfaces/IActiveMergeState.cs @@ -7,4 +7,9 @@ namespace ClaudeDo.Worker.Planning; public interface IActiveMergeState { bool HasActiveMerge(string taskId); + + // In-memory state above is empty after a worker restart. This falls back to an on-disk + // check (repo mid-merge) for the one case that matters: a unit-merge parent paused on a + // conflict when the process died mid-drain. + Task HasActiveMergeAsync(string taskId, CancellationToken ct); } diff --git a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs index db2eba1e..b661b47a 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs @@ -164,6 +164,31 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState public bool HasActiveMerge(string parentTaskId) => _states.TryGetValue(parentTaskId, out var s) && (s.CurrentSubtaskId is not null || s.IsFinalizing); + /// Stateless fallback for : after a worker restart + /// is empty, so a unit-merge parent paused on a conflict would look + /// cancellable when its repo is still mid-merge. Only a WaitingForReview task with children + /// (i.e. a would-be merge parent) triggers the on-disk check — an ordinary task in the same + /// list must stay cancellable even while the list's repo is mid-merge. + public async Task HasActiveMergeAsync(string taskId, CancellationToken ct) + { + if (HasActiveMerge(taskId)) return true; + + string? workingDir; + bool hasChildren; + await using (var ctx = _dbFactory.CreateDbContext()) + { + var task = await ctx.Tasks + .Include(t => t.List) + .SingleOrDefaultAsync(t => t.Id == taskId, ct); + if (task is null || task.Status != TaskStatus.WaitingForReview) return false; + workingDir = task.List.WorkingDir; + hasChildren = await ctx.Tasks.AnyAsync(c => c.ParentTaskId == taskId, ct); + } + + if (!hasChildren || string.IsNullOrWhiteSpace(workingDir)) return false; + return await _git.IsMidMergeAsync(workingDir, ct); + } + /// 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 rather than trusting the in-memory flag diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index b58e70be..cec5efc6 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -286,7 +286,7 @@ public sealed class TaskStateService : ITaskStateService // 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)) + if (await _mergeState().HasActiveMergeAsync(taskId, ct)) return new TransitionResult(false, "A merge is in progress for this task; wait for it to finish before cancelling."); List cancelledChildIds; diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs index afcb75d7..b247e391 100644 --- a/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs @@ -66,6 +66,7 @@ file sealed class NoActiveMergeState : IActiveMergeState { public static readonly NoActiveMergeState Instance = new(); public bool HasActiveMerge(string taskId) => false; + public Task HasActiveMergeAsync(string taskId, CancellationToken ct) => Task.FromResult(false); } // Never actually invoked: TicketSystemConfig.IsConfigured is false in every TaskStateServiceBuilder diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs index 8a25f8c3..a31bd2db 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs @@ -162,6 +162,36 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable Assert.Contains(spy, c => c.Method == "PlanningCompleted"); } + [Fact] + public async Task CancelAsync_UnitMergeParentMidMergeAfterSimulatedRestart_BlocksUntilAborted() + { + var db = NewDb(); + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var (parentId, _, _, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo); + + var (orch, _) = BuildOrchestrator(db); + var startResult = await orch.StartAsync(parentId, "main", CancellationToken.None); + Assert.Equal(TaskMergeService.StatusConflict, startResult.Status); + + // Simulate a worker restart: a brand-new orchestrator instance has an empty in-memory + // _states dictionary, so the guard must fall back to the on-disk mid-merge check. + var (freshOrch, _) = BuildOrchestrator(db); + var cancelBuilt = TaskStateServiceBuilder.Build(db.CreateFactory(), () => freshOrch); + + var blocked = await cancelBuilt.State.CancelAsync(parentId, DateTime.UtcNow, CancellationToken.None); + Assert.False(blocked.Ok); + + using (var ctx = db.CreateContext()) + Assert.Equal(TaskStatus.WaitingForReview, ctx.Tasks.Single(t => t.Id == parentId).Status); + + GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort"); + + var ok = await cancelBuilt.State.CancelAsync(parentId, DateTime.UtcNow, CancellationToken.None); + Assert.True(ok.Ok); + } + private async Task<(string parentId, string subA, string subB, string subC)> SeedPlanningThreeChildrenMiddleConflictsAsync( DbFixture db, GitRepoFixture repo) { diff --git a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs index 521f239e..534a2066 100644 --- a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs @@ -15,6 +15,7 @@ file sealed class FakeActiveMergeState : IActiveMergeState { public HashSet ActiveTaskIds { get; } = new(); public bool HasActiveMerge(string taskId) => ActiveTaskIds.Contains(taskId); + public Task HasActiveMergeAsync(string taskId, CancellationToken ct) => Task.FromResult(HasActiveMerge(taskId)); } public sealed class TaskStateServiceTests : IDisposable