diff --git a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs
index 3b4b6bb..0e2aec3 100644
--- a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs
+++ b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs
@@ -85,7 +85,8 @@ public sealed class PlanningMergeOrchestrator
}
if (await _git.IsMidMergeAsync(workingDir, ct))
- throw new InvalidOperationException("repo is mid-merge");
+ throw new InvalidOperationException(
+ "repo is mid-merge; use AbortPlanningMerge to reset the repository, then Approve again");
if (await _git.HasChangesAsync(workingDir, ct))
throw new InvalidOperationException("working tree has uncommitted changes");
@@ -110,7 +111,8 @@ public sealed class PlanningMergeOrchestrator
public async Task ContinueAsync(string planningTaskId, CancellationToken ct)
{
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
- throw new InvalidOperationException("no in-progress merge to continue");
+ throw new InvalidOperationException(
+ "no in-progress merge to continue; if the worker was restarted during a conflict, use AbortPlanningMerge to reset the repository");
var current = state.CurrentSubtaskId;
var result = await _merge.ContinueMergeAsync(current, ct);
@@ -140,13 +142,40 @@ public sealed class PlanningMergeOrchestrator
public async Task AbortAsync(string planningTaskId, CancellationToken ct)
{
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
- throw new InvalidOperationException("no in-progress merge to abort");
+ {
+ // No in-memory state — worker may have been restarted while a conflict was paused.
+ // Check whether the list repo is still mid-merge and abort it directly.
+ await AbortStatelessAsync(planningTaskId, ct);
+ return;
+ }
await _merge.AbortMergeAsync(state.CurrentSubtaskId, ct);
_states.TryRemove(planningTaskId, out _);
await _broadcaster.PlanningMergeAborted(planningTaskId);
}
+ private async Task AbortStatelessAsync(string planningTaskId, CancellationToken ct)
+ {
+ string? workingDir;
+ await using (var ctx = _dbFactory.CreateDbContext())
+ {
+ workingDir = await ctx.Tasks
+ .Where(t => t.Id == planningTaskId)
+ .Select(t => t.List.WorkingDir)
+ .FirstOrDefaultAsync(ct);
+ }
+
+ if (string.IsNullOrWhiteSpace(workingDir) || !await _git.IsMidMergeAsync(workingDir, ct))
+ throw new InvalidOperationException("no in-progress merge to abort");
+
+ await _git.MergeAbortAsync(workingDir, ct);
+ _logger.LogInformation(
+ "Stateless abort of mid-merge for planning task {ParentId} (post-restart recovery)",
+ planningTaskId);
+ await _broadcaster.PlanningMergeAborted(planningTaskId);
+ // Parent remains WaitingForReview — Approve will restart the unit merge from scratch.
+ }
+
private async Task DrainAsync(string planningTaskId, CancellationToken ct)
{
if (!_states.TryGetValue(planningTaskId, out var state)) return;
diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs
index 0c646e2..0202258 100644
--- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs
@@ -245,6 +245,59 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None));
}
+ // ─── Stateless abort (post-restart recovery) ───────────────────────────
+
+ ///
+ /// Worker restarted while a conflict was paused: _states is empty but the list repo is
+ /// still mid-merge. AbortAsync must abort the dangling merge, broadcast PlanningMergeAborted,
+ /// and leave the parent in WaitingForReview so a fresh Approve can retry.
+ ///
+ [Fact]
+ public async Task AbortAsync_NoState_RepoMidMerge_AbortsAndBroadcasts()
+ {
+ var db = NewDb();
+ var repo = NewRepo();
+ GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
+
+ var (parentId, _, subB, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
+
+ // Drive orch1 into the conflict pause — repo is now mid-merge.
+ var (orch1, _) = BuildOrchestrator(db);
+ await orch1.StartAsync(parentId, "main", CancellationToken.None);
+
+ // Simulate restart: fresh orchestrator has no in-memory state.
+ var (orch2, spy) = BuildOrchestrator(db);
+
+ await orch2.AbortAsync(parentId, CancellationToken.None);
+
+ var git = new GitService();
+ Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None));
+
+ using var ctx = db.CreateContext();
+ Assert.Equal(TaskStatus.WaitingForReview, ctx.Tasks.Single(t => t.Id == parentId).Status);
+
+ Assert.Contains(spy, c => c.Method == "PlanningMergeAborted" && (string)c.Args[0]! == parentId);
+ }
+
+ ///
+ /// No in-memory state and repo is clean — nothing to abort. Must throw a clear error.
+ ///
+ [Fact]
+ public async Task AbortAsync_NoState_RepoNotMidMerge_ThrowsClear()
+ {
+ var db = NewDb();
+ var repo = NewRepo();
+ GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
+
+ var (parentId, _, _) = await SeedPlanningWithTwoNonConflictingChildrenAsync(db, repo);
+
+ var (orch, _) = BuildOrchestrator(db);
+
+ var ex = await Assert.ThrowsAsync(
+ () => orch.AbortAsync(parentId, CancellationToken.None));
+ Assert.Contains("no in-progress merge", ex.Message);
+ }
+
private (PlanningMergeOrchestrator orch, List<(string Method, object?[] Args)> calls) BuildOrchestrator(DbFixture db)
{
var fakeHub = new OrchestratorFakeHubContext();