Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Runner/FailureDiagnosisTests.cs
T
mika kuns a067a7bfff fix(mcp): report a Failed task's failureReason instead of a bare status
get_task/batch_get_tasks now return failureReason (max_turns|timeout|error|
cancelled|unknown) plus failureTurnsUsed/failureMaxTurns on a Failed task, so
max_turns (worktree usually fine, continue_task) is distinguishable from a
real error (reset_failed_task) without pulling get_task_log's raw NDJSON.
Classified and stamped onto TaskEntity by TaskRunner.MarkFailed via
TaskStateService.FailAsync; TaskRunEntity also keeps the CLI's raw
terminal_reason/result_subtype/errors for deeper diagnosis. reset_failed_task's
description now warns explicitly that it discards the worktree and points at
continue_task for max_turns. Surfaced on the task card's status-chip tooltip.
2026-08-10 14:20:00 +02:00

286 lines
12 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);
}
[Theory]
[InlineData("max_turns", "max_turns")]
[InlineData("timeout", "timeout")]
[InlineData("api_error", "error")]
[InlineData("some_new_reason", "error")]
[InlineData(null, "error")]
public void ClassifyFailureReason_Maps_TerminalReason_To_McpEnum(string? terminalReason, string expected)
{
Assert.Equal(expected, TaskRunner.ClassifyFailureReason(terminalReason));
}
}
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);
}
// The bug this feature fixes: get_task/batch_get_tasks must be able to tell error_max_turns
// apart from a real failure without pulling get_task_log. These two tests land a run of each
// kind and check the classified failureReason (plus turns/budget) that ends up on the task —
// exactly what those MCP tools read.
[Fact]
public async Task Max_Turns_Run_Sets_FailureReason_MaxTurns_With_Turns_And_Budget()
{
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",
ResultSubtype = "error_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.Equal("max_turns", task.FailureReason);
Assert.Equal(60, task.FailureTurnsUsed);
Assert.Equal(60, task.FailureMaxTurns);
var run = await new TaskRunRepository(verify).GetLatestByTaskIdAsync("t1");
Assert.Equal("max_turns", run!.TerminalReason);
Assert.Equal("error_max_turns", run.ResultSubtype);
}
[Fact]
public async Task Real_Error_Run_Sets_FailureReason_Error_Distinct_From_MaxTurns()
{
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("error", task.FailureReason);
Assert.NotEqual("max_turns", task.FailureReason);
}
}