From 6f4b5d554491a935a6426bc7da42481449e3718a Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 4 Jun 2026 15:38:15 +0200 Subject: [PATCH] feat(state): add SubmitForChildrenAsync (Running -> WaitingForChildren) Co-Authored-By: Claude Sonnet 4.6 --- .../State/Interfaces/ITaskStateService.cs | 1 + src/ClaudeDo.Worker/State/TaskStateService.cs | 17 +++ .../WaitingForChildrenLifecycleTests.cs | 129 ++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs diff --git a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs index dfcda9e..1804caa 100644 --- a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs +++ b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs @@ -6,6 +6,7 @@ public interface ITaskStateService Task StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct); Task CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct); Task SubmitForReviewAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct); + Task SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct); Task FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct); Task CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct); Task ResetToIdleAsync(string taskId, CancellationToken ct); diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index f012700..ce248a3 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -107,6 +107,23 @@ public sealed class TaskStateService : ITaskStateService return new TransitionResult(true, null); } + public async Task SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var affected = await ctx.Tasks + .Where(t => t.Id == taskId && t.Status == TaskStatus.Running) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.WaitingForChildren) + .SetProperty(t => t.FinishedAt, finishedAt) + .SetProperty(t => t.Result, result), ct); + + if (affected == 0) + return new TransitionResult(false, "Task not running; cannot submit for children."); + + await _broadcaster.TaskUpdated(taskId); + return new TransitionResult(true, null); + } + public async Task ApproveReviewAsync(string taskId, CancellationToken ct) { await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) diff --git a/tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs b/tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs new file mode 100644 index 0000000..146ca41 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs @@ -0,0 +1,129 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Tests.Infrastructure; +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 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(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); + } +}