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.
This commit is contained in:
@@ -224,6 +224,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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user