Merge task branch for: fix(worker): deleting the last non-terminal child leaves the parent stuck in WaitingForChildren

This commit is contained in:
mika kuns
2026-07-23 18:00:48 +02:00
5 changed files with 36 additions and 7 deletions
-1
View File
@@ -52,7 +52,6 @@ Alle 9 Review-Tasks (5 Refactorings, 4 Bugfixes) sind umgesetzt und gemerged; De
**Plausibel, noch nicht einzeln verifiziert (bei Gelegenheit prüfen):** **Plausibel, noch nicht einzeln verifiziert (bei Gelegenheit prüfen):**
- Ketten-Kaskade stoppt an einem `Idle`-Mittelglied (`OnChildFinishedAsync` prüft `CancelAsync`-Ergebnis nicht) → Rest bleibt `Queued+blocked`. - 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). - `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`). - `HasChangesAsync` zählt untracked Files → blockiert Merges unnötig (`--untracked-files=no`).
- `UnifiedDiffParser`: Pfade mit Leerzeichen / git-gequotete Pfade aus `diff --git` falsch geparst. - `UnifiedDiffParser`: Pfade mit Leerzeichen / git-gequotete Pfade aus `diff --git` falsch geparst.
+2
View File
@@ -357,6 +357,8 @@ public sealed class ExternalMcpService
throw new InvalidOperationException("Cannot delete a running task. Cancel it first."); throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
await _tasks.DeleteAsync(taskId, cancellationToken); await _tasks.DeleteAsync(taskId, cancellationToken);
if (task.ParentTaskId is not null)
await _state.TryAdvanceParentAsync(task.ParentTaskId);
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
return new DeleteTaskResult(true, taskId); return new DeleteTaskResult(true, taskId);
} }
@@ -24,5 +24,10 @@ public interface ITaskStateService
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct); Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct); Task<TransitionResult> 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<int> RecoverStaleRunningAsync(string reason, CancellationToken ct); Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct);
} }
+11 -5
View File
@@ -417,9 +417,18 @@ public sealed class TaskStateService : ITaskStateService
_logger.LogWarning(ex, "PlanningChain advance failed for {TaskId}", taskId); _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 try
{ {
await TryAdvanceParentAsync(parentId); await AdvanceParentIfAllChildrenTerminalAsync(parentId);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -427,10 +436,7 @@ public sealed class TaskStateService : ITaskStateService
} }
} }
// Any parent (planning or improvement) sitting in WaitingForChildren surfaces for review private async Task AdvanceParentIfAllChildrenTerminalAsync(string parentId)
// 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)
{ {
string? parentResult; string? parentResult;
List<TaskStatus> childStatuses; List<TaskStatus> childStatuses;
@@ -104,7 +104,8 @@ public sealed class ExternalMcpServiceTests : IDisposable
return id; return id;
} }
private async Task<TaskEntity> SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Idle) private async Task<TaskEntity> SeedTaskAsync(
string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null)
{ {
var task = new TaskEntity var task = new TaskEntity
{ {
@@ -112,6 +113,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
ListId = listId, ListId = listId,
Title = title, Title = title,
Status = status, Status = status,
ParentTaskId = parentId,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CommitType = "chore", CommitType = "chore",
}; };
@@ -284,6 +286,21 @@ public sealed class ExternalMcpServiceTests : IDisposable
sut.DeleteTask("does-not-exist", CancellationToken.None)); 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 ExternalMcpService NewService() => BuildSut(CreateQueue());
private async Task<string> SeedIdleTask(string title = "t") private async Task<string> SeedIdleTask(string title = "t")