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(
|
[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."),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user