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( [McpServerTool, Description(
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " + "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( public async Task<IReadOnlyList<TaskDto>> ListTasks(
string listId, string listId,
string? createdBy, string? createdBy,
@@ -105,7 +105,7 @@ public sealed class ExternalMcpService
{ {
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed)) if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
throw new InvalidOperationException( 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; statusFilter = parsed;
} }
@@ -121,7 +121,8 @@ public sealed class ExternalMcpService
[McpServerTool, Description( [McpServerTool, Description(
"Get a single task by id, including its current status and result. " + "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.")] "Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")]
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken) public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
{ {
@@ -197,9 +198,9 @@ public sealed class ExternalMcpService
[McpServerTool, Description( [McpServerTool, Description(
"Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " + "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). " + "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( public async Task<TaskDto> UpdateTaskStatus(
string taskId, string taskId,
string status, string status,
@@ -234,6 +235,38 @@ public sealed class ExternalMcpService
return ToDto(reload); 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).")] [McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")]
public async Task RunTaskNow(string taskId, CancellationToken cancellationToken) 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("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("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("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("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."), 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)); 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] [Fact]
public async Task DeleteTask_RemovesTask() public async Task DeleteTask_RemovesTask()
{ {