Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs
mika kuns 33bdff8a6e fix(worker): harden CLI injection, stuck-Running, chain wedge, and Fail guard
1. ArgumentList (fix injection): ClaudeArgsBuilder.Build() now returns
   IReadOnlyList<string>; ClaudeProcess populates ProcessStartInfo.ArgumentList
   instead of Arguments, so values like system prompts are never shell-split.
   DailyPrepPrompt, RefinePrompt, and WeekReportService migrated similarly.
   All IClaudeProcess fakes updated.

2. ContinueAsync exception guard: wrap RunOnceAsync in try/catch matching
   the RunAsync pattern so an unexpected exception never leaves the task
   stuck in Running status.

3. Planning chain cascade: OnChildFinishedAsync now calls CancelAsync on
   the immediate blocked successor when a child fails or is cancelled,
   triggering a recursive cascade that clears the entire remaining chain
   instead of leaving it wedged.

4. FailAsync guard: restrict valid source states to Running and Queued;
   WaitingForReview -> Failed is now rejected, preventing an invalid
   transition that could corrupt the review workflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:05:40 +02:00

307 lines
11 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Planning;
public sealed class PlanningChainCoordinatorTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly TestDbContextFactory _factory;
private readonly PlanningChainCoordinator _sut;
private readonly string _listId;
public PlanningChainCoordinatorTests()
{
_factory = _db.CreateFactory();
_sut = TaskStateServiceBuilder.Build(_factory).Chain;
_listId = Guid.NewGuid().ToString();
using var ctx = _factory.CreateDbContext();
ctx.Lists.Add(new ListEntity
{
Id = _listId,
Name = "Test",
CreatedAt = DateTime.UtcNow,
DefaultCommitType = "chore",
});
ctx.SaveChanges();
}
public void Dispose() => _db.Dispose();
private async Task SeedPlanningFamilyAsync(string parentId, int childCount, TaskStatus childStatus = TaskStatus.Idle)
{
await using var ctx = _factory.CreateDbContext();
ctx.Tasks.Add(new TaskEntity
{
Id = parentId,
ListId = _listId,
Title = "Parent",
CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Idle,
PlanningPhase = PlanningPhase.Finalized,
});
for (int i = 0; i < childCount; i++)
{
ctx.Tasks.Add(new TaskEntity
{
Id = $"{parentId}-c{i}",
ListId = _listId,
Title = $"Child {i}",
CreatedAt = DateTime.UtcNow,
Status = childStatus,
ParentTaskId = parentId,
SortOrder = i,
});
}
await ctx.SaveChangesAsync();
}
private async Task<List<TaskEntity>> GetChildrenAsync(string parentId)
{
await using var ctx = _factory.CreateDbContext();
return await ctx.Tasks
.AsNoTracking()
.Where(t => t.ParentTaskId == parentId)
.OrderBy(t => t.SortOrder)
.ToListAsync();
}
[Fact]
public async Task SetupChain_FirstChildQueuedUnblocked_RestQueuedBlockedByPredecessor()
{
await SeedPlanningFamilyAsync("P", 3);
var count = await _sut.SetupChainAsync("P", default);
Assert.Equal(3, 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(kids[0].Id, kids[1].BlockedByTaskId);
Assert.Equal(TaskStatus.Queued, kids[2].Status);
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
}
[Fact]
public async Task SetupChain_AcceptsIdleChildren()
{
await SeedPlanningFamilyAsync("P", 2, childStatus: TaskStatus.Idle);
var count = await _sut.SetupChainAsync("P", default);
Assert.Equal(2, count);
}
[Fact]
public async Task OnChildDone_UnblocksTheSuccessor()
{
await SeedPlanningFamilyAsync("P", 3);
await _sut.SetupChainAsync("P", default);
// Mark the head child Done before announcing.
await using (var ctx = _factory.CreateDbContext())
{
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
first.Status = TaskStatus.Done;
await ctx.SaveChangesAsync();
}
var advanced = await _sut.OnChildFinishedAsync("P-c0", TaskStatus.Done, default);
Assert.Equal("P-c1", advanced);
var kids = await GetChildrenAsync("P");
Assert.Equal(TaskStatus.Done, kids[0].Status);
// c1 was Queued+BlockedBy=c0; UnblockAsync clears the block.
Assert.Equal(TaskStatus.Queued, kids[1].Status);
Assert.Null(kids[1].BlockedByTaskId);
// c2 still blocked on c1.
Assert.Equal(TaskStatus.Queued, kids[2].Status);
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
}
[Fact]
public async Task OnChildFailed_CancelsPendingSuccessors_ChainIsNotWedged()
{
await SeedPlanningFamilyAsync("P", 3);
await _sut.SetupChainAsync("P", default);
await using (var ctx = _factory.CreateDbContext())
{
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
first.Status = TaskStatus.Failed;
await ctx.SaveChangesAsync();
}
var advanced = await _sut.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
// No "advancement" — the chain does not continue on failure.
Assert.Null(advanced);
var kids = await GetChildrenAsync("P");
Assert.Equal(TaskStatus.Failed, kids[0].Status);
// Successors must be Cancelled, not left stuck as Queued.
Assert.Equal(TaskStatus.Cancelled, kids[1].Status);
Assert.Equal(TaskStatus.Cancelled, kids[2].Status);
}
[Fact]
public async Task OnChildFailed_MidChain_CancelsAllDownstreamSuccessors()
{
// Chain: c0 → c1 → c2 → c3. c1 fails mid-chain; c2 and c3 must be cancelled.
await SeedPlanningFamilyAsync("P", 4);
await _sut.SetupChainAsync("P", default);
// Mark c0 Done so c1 was unblocked; c1 ran and failed.
await using (var ctx = _factory.CreateDbContext())
{
var c0 = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
c0.Status = TaskStatus.Done;
c0.BlockedByTaskId = null;
var c1 = await ctx.Tasks.FirstAsync(t => t.Id == "P-c1");
c1.Status = TaskStatus.Failed;
c1.BlockedByTaskId = null;
await ctx.SaveChangesAsync();
}
// Announce that c1 finished as Failed — the coordinator must cascade to c2/c3.
var advanced = await _sut.OnChildFinishedAsync("P-c1", TaskStatus.Failed, default);
Assert.Null(advanced);
var kids = await GetChildrenAsync("P");
Assert.Equal(TaskStatus.Done, kids[0].Status);
Assert.Equal(TaskStatus.Failed, kids[1].Status);
Assert.Equal(TaskStatus.Cancelled, kids[2].Status);
Assert.Equal(TaskStatus.Cancelled, kids[3].Status);
}
[Fact]
public async Task OnChildDone_LastChild_ReturnsNull()
{
await SeedPlanningFamilyAsync("P", 2);
await _sut.SetupChainAsync("P", default);
await using (var ctx = _factory.CreateDbContext())
{
foreach (var t in ctx.Tasks.Where(t => t.ParentTaskId == "P"))
t.Status = TaskStatus.Done;
await ctx.SaveChangesAsync();
}
var advanced = await _sut.OnChildFinishedAsync("P-c1", TaskStatus.Done, default);
Assert.Null(advanced);
}
[Fact]
public async Task SetupChain_RejectsRunningChildren()
{
await SeedPlanningFamilyAsync("P", 2);
await using (var ctx = _factory.CreateDbContext())
{
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
first.Status = TaskStatus.Running;
await ctx.SaveChangesAsync();
}
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);
}
[Fact]
public async Task QueuePlan_WhenParentFinalized_BuildsChain()
{
await SeedPlanningFamilyAsync("P", 2);
var count = await _sut.QueuePlanAsync("P", default);
Assert.Equal(2, 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);
}
[Fact]
public async Task QueuePlan_WhenParentNotFinalized_Throws_AndLeavesChildrenIdle()
{
await SeedPlanningFamilyAsync("P", 2);
await using (var ctx = _factory.CreateDbContext())
{
var parent = await ctx.Tasks.FirstAsync(t => t.Id == "P");
parent.PlanningPhase = PlanningPhase.Active;
await ctx.SaveChangesAsync();
}
await Assert.ThrowsAsync<InvalidOperationException>(
() => _sut.QueuePlanAsync("P", default));
var kids = await GetChildrenAsync("P");
Assert.All(kids, k => Assert.Equal(TaskStatus.Idle, k.Status));
}
}