diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 1102245..f30933d 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -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> ListTasks( string listId, string? createdBy, @@ -105,7 +105,7 @@ public sealed class ExternalMcpService { if (!Enum.TryParse(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 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 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 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."), ]); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 55a4f78..e792ff8 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -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(() => + 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(() => + sut.ReviewTask(task.Id, "bogus", null, CancellationToken.None)); + } + [Fact] public async Task DeleteTask_RemovesTask() {