Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Runner/FailureDiagnosisTests.cs
T
mika kuns f6cb8250bb fix(tests): pass FakeTranscriptUsageReader to TaskRunner in FailureDiagnosisTests
The cache-token task added an ITranscriptUsageReader ctor param to TaskRunner
while the failure-diagnosis task added this test file. Both branches were green
in isolation; the merged result did not compile.
2026-08-05 16:15:50 +02:00

207 lines
8.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Xunit;
namespace ClaudeDo.Worker.Tests.Runner;
public class FailureDiagnosisTests
{
[Fact]
public void Max_Turns_Message_Names_The_Limit_And_Requeue()
{
var result = new RunResult { ExitCode = 1, TerminalReason = "max_turns", TurnCount = 55 };
var message = TaskRunner.BuildFailureMarkdown(result, configuredMaxTurns: 60);
Assert.Contains("55", message);
Assert.Contains("60", message);
Assert.Contains("maxTurns", message);
Assert.Contains("Queue", message);
Assert.DoesNotContain(ClaudeProcess.NoResultPrefix, message);
}
[Fact]
public void Max_Turns_Message_Without_Configured_Limit_Still_Shows_Actual_Turns()
{
var result = new RunResult { ExitCode = 1, TerminalReason = "max_turns", TurnCount = 30 };
var message = TaskRunner.BuildFailureMarkdown(result, configuredMaxTurns: null);
Assert.Contains("30", message);
}
[Fact]
public void Api_Error_Passes_Through_Provider_Text_With_Reset_Time()
{
var result = new RunResult
{
ExitCode = 1,
TerminalReason = "api_error",
ResultMarkdown = "You've hit your session limit resets 1pm (Europe/Berlin)",
ErrorMarkdown = $"{ClaudeProcess.NoResultPrefix} 1 and no result.",
};
var message = TaskRunner.BuildFailureMarkdown(result, configuredMaxTurns: 60);
Assert.Equal("You've hit your session limit resets 1pm (Europe/Berlin)", message);
}
[Fact]
public void Api_Error_Falls_Back_To_Errors_List_When_No_Result_Text()
{
var result = new RunResult
{
ExitCode = 1,
TerminalReason = "api_error",
Errors = new[] { "rate limited" },
ErrorMarkdown = $"{ClaudeProcess.NoResultPrefix} 1 and no result.",
};
var message = TaskRunner.BuildFailureMarkdown(result, configuredMaxTurns: 60);
Assert.Equal("rate limited", message);
}
[Fact]
public void Unknown_Reason_Keeps_Existing_Text_And_Appends_Terminal_Reason()
{
var result = new RunResult
{
ExitCode = 1,
TerminalReason = "some_new_reason",
ErrorMarkdown = "stderr output here",
};
var message = TaskRunner.BuildFailureMarkdown(result, configuredMaxTurns: 60);
Assert.Contains("stderr output here", message);
Assert.Contains("some_new_reason", message);
}
[Fact]
public void No_Terminal_Reason_Behaves_As_Before()
{
var result = new RunResult
{
ExitCode = 1,
ErrorMarkdown = $"{ClaudeProcess.NoResultPrefix} 1 and no result.",
};
var message = TaskRunner.BuildFailureMarkdown(result, configuredMaxTurns: 60);
Assert.Equal($"{ClaudeProcess.NoResultPrefix} 1 and no result.", message);
}
}
public sealed class FailureDiagnosisEndToEndTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly WorkerConfig _cfg;
private readonly string _tempDir;
public FailureDiagnosisEndToEndTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"cd_faildiag_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
_cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir };
}
public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } }
private TaskRunner MakeRunner(IDbContextFactory<ClaudeDoDbContext> dbFactory, FakeClaudeProcess fake)
{
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
return new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt,
new ClaudeArgsBuilder(), _cfg, NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(),
new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
}
[Fact]
public async Task Max_Turns_Failure_Surfaces_Specific_Message_On_Task_And_Run()
{
var dbFactory = _db.CreateFactory();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "T", MaxTurns = 60,
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
var fake = new FakeClaudeProcess((_, _, _, _, _) => Task.FromResult(new RunResult
{
ExitCode = 1,
TerminalReason = "max_turns",
Errors = new[] { "Reached maximum number of turns (60)" },
TurnCount = 60,
}));
var runner = MakeRunner(dbFactory, fake);
using (var ctx = _db.CreateContext())
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", default, alreadyClaimed: true);
using var verify = _db.CreateContext();
var task = await new TaskRepository(verify).GetByIdAsync("t1");
Assert.Equal(TaskStatus.Failed, task!.Status);
Assert.Contains("Turn-Budget", task.Result);
Assert.Contains("60", task.Result);
Assert.DoesNotContain(ClaudeProcess.NoResultPrefix, task.Result);
var run = await new TaskRunRepository(verify).GetLatestByTaskIdAsync("t1");
Assert.Contains("Turn-Budget", run!.ErrorMarkdown);
}
[Fact]
public async Task Api_Error_Failure_Surfaces_Provider_Text_With_Reset_Time()
{
var dbFactory = _db.CreateFactory();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "T",
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
var fake = new FakeClaudeProcess((_, _, _, _, _) => Task.FromResult(new RunResult
{
ExitCode = 1,
TerminalReason = "api_error",
ResultMarkdown = "You've hit your session limit resets 1pm (Europe/Berlin)",
}));
var runner = MakeRunner(dbFactory, fake);
using (var ctx = _db.CreateContext())
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", default, alreadyClaimed: true);
using var verify = _db.CreateContext();
var task = await new TaskRepository(verify).GetByIdAsync("t1");
Assert.Equal(TaskStatus.Failed, task!.Status);
Assert.Contains("session limit", task.Result);
Assert.Contains("Europe/Berlin", task.Result);
}
[Fact]
public async Task Crash_Without_Result_Line_Keeps_Generic_Message()
{
var dbFactory = _db.CreateFactory();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "T",
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
var fake = new FakeClaudeProcess((_, _, _, _, _) => Task.FromResult(new RunResult
{
ExitCode = 1,
ErrorMarkdown = $"{ClaudeProcess.NoResultPrefix} 1 and no result.",
}));
var runner = MakeRunner(dbFactory, fake);
using (var ctx = _db.CreateContext())
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", default, alreadyClaimed: true);
using var verify = _db.CreateContext();
var task = await new TaskRepository(verify).GetByIdAsync("t1");
Assert.Equal(TaskStatus.Failed, task!.Status);
Assert.Equal($"{ClaudeProcess.NoResultPrefix} 1 and no result.", task.Result);
}
}