Merge branch 'claudedo/16172942c1424a67a0499e01cefb7d60'

This commit is contained in:
mika kuns
2026-08-10 15:08:51 +02:00
19 changed files with 1295 additions and 24 deletions
@@ -227,6 +227,68 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Equal("the full description text", dto.Description);
}
[Fact]
public async Task GetTask_Failed_ReturnsClassifiedFailureReason()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Failed);
task.FailureReason = "max_turns";
task.FailureTurnsUsed = 55;
task.FailureMaxTurns = 60;
await _tasks.UpdateAsync(task, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var dto = await sut.GetTask(task.Id, CancellationToken.None);
Assert.Equal("max_turns", dto.FailureReason);
Assert.Equal(55, dto.FailureTurnsUsed);
Assert.Equal(60, dto.FailureMaxTurns);
}
[Fact]
public async Task GetTask_FailedWithoutStoredReason_ReturnsUnknownNotError()
{
// A Failed task written before this field existed has FailureReason=null in the DB —
// the MCP surface must still hand back a defined value, not null or a throw.
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Failed);
var sut = BuildSut(CreateQueue());
var dto = await sut.GetTask(task.Id, CancellationToken.None);
Assert.Equal("unknown", dto.FailureReason);
Assert.Null(dto.FailureTurnsUsed);
Assert.Null(dto.FailureMaxTurns);
}
[Fact]
public async Task GetTask_NotFailed_FailureReasonIsNull()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
var sut = BuildSut(CreateQueue());
var dto = await sut.GetTask(task.Id, CancellationToken.None);
Assert.Null(dto.FailureReason);
}
[Fact]
public async Task GetTaskRefAsync_Failed_ReturnsClassifiedFailureReason()
{
// GetTaskRefAsync backs batch_get_tasks' default (lean) path — the same field must be
// present there, not just on the full get_task DTO.
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Failed);
task.FailureReason = "error";
await _tasks.UpdateAsync(task, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var dto = await sut.GetTaskRefAsync(task.Id, CancellationToken.None);
Assert.Equal("error", dto.FailureReason);
}
[Fact]
public async Task UpdateTask_OnRunning_Throws()
{
@@ -88,6 +88,17 @@ public class FailureDiagnosisTests
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
@@ -203,4 +214,72 @@ public sealed class FailureDiagnosisEndToEndTests : IDisposable
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);
}
}