feat(worker): add review_task MCP tool and status reference updates
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -93,7 +93,7 @@ public sealed class ExternalMcpService
|
||||
|
||||
[McpServerTool, Description(
|
||||
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
|
||||
"Valid status values: Idle, Queued, Running, Done, Failed, Cancelled.")]
|
||||
"Valid status values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled.")]
|
||||
public async Task<IReadOnlyList<TaskDto>> ListTasks(
|
||||
string listId,
|
||||
string? createdBy,
|
||||
@@ -105,7 +105,7 @@ public sealed class ExternalMcpService
|
||||
{
|
||||
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled.");
|
||||
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled.");
|
||||
statusFilter = parsed;
|
||||
}
|
||||
|
||||
@@ -121,7 +121,8 @@ public sealed class ExternalMcpService
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Get a single task by id, including its current status and result. " +
|
||||
"Status lifecycle: Idle → Queued → Running → Done | Failed | Cancelled. " +
|
||||
"Status lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
|
||||
"A successful run lands in WaitingForReview; use review_task to approve, reject, or cancel. " +
|
||||
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")]
|
||||
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -197,9 +198,9 @@ public sealed class ExternalMcpService
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " +
|
||||
"use run_task_now or cancel_task for execution control. " +
|
||||
"use run_task_now or cancel_task for execution control, and review_task to act on a WaitingForReview task. " +
|
||||
"Settable: Idle (reset to editable), Queued (enqueue for execution). " +
|
||||
"Full lifecycle: Idle → Queued → Running → Done | Failed | Cancelled.")]
|
||||
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")]
|
||||
public async Task<TaskDto> UpdateTaskStatus(
|
||||
string taskId,
|
||||
string status,
|
||||
@@ -234,6 +235,38 @@ public sealed class ExternalMcpService
|
||||
return ToDto(reload);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Review a task that is WaitingForReview. " +
|
||||
"decision='approve' → Done. " +
|
||||
"decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
|
||||
"decision='reject_park' → Idle for manual editing (feedback ignored). " +
|
||||
"decision='cancel' → Cancelled. " +
|
||||
"Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued).")]
|
||||
public async Task<TaskDto> ReviewTask(
|
||||
string taskId,
|
||||
string decision,
|
||||
string? feedback,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
TransitionResult result = decision.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"approve" => await _state.ApproveReviewAsync(taskId, cancellationToken),
|
||||
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
|
||||
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
|
||||
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
|
||||
};
|
||||
|
||||
if (!result.Ok)
|
||||
throw new InvalidOperationException(result.Reason ?? "Review action failed.");
|
||||
|
||||
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")]
|
||||
public async Task RunTaskNow(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -281,7 +314,8 @@ public sealed class ExternalMcpService
|
||||
new("Idle", "Not yet queued; task is editable and will not run until enqueued."),
|
||||
new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
|
||||
new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
|
||||
new("Done", "Completed successfully; result text is available in the result field. Can be reset to Idle for re-execution."),
|
||||
new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."),
|
||||
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
|
||||
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
|
||||
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
|
||||
]);
|
||||
|
||||
@@ -171,6 +171,54 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
sut.UpdateTask("does-not-exist", "x", null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReviewTask_Approve_SetsDone()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.ReviewTask(task.Id, "approve", null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("Done", dto.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReviewTask_RejectRerun_WithoutFeedback_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.ReviewTask(task.Id, "reject_rerun", null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReviewTask_RejectRerun_QueuesAndStoresFeedback()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", CancellationToken.None);
|
||||
|
||||
Assert.Equal("Queued", dto.Status);
|
||||
var loaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
|
||||
Assert.Equal("fix it", loaded!.ReviewFeedback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReviewTask_UnknownDecision_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.ReviewTask(task.Id, "bogus", null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTask_RemovesTask()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user