Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Runner/FailureDiagnosisTests.cs
T
mika kuns 07dd75700d feat(worker): Toggle "Continue on session limit reset" für Usage-Limit-Abbrüche
Klassifiziert einen echten Usage-Limit-Abbruch als eigene FailureReason
"usage_limit" (TaskRunner.ClassifyFailureReason: nur bei terminal_reason
"api_error" plus einem Limit-Muster im gerenderten Fehlertext, nicht an
Status==Failed allein). Neuer Toggle AutoContinueOnUsageLimit (app_settings,
Default aus) unter Settings → General → "Usage limit stop":

- UsageLimitAutoContinueCoordinator feuert pro Task genau einmal ContinueTask
  über OverrideSlotService, sobald das 5h-Fenster (UsageState.Snapshot.FiveHour
  .ResetsAt) tatsächlich zurückgesetzt ist; ein persistenter Marker
  (TaskEntity.UsageLimitAutoContinuedAt) verhindert einen zweiten Anlauf bei
  einem erneuten Limit-Treffer.
- QueueService schedult zusätzlich einen exakten Wake-Timer auf den
  Reset-Zeitpunkt, statt nur auf den 30s-Backstop zu warten.
- Fail-open durchgängig: kein Snapshot/keine Reset-Zeit → kein Timer, kein
  Continue, kein Throw. Toggle aus ändert das heutige Verhalten nicht.

Migration AddUsageLimitAutoContinue fügt beide Spalten hinzu; die von
`dotnet ef migrations add` mitgescaffoldete leere UpdateData auf app_settings
(columns/values: []) erzeugte ungültiges SQL ("near WHERE") und wurde entfernt
— TaskNumberMigrationTests deckte das über den vollen Migrate()-Pfad auf.
2026-08-21 18:43:02 +02:00

337 lines
14 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));
}
[Theory]
[InlineData("You've hit your session limit resets 1pm (Europe/Berlin)")]
[InlineData("Claude usage limit reached, resets at 3pm")]
[InlineData("rate limited, please retry later")]
public void ClassifyFailureReason_ApiError_With_Limit_Text_Returns_UsageLimit(string messageText)
{
Assert.Equal("usage_limit", TaskRunner.ClassifyFailureReason("api_error", messageText));
}
[Fact]
public void ClassifyFailureReason_ApiError_With_Generic_Text_Stays_Error()
{
Assert.Equal("error", TaskRunner.ClassifyFailureReason("api_error", "Internal server error, please retry"));
}
[Theory]
[InlineData("max_turns")]
[InlineData("timeout")]
public void ClassifyFailureReason_Ignores_Limit_Text_For_NonApiError_Reasons(string terminalReason)
{
Assert.Equal(terminalReason, TaskRunner.ClassifyFailureReason(terminalReason, "usage limit reached"));
}
}
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);
}
[Fact]
public async Task Usage_Limit_Run_Sets_FailureReason_UsageLimit()
{
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.Equal("usage_limit", task.FailureReason);
}
}