diff --git a/docs/open.md b/docs/open.md index dc279b7c..d449cdd0 100644 --- a/docs/open.md +++ b/docs/open.md @@ -53,7 +53,6 @@ Alle 9 Review-Tasks (5 Refactorings, 4 Bugfixes) sind umgesetzt und gemerged; De - Cancel eines `WaitingForChildren`-Parents kaskadiert nicht auf laufende/queued Kinder (verwaiste Worktree-Commits). - Ketten-Kaskade stoppt an einem `Idle`-Mittelglied (`OnChildFinishedAsync` prüft `CancelAsync`-Ergebnis nicht) → Rest bleibt `Queued+blocked`. -- Delete des *letzten* nicht-terminalen Kindes triggert kein `TryAdvanceParentAsync` → Parent kann in `WaitingForChildren` hängen (FK `SET NULL` rettet nur die Blocked-Kette). - `ContinueMergeAsync` staged per `git add -A` vor dem Konflikt-Check (Marker im Index, Abort danach ggf. unsauber). - `HasChangesAsync` zählt untracked Files → blockiert Merges unnötig (`--untracked-files=no`). - `UnifiedDiffParser`: Pfade mit Leerzeichen / git-gequotete Pfade aus `diff --git` falsch geparst. diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index d090863b..ef1a59b2 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -357,6 +357,8 @@ public sealed class ExternalMcpService throw new InvalidOperationException("Cannot delete a running task. Cancel it first."); await _tasks.DeleteAsync(taskId, cancellationToken); + if (task.ParentTaskId is not null) + await _state.TryAdvanceParentAsync(task.ParentTaskId); await _broadcaster.TaskUpdated(taskId); return new DeleteTaskResult(true, taskId); } diff --git a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs index 1804caac..0454e9b0 100644 --- a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs +++ b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs @@ -24,5 +24,10 @@ public interface ITaskStateService Task BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct); Task UnblockAsync(string taskId, CancellationToken ct); + // Surfaces a WaitingForChildren parent for review once all its children are terminal. + // Best-effort (swallows and logs failures) — safe to call after any child mutation, + // e.g. deleting the last non-terminal child (no terminal transition fires for a delete). + Task TryAdvanceParentAsync(string parentId); + Task RecoverStaleRunningAsync(string reason, CancellationToken ct); } diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index d6162194..8599d844 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -395,9 +395,18 @@ public sealed class TaskStateService : ITaskStateService _logger.LogWarning(ex, "PlanningChain advance failed for {TaskId}", taskId); } + await TryAdvanceParentAsync(parentId); + } + + // Any parent (planning or improvement) sitting in WaitingForChildren surfaces for review + // once every child is terminal (Done/Failed/Cancelled). A failed or cancelled child does + // not wedge the parent — it is flagged on the result. Also called directly after a child + // is deleted, since no terminal transition fires in that case. + public async Task TryAdvanceParentAsync(string parentId) + { try { - await TryAdvanceParentAsync(parentId); + await AdvanceParentIfAllChildrenTerminalAsync(parentId); } catch (Exception ex) { @@ -405,10 +414,7 @@ public sealed class TaskStateService : ITaskStateService } } - // Any parent (planning or improvement) sitting in WaitingForChildren surfaces for review - // once every child is terminal (Done/Failed/Cancelled). A failed or cancelled child does - // not wedge the parent — it is flagged on the result. - private async Task TryAdvanceParentAsync(string parentId) + private async Task AdvanceParentIfAllChildrenTerminalAsync(string parentId) { string? parentResult; List childStatuses; diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 4d785beb..64bb2465 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -104,7 +104,8 @@ public sealed class ExternalMcpServiceTests : IDisposable return id; } - private async Task SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Idle) + private async Task SeedTaskAsync( + string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null) { var task = new TaskEntity { @@ -112,6 +113,7 @@ public sealed class ExternalMcpServiceTests : IDisposable ListId = listId, Title = title, Status = status, + ParentTaskId = parentId, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; @@ -284,6 +286,21 @@ public sealed class ExternalMcpServiceTests : IDisposable sut.DeleteTask("does-not-exist", CancellationToken.None)); } + [Fact] + public async Task DeleteTask_LastNonTerminalChild_AdvancesParentToWaitingForReview() + { + var listId = await SeedListAsync(); + var parent = await SeedTaskAsync(listId, status: TaskStatus.WaitingForChildren); + await SeedTaskAsync(listId, status: TaskStatus.Done, parentId: parent.Id); + var lastChild = await SeedTaskAsync(listId, status: TaskStatus.Idle, parentId: parent.Id); + var sut = BuildSut(CreateQueue()); + + await sut.DeleteTask(lastChild.Id, CancellationToken.None); + + var reloadedParent = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.WaitingForReview, reloadedParent!.Status); + } + private ExternalMcpService NewService() => BuildSut(CreateQueue()); private async Task SeedIdleTask(string title = "t")