From 12732d6dc9a3e013bf5273c4f76966468574d8aa Mon Sep 17 00:00:00 2001 From: mika kuns Date: Tue, 9 Jun 2026 11:19:29 +0200 Subject: [PATCH] feat(worker): planning finalize enters WaitingForChildren A finalized planning parent now joins the unified parent lifecycle: WaitingForChildren while its child chain runs (or WaitingForReview directly if it has no children), advancing to review like an improvement parent. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ClaudeDo.Worker/State/TaskStateService.cs | 6 +- .../WaitingForChildrenLifecycleTests.cs | 92 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index cea4edc..fe48954 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -289,12 +289,16 @@ public sealed class TaskStateService : ITaskStateService public async Task FinalizePlanningAsync(string parentId, CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == parentId, ct); + var newStatus = hasChildren ? TaskStatus.WaitingForChildren : TaskStatus.WaitingForReview; + var affected = await ctx.Tasks .Where(t => t.Id == parentId && t.PlanningPhase == PlanningPhase.Active) .ExecuteUpdateAsync(s => s .SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized) .SetProperty(t => t.PlanningFinalizedAt, DateTime.UtcNow) - .SetProperty(t => t.PlanningSessionToken, (string?)null), ct); + .SetProperty(t => t.PlanningSessionToken, (string?)null) + .SetProperty(t => t.Status, newStatus), ct); if (affected == 0) return new TransitionResult(false, "No active planning session."); diff --git a/tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs b/tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs index 8bbb01d..c84bb7c 100644 --- a/tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs +++ b/tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs @@ -2,6 +2,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using Xunit; @@ -160,4 +161,95 @@ public sealed class WaitingForChildrenLifecycleTests : IDisposable Assert.Equal(TaskStatus.WaitingForReview, par!.Status); } } + + // ─── FinalizePlanningAsync ──────────────────────────────────────────── + + private async Task SeedActivePlanningParentAsync(string id = "par") + { + using var ctx = _db.CreateContext(); + if (!ctx.Lists.Any()) + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity + { + Id = id, + ListId = "l1", + Title = "Parent", + Status = TaskStatus.Idle, + PlanningPhase = PlanningPhase.Active, + CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + return id; + } + + private async Task SeedChildAsync(string parentId, string childId, TaskStatus status = TaskStatus.Idle) + { + using var ctx = _db.CreateContext(); + ctx.Tasks.Add(new TaskEntity + { + Id = childId, + ListId = "l1", + Title = "Child", + Status = status, + ParentTaskId = parentId, + CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + [Fact] + public async Task FinalizePlanning_WithChildren_SetsWaitingForChildren_AndPlanningFinalized() + { + await SeedActivePlanningParentAsync(); + await SeedChildAsync("par", "c1"); + await SeedChildAsync("par", "c2"); + + var result = await _built.State.FinalizePlanningAsync("par", default); + + Assert.True(result.Ok); + using var ctx = _db.CreateContext(); + var parent = await new TaskRepository(ctx).GetByIdAsync("par"); + Assert.Equal(TaskStatus.WaitingForChildren, parent!.Status); + Assert.Equal(PlanningPhase.Finalized, parent.PlanningPhase); + } + + [Fact] + public async Task FinalizePlanning_WithChildren_ThenAllChildrenTerminal_AdvancesToWaitingForReview() + { + await SeedActivePlanningParentAsync(); + await SeedChildAsync("par", "c1"); + await SeedChildAsync("par", "c2"); + + await _built.State.FinalizePlanningAsync("par", default); + + // Manually transition parent to WaitingForChildren so CompleteAsync works on the children. + // Children are Idle — complete them by seeding them as Running first. + using (var ctx = _db.CreateContext()) + { + await ctx.Tasks.Where(t => t.Id == "c1").ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Running)); + await ctx.Tasks.Where(t => t.Id == "c2").ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Running)); + } + + await _built.State.CompleteAsync("c1", DateTime.UtcNow, "ok", default); + using (var ctx = _db.CreateContext()) + Assert.Equal(TaskStatus.WaitingForChildren, (await new TaskRepository(ctx).GetByIdAsync("par"))!.Status); + + await _built.State.CompleteAsync("c2", DateTime.UtcNow, "ok", default); + using (var ctx = _db.CreateContext()) + Assert.Equal(TaskStatus.WaitingForReview, (await new TaskRepository(ctx).GetByIdAsync("par"))!.Status); + } + + [Fact] + public async Task FinalizePlanning_WithNoChildren_SetsWaitingForReview_Directly() + { + await SeedActivePlanningParentAsync(); + + var result = await _built.State.FinalizePlanningAsync("par", default); + + Assert.True(result.Ok); + using var ctx = _db.CreateContext(); + var parent = await new TaskRepository(ctx).GetByIdAsync("par"); + Assert.Equal(TaskStatus.WaitingForReview, parent!.Status); + Assert.Equal(PlanningPhase.Finalized, parent.PlanningPhase); + } }