feat(worker): refine planning chain re-shape on re-run
SetupChainAsync now sequences only non-terminal children (Idle/Queued). Done/Failed/Cancelled rows are left in place so a re-run on a partially executed chain keeps history intact and only reshapes the tail. Running children abort the op since the chain cannot be reshaped mid-flight. First non-terminal child is explicitly unblocked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -191,4 +191,64 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.SetupChainAsync("P", default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetupChain_AcceptsPartiallyQueuedChildren_IsIdempotent()
|
||||
{
|
||||
// Mirrors the BoxDataReader scenario: chain was partially set up earlier,
|
||||
// user re-runs "Queue subtasks sequentially" — should re-establish the chain
|
||||
// without throwing.
|
||||
await SeedPlanningFamilyAsync("P", 4);
|
||||
await using (var ctx = _factory.CreateDbContext())
|
||||
{
|
||||
// Pre-state: c0,c1 Idle; c2,c3 already Queued+blocked.
|
||||
var c2 = await ctx.Tasks.FirstAsync(t => t.Id == "P-c2");
|
||||
c2.Status = TaskStatus.Queued;
|
||||
c2.BlockedByTaskId = "P-c1";
|
||||
var c3 = await ctx.Tasks.FirstAsync(t => t.Id == "P-c3");
|
||||
c3.Status = TaskStatus.Queued;
|
||||
c3.BlockedByTaskId = "P-c2";
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var count = await _sut.SetupChainAsync("P", default);
|
||||
|
||||
Assert.Equal(4, count);
|
||||
var kids = await GetChildrenAsync("P");
|
||||
Assert.Equal(TaskStatus.Queued, kids[0].Status);
|
||||
Assert.Null(kids[0].BlockedByTaskId);
|
||||
Assert.Equal(TaskStatus.Queued, kids[1].Status);
|
||||
Assert.Equal("P-c0", kids[1].BlockedByTaskId);
|
||||
Assert.Equal(TaskStatus.Queued, kids[2].Status);
|
||||
Assert.Equal("P-c1", kids[2].BlockedByTaskId);
|
||||
Assert.Equal(TaskStatus.Queued, kids[3].Status);
|
||||
Assert.Equal("P-c2", kids[3].BlockedByTaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetupChain_SkipsTerminalChildren_DoesNotResurrectThem()
|
||||
{
|
||||
await SeedPlanningFamilyAsync("P", 4);
|
||||
await using (var ctx = _factory.CreateDbContext())
|
||||
{
|
||||
var c0 = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
||||
c0.Status = TaskStatus.Done;
|
||||
var c1 = await ctx.Tasks.FirstAsync(t => t.Id == "P-c1");
|
||||
c1.Status = TaskStatus.Failed;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var count = await _sut.SetupChainAsync("P", default);
|
||||
|
||||
// Only the two non-terminal tail children get chained.
|
||||
Assert.Equal(2, count);
|
||||
var kids = await GetChildrenAsync("P");
|
||||
Assert.Equal(TaskStatus.Done, kids[0].Status);
|
||||
Assert.Equal(TaskStatus.Failed, kids[1].Status);
|
||||
// First non-terminal becomes the chain head (unblocked).
|
||||
Assert.Equal(TaskStatus.Queued, kids[2].Status);
|
||||
Assert.Null(kids[2].BlockedByTaskId);
|
||||
Assert.Equal(TaskStatus.Queued, kids[3].Status);
|
||||
Assert.Equal("P-c2", kids[3].BlockedByTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user