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:
mika kuns
2026-06-01 17:17:56 +02:00
parent 9c1f20f2d9
commit c88ed9d5eb
2 changed files with 88 additions and 6 deletions

View File

@@ -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."),
]);

View File

@@ -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()
{