fix(worker): keep planning-chain cascade moving past an Idle middle link

OnChildFinishedAsync ignored CancelAsync's result. If a chain successor
sat in a non-cancellable state (e.g. parked to Idle out of band) when
its predecessor failed/was cancelled, CancelAsync was a silent no-op and
the cascade stopped there, leaving the rest of the chain Queued+blocked
forever. Now it walks past any link CancelAsync can't touch and keeps
cancelling downstream.
This commit is contained in:
mika kuns
2026-07-23 17:09:55 +02:00
parent 85c7e650c9
commit 816f247d90
2 changed files with 60 additions and 13 deletions
@@ -178,6 +178,37 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
Assert.Equal(TaskStatus.Cancelled, kids[3].Status);
}
[Fact]
public async Task OnChildFailed_MidChain_IdleSuccessor_DoesNotWedgeTail()
{
// Chain: c0 → c1 → c2 → c3. c1 is parked back to Idle (e.g. a manual reset)
// while still wired into the chain (BlockedByTaskId = c0). c0 then fails:
// CancelAsync(c1) is a no-op because Idle isn't a cancellable state, so the
// cascade must keep walking past c1 instead of leaving c2/c3 wedged forever.
await SeedPlanningFamilyAsync("P", 4);
await _sut.SetupChainAsync("P", enqueue: true, default);
await using (var ctx = _factory.CreateDbContext())
{
var c0 = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
c0.Status = TaskStatus.Failed;
var c1 = await ctx.Tasks.FirstAsync(t => t.Id == "P-c1");
c1.Status = TaskStatus.Idle;
await ctx.SaveChangesAsync();
}
var advanced = await _sut.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
Assert.Null(advanced);
var kids = await GetChildrenAsync("P");
Assert.Equal(TaskStatus.Failed, kids[0].Status);
// c1 was already parked Idle; the coordinator must not resurrect it.
Assert.Equal(TaskStatus.Idle, kids[1].Status);
// c2/c3 must not stay wedged as Queued+blocked forever.
Assert.Equal(TaskStatus.Cancelled, kids[2].Status);
Assert.Equal(TaskStatus.Cancelled, kids[3].Status);
}
[Fact]
public async Task OnChildDone_LastChild_ReturnsNull()
{