Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.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

139 lines
4.0 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Refine;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Refine;
public sealed class RefineRunnerTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
public RefineRunnerTests()
{
_ctx = _db.CreateContext();
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private async Task<string> SeedListAsync()
{
var listId = Guid.NewGuid().ToString();
await new ListRepository(_ctx).AddAsync(new ListEntity
{
Id = listId,
Name = "Test",
CreatedAt = DateTime.UtcNow,
WorkingDir = null,
});
return listId;
}
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status)
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "Test task",
Status = status,
CreatedAt = DateTime.UtcNow,
};
await new TaskRepository(_ctx).AddAsync(task);
return task;
}
private RefineRunner BuildRunner(RecordingClaudeProcess claude, RecordingRefineBroadcaster broadcaster)
{
return new RefineRunner(
claude,
_db.CreateFactory(),
NullLogger<RefineRunner>.Instance,
broadcaster);
}
[Fact]
public async Task Refuses_when_task_not_idle()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, TaskStatus.Queued);
var claude = new RecordingClaudeProcess(success: true);
var broadcaster = new RecordingRefineBroadcaster();
var runner = BuildRunner(claude, broadcaster);
var outcome = await runner.RefineAsync(task.Id, CancellationToken.None);
Assert.False(outcome.Success);
Assert.Equal(0, claude.CallCount);
}
[Fact]
public async Task Idle_task_invokes_claude_once_and_brackets_with_events()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, TaskStatus.Idle);
var claude = new RecordingClaudeProcess(success: true);
var broadcaster = new RecordingRefineBroadcaster();
var runner = BuildRunner(claude, broadcaster);
var outcome = await runner.RefineAsync(task.Id, CancellationToken.None);
Assert.True(outcome.Success);
Assert.Equal(1, claude.CallCount);
Assert.Equal(1, broadcaster.StartedCount);
Assert.Equal(1, broadcaster.FinishedCount);
}
}
internal sealed class RecordingClaudeProcess : IClaudeProcess
{
private readonly bool _success;
private int _callCount;
public int CallCount => _callCount;
public RecordingClaudeProcess(bool success) => _success = success;
public Task<RunResult> RunAsync(IReadOnlyList<string> arguments, string prompt, string workingDirectory,
Func<string, Task> onStdoutLine, CancellationToken ct)
{
Interlocked.Increment(ref _callCount);
var result = _success
? new RunResult { ExitCode = 0, ResultMarkdown = "ok" }
: new RunResult { ExitCode = 1, ResultMarkdown = null };
return Task.FromResult(result);
}
}
internal sealed class RecordingRefineBroadcaster : IRefineBroadcaster
{
private int _startedCount;
private int _finishedCount;
public int StartedCount => _startedCount;
public int FinishedCount => _finishedCount;
public Task RefineStartedAsync(string taskId)
{
Interlocked.Increment(ref _startedCount);
return Task.CompletedTask;
}
public Task RefineFinishedAsync(string taskId, bool success, string? error)
{
Interlocked.Increment(ref _finishedCount);
return Task.CompletedTask;
}
}