Add WaitingForChildren to the CancelAsync guard so a parent waiting on its children can be cancelled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
277 lines
12 KiB
C#
277 lines
12 KiB
C#
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;
|
|
|
|
namespace ClaudeDo.Worker.Tests;
|
|
|
|
public sealed class WaitingForChildrenLifecycleTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly TestDbContextFactory _factory;
|
|
private readonly TaskStateServiceBuilder.Built _built;
|
|
|
|
public WaitingForChildrenLifecycleTests()
|
|
{
|
|
_factory = _db.CreateFactory();
|
|
_built = TaskStateServiceBuilder.Build(_factory);
|
|
}
|
|
public void Dispose() => _db.Dispose();
|
|
|
|
private async Task<string> SeedRunningStandaloneAsync()
|
|
{
|
|
using var ctx = _db.CreateContext();
|
|
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity { Id = "p1", ListId = "l1", Title = "Parent",
|
|
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
|
|
await ctx.SaveChangesAsync();
|
|
return "p1";
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubmitForChildren_moves_running_task_to_WaitingForChildren()
|
|
{
|
|
var id = await SeedRunningStandaloneAsync();
|
|
var result = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, "ran ok", default);
|
|
Assert.True(result.Ok);
|
|
using var ctx = _db.CreateContext();
|
|
var t = await new TaskRepository(ctx).GetByIdAsync(id);
|
|
Assert.Equal(TaskStatus.WaitingForChildren, t!.Status);
|
|
Assert.Equal("ran ok", t.Result);
|
|
Assert.NotNull(t.FinishedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubmitForChildren_rejects_when_not_running()
|
|
{
|
|
var id = await SeedRunningStandaloneAsync();
|
|
await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, null, default);
|
|
var second = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, null, default);
|
|
Assert.False(second.Ok);
|
|
}
|
|
|
|
private async Task SeedParentWithChildrenAsync(string parentStatus, params TaskStatus[] childStatuses)
|
|
{
|
|
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 = "par", ListId = "l1", Title = "Parent",
|
|
Status = Enum.Parse<TaskStatus>(parentStatus), Result = "parent ran", CreatedAt = DateTime.UtcNow });
|
|
int i = 0;
|
|
foreach (var cs in childStatuses)
|
|
ctx.Tasks.Add(new TaskEntity { Id = $"c{i++}", ListId = "l1", Title = "Child",
|
|
Status = cs, ParentTaskId = "par", CreatedAt = DateTime.UtcNow });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LastChildDone_advances_WaitingForChildren_parent_to_WaitingForReview()
|
|
{
|
|
await SeedParentWithChildrenAsync("WaitingForChildren", TaskStatus.Done, TaskStatus.Running);
|
|
await _built.State.CompleteAsync("c1", DateTime.UtcNow, "child ok", default);
|
|
using var ctx = _db.CreateContext();
|
|
var parent = await new TaskRepository(ctx).GetByIdAsync("par");
|
|
Assert.Equal(TaskStatus.WaitingForReview, parent!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NonLastChild_leaves_parent_in_WaitingForChildren()
|
|
{
|
|
await SeedParentWithChildrenAsync("WaitingForChildren", TaskStatus.Running, TaskStatus.Idle);
|
|
await _built.State.CompleteAsync("c0", DateTime.UtcNow, "ok", default);
|
|
using var ctx = _db.CreateContext();
|
|
var parent = await new TaskRepository(ctx).GetByIdAsync("par");
|
|
Assert.Equal(TaskStatus.WaitingForChildren, parent!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FailedChild_still_advances_parent_and_flags_failure()
|
|
{
|
|
await SeedParentWithChildrenAsync("WaitingForChildren", TaskStatus.Done, TaskStatus.Running);
|
|
await _built.State.FailAsync("c1", DateTime.UtcNow, "boom", default);
|
|
using var ctx = _db.CreateContext();
|
|
var parent = await new TaskRepository(ctx).GetByIdAsync("par");
|
|
Assert.Equal(TaskStatus.WaitingForReview, parent!.Status);
|
|
Assert.Contains("1 failed", parent.Result!);
|
|
}
|
|
|
|
private async Task SeedParentChildPairAsync(PlanningPhase parentPhase)
|
|
{
|
|
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 = "par", ListId = "l1", Title = "Parent",
|
|
Status = TaskStatus.WaitingForChildren, PlanningPhase = parentPhase, CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity { Id = "kid", ListId = "l1", Title = "Child",
|
|
Status = TaskStatus.Idle, ParentTaskId = "par", CreatedAt = DateTime.UtcNow });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImprovementChild_can_be_enqueued()
|
|
{
|
|
await SeedParentChildPairAsync(PlanningPhase.None);
|
|
var result = await _built.State.EnqueueAsync("kid", default);
|
|
Assert.True(result.Ok);
|
|
using var ctx = _db.CreateContext();
|
|
Assert.Equal(TaskStatus.Queued, (await new TaskRepository(ctx).GetByIdAsync("kid"))!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlanningDraftChild_cannot_be_enqueued()
|
|
{
|
|
await SeedParentChildPairAsync(PlanningPhase.Active);
|
|
var result = await _built.State.EnqueueAsync("kid", default);
|
|
Assert.False(result.Ok);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SequentialChildren_parent_only_advances_after_both_are_terminal()
|
|
{
|
|
// Arrange: parent WaitingForChildren, child1 Done, child2 blocked by child1 (Running)
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity { Id = "par", ListId = "l1", Title = "Parent",
|
|
Status = TaskStatus.WaitingForChildren, CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity { Id = "c1", ListId = "l1", Title = "Child1",
|
|
Status = TaskStatus.Running, ParentTaskId = "par", CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity { Id = "c2", ListId = "l1", Title = "Child2",
|
|
Status = TaskStatus.Running, ParentTaskId = "par", BlockedByTaskId = "c1",
|
|
CreatedAt = DateTime.UtcNow });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// Act: complete child1 — parent must stay WaitingForChildren because child2 is still running
|
|
await _built.State.CompleteAsync("c1", DateTime.UtcNow, "c1 ok", default);
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var par = await new TaskRepository(ctx).GetByIdAsync("par");
|
|
Assert.Equal(TaskStatus.WaitingForChildren, par!.Status);
|
|
}
|
|
|
|
// Act: complete child2 — now all children are terminal; parent must advance
|
|
await _built.State.CompleteAsync("c2", DateTime.UtcNow, "c2 ok", default);
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var par = await new TaskRepository(ctx).GetByIdAsync("par");
|
|
Assert.Equal(TaskStatus.WaitingForReview, par!.Status);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WaitingForChildren_parent_can_be_cancelled()
|
|
{
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity { Id = "par", ListId = "l1", Title = "Parent",
|
|
Status = TaskStatus.WaitingForChildren, CreatedAt = DateTime.UtcNow });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var result = await _built.State.CancelAsync("par", DateTime.UtcNow, default);
|
|
|
|
Assert.True(result.Ok);
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var par = await new TaskRepository(ctx).GetByIdAsync("par");
|
|
Assert.Equal(TaskStatus.Cancelled, 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);
|
|
}
|
|
}
|