feat(worker): chain a normal parent's children on WaitingForChildren

A standalone parent (PlanningPhase.None) with existing Idle/Queued children
now runs itself first, then sequences its children instead of flat-enqueuing
all of them at once: SubmitForChildrenAsync calls
PlanningChainCoordinator.SetupChainAsync(enqueue: true) right after the
Running -> WaitingForChildren transition, mirroring the planning path but
firing immediately instead of waiting for an explicit "Queue plan". Planning
parents are left untouched (they queue via QueuePlanAsync), and a child
already Running makes SetupChainAsync throw, which is now caught and logged
so it can't fail the parent's own transition.
This commit is contained in:
mika kuns
2026-09-10 13:36:08 +02:00
parent 897f287ee1
commit d2648f965c
4 changed files with 109 additions and 12 deletions
@@ -54,6 +54,81 @@ public sealed class WaitingForChildrenLifecycleTests : IDisposable
Assert.False(second.Ok);
}
private async Task<string> SeedRunningParentWithChildrenAsync(
PlanningPhase parentPhase, 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 = "p1", ListId = "l1", Title = "Parent", Number = ++_numberSeed,
Status = TaskStatus.Running, PlanningPhase = parentPhase, CreatedAt = DateTime.UtcNow });
int i = 0;
foreach (var cs in childStatuses)
ctx.Tasks.Add(new TaskEntity { Id = $"c{i++}", ListId = "l1", Title = "Child", Number = ++_numberSeed,
Status = cs, ParentTaskId = "p1", CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
return "p1";
}
[Fact]
public async Task SubmitForChildren_NormalParent_ChainsIdleChildren()
{
var id = await SeedRunningParentWithChildrenAsync(
PlanningPhase.None, TaskStatus.Idle, TaskStatus.Idle, TaskStatus.Idle);
var result = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, "ran ok", default);
Assert.True(result.Ok);
using var ctx = _db.CreateContext();
var repo = new TaskRepository(ctx);
var c0 = await repo.GetByIdAsync("c0");
var c1 = await repo.GetByIdAsync("c1");
var c2 = await repo.GetByIdAsync("c2");
Assert.Equal(TaskStatus.Queued, c0!.Status);
Assert.Null(c0.BlockedByTaskId);
Assert.Equal(TaskStatus.Queued, c1!.Status);
Assert.Equal("c0", c1.BlockedByTaskId);
Assert.Equal(TaskStatus.Queued, c2!.Status);
Assert.Equal("c1", c2.BlockedByTaskId);
}
[Fact]
public async Task SubmitForChildren_PlanningParent_DoesNotChainChildren()
{
var id = await SeedRunningParentWithChildrenAsync(PlanningPhase.Active, TaskStatus.Idle, TaskStatus.Idle);
var result = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, "ran ok", default);
Assert.True(result.Ok);
using var ctx = _db.CreateContext();
var repo = new TaskRepository(ctx);
var c0 = await repo.GetByIdAsync("c0");
var c1 = await repo.GetByIdAsync("c1");
Assert.Equal(TaskStatus.Idle, c0!.Status);
Assert.Null(c0.BlockedByTaskId);
Assert.Equal(TaskStatus.Idle, c1!.Status);
Assert.Null(c1.BlockedByTaskId);
}
[Fact]
public async Task SubmitForChildren_RunningChild_DoesNotFailParentTransition()
{
var id = await SeedRunningParentWithChildrenAsync(PlanningPhase.None, TaskStatus.Running, TaskStatus.Idle);
var result = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, "ran ok", default);
Assert.True(result.Ok);
using var ctx = _db.CreateContext();
var repo = new TaskRepository(ctx);
Assert.Equal(TaskStatus.WaitingForChildren, (await repo.GetByIdAsync(id))!.Status);
// Reshape aborted (a running child cannot be reshaped) — the untouched sibling stays Idle.
Assert.Equal(TaskStatus.Idle, (await repo.GetByIdAsync("c1"))!.Status);
}
private async Task SeedParentWithChildrenAsync(string parentStatus, params TaskStatus[] childStatuses)
{
using var ctx = _db.CreateContext();