fix(worker): advance parent when the last non-terminal child is deleted
DeleteTask never re-evaluated a WaitingForChildren parent, so deleting the last non-terminal child left it stuck (the BlockedByTaskId SET NULL FK only repairs the blocked chain, not parent status). Expose TaskStateService.TryAdvanceParentAsync on the interface and call it from ExternalMcpService.DeleteTask after a child delete.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -24,5 +24,10 @@ public interface ITaskStateService
|
||||
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, 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);
|
||||
}
|
||||
|
||||
@@ -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<TaskStatus> childStatuses;
|
||||
|
||||
@@ -104,7 +104,8 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
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
|
||||
{
|
||||
@@ -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<string> SeedIdleTask(string title = "t")
|
||||
|
||||
Reference in New Issue
Block a user