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:
@@ -175,12 +175,14 @@ public sealed class TaskRunner
|
||||
}
|
||||
else
|
||||
{
|
||||
await MarkFailed(task.Id, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount);
|
||||
await MarkFailed(task.Id, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount,
|
||||
retryConfig.MaxTurns, ClassifyFailureReason(retryResult.TerminalReason));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await MarkFailed(task.Id, task.Title, slot, result.ErrorMarkdown, result.TurnCount);
|
||||
await MarkFailed(task.Id, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
|
||||
resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +191,7 @@ public sealed class TaskRunner
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Task {TaskId} was cancelled", task.Id);
|
||||
await MarkFailed(task.Id, task.Title, slot, "Task cancelled.");
|
||||
await MarkFailed(task.Id, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -284,7 +286,8 @@ public sealed class TaskRunner
|
||||
}
|
||||
else
|
||||
{
|
||||
await MarkFailed(taskId, task.Title, slot, result.ErrorMarkdown, result.TurnCount);
|
||||
await MarkFailed(taskId, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
|
||||
resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason));
|
||||
}
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
@@ -292,7 +295,7 @@ public sealed class TaskRunner
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Task {TaskId} was cancelled during continue", taskId);
|
||||
await MarkFailed(taskId, task.Title, slot, "Task cancelled.");
|
||||
await MarkFailed(taskId, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -388,6 +391,9 @@ public sealed class TaskRunner
|
||||
run.ErrorMarkdown = result.ErrorMarkdown;
|
||||
run.ExitCode = result.ExitCode;
|
||||
run.TurnCount = result.TurnCount;
|
||||
run.ResultSubtype = result.ResultSubtype;
|
||||
run.TerminalReason = result.TerminalReason;
|
||||
run.Errors = result.Errors.Count > 0 ? string.Join("\n", result.Errors) : null;
|
||||
if (result.SessionId is not null)
|
||||
await ApplyUsageAsync(run, taskId, result.SessionId);
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
@@ -405,6 +411,7 @@ public sealed class TaskRunner
|
||||
// Ensure the run row is completed so ContinueAsync / inspection
|
||||
// isn't left staring at a null session_id / finished_at.
|
||||
run.ErrorMarkdown = "Cancelled.";
|
||||
run.TerminalReason = "cancelled";
|
||||
run.ExitCode = -1;
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
try
|
||||
@@ -517,14 +524,17 @@ public sealed class TaskRunner
|
||||
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
|
||||
}
|
||||
|
||||
private async Task MarkFailed(string taskId, string taskTitle, string slot, string? error, int turnCount = 0)
|
||||
private async Task MarkFailed(
|
||||
string taskId, string taskTitle, string slot, string? error, int turnCount = 0,
|
||||
int? maxTurns = null, string failureReason = "error")
|
||||
{
|
||||
// Terminal write for a failed task: never cancel (the status must always
|
||||
// be persisted) and never throw (a logging failure must not mask the error).
|
||||
try
|
||||
{
|
||||
var finishedAt = DateTime.UtcNow;
|
||||
await _state.FailAsync(taskId, finishedAt, error, CancellationToken.None);
|
||||
await _state.FailAsync(taskId, finishedAt, error, CancellationToken.None,
|
||||
failureReason, turnCount > 0 ? turnCount : null, maxTurns);
|
||||
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
|
||||
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
|
||||
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, turnCount, error);
|
||||
@@ -535,6 +545,16 @@ public sealed class TaskRunner
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Classifies the CLI's raw <c>terminal_reason</c> into the small, MCP-facing enum
|
||||
/// (<c>max_turns|timeout|error</c>) get_task/batch_get_tasks report as failureReason.
|
||||
/// "cancelled" is set explicitly at the call sites that know it (there's no CLI signal for it).</summary>
|
||||
internal static string ClassifyFailureReason(string? terminalReason) => terminalReason switch
|
||||
{
|
||||
"max_turns" => "max_turns",
|
||||
"timeout" => "timeout",
|
||||
_ => "error",
|
||||
};
|
||||
|
||||
private string BuildRunMcpConfigJson(string token)
|
||||
{
|
||||
var payload = new
|
||||
|
||||
Reference in New Issue
Block a user