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) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-09 11:19:29 +02:00
parent b3a2daf40d
commit 12732d6dc9
2 changed files with 97 additions and 1 deletions

View File

@@ -289,12 +289,16 @@ public sealed class TaskStateService : ITaskStateService
public async Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct) public async Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct)
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(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 var affected = await ctx.Tasks
.Where(t => t.Id == parentId && t.PlanningPhase == PlanningPhase.Active) .Where(t => t.Id == parentId && t.PlanningPhase == PlanningPhase.Active)
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
.SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized) .SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized)
.SetProperty(t => t.PlanningFinalizedAt, DateTime.UtcNow) .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) if (affected == 0)
return new TransitionResult(false, "No active planning session."); return new TransitionResult(false, "No active planning session.");

View File

@@ -2,6 +2,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure; using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Xunit; using Xunit;
@@ -160,4 +161,95 @@ public sealed class WaitingForChildrenLifecycleTests : IDisposable
Assert.Equal(TaskStatus.WaitingForReview, par!.Status); Assert.Equal(TaskStatus.WaitingForReview, par!.Status);
} }
} }
// ─── FinalizePlanningAsync ────────────────────────────────────────────
private async Task<string> 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);
}
} }