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>
This commit is contained in:
@@ -125,7 +125,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnChildFailed_DoesNotAdvanceChain()
|
||||
public async Task OnChildFailed_CancelsPendingSuccessors_ChainIsNotWedged()
|
||||
{
|
||||
await SeedPlanningFamilyAsync("P", 3);
|
||||
await _sut.SetupChainAsync("P", default);
|
||||
@@ -139,12 +139,43 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
||||
|
||||
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 remain blocked on the failed predecessor.
|
||||
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
|
||||
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
|
||||
// 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]
|
||||
|
||||
Reference in New Issue
Block a user