From cc823ec4f65541fc1cea7da237b0cfd8f92a82c6 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 27 Jul 2026 12:45:45 +0200 Subject: [PATCH] feat(worker): allow update_task_status to set Cancelled --- .../External/ExternalMcpService.cs | 15 ++++++++--- .../State/Interfaces/ITaskStateService.cs | 2 +- src/ClaudeDo.Worker/State/TaskStateService.cs | 11 ++++++-- .../External/ExternalMcpServiceTests.cs | 26 +++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index ead1a174..11f26a7d 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -262,9 +262,10 @@ 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, and review_task to act on a WaitingForReview task. " + - "Settable: Idle (reset to editable), Queued (enqueue for execution). " + + "Update a task's status. Only 'Idle', 'Queued' and 'Cancelled' are permitted externally — " + + "use run_task_now for execution control, and review_task to act on a WaitingForReview task. " + + "Settable: Idle (reset to editable), Queued (enqueue for execution), " + + "Cancelled (retire the task without deleting it; it can be reset to Idle later). " + "Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")] public async Task UpdateTaskStatus( string taskId, @@ -291,9 +292,15 @@ public sealed class ExternalMcpService throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task."); break; + case TaskStatus.Cancelled: + var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken, allowFromIdle: true); + if (!cancelResult.Ok) + throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task."); + break; + default: throw new InvalidOperationException( - $"Status '{target}' is not settable externally. Use run_task_now or cancel_task."); + $"Status '{target}' is not settable externally. Use run_task_now or review_task."); } var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; diff --git a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs index eba6e2e0..18061ad6 100644 --- a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs +++ b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs @@ -9,7 +9,7 @@ public interface ITaskStateService Task SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, CancellationToken ct); Task SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct); Task FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct); - Task CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct); + Task CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false); Task ResetToIdleAsync(string taskId, CancellationToken ct); Task ApproveReviewAsync(string taskId, CancellationToken ct); diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index 6e0f1a15..2557fd26 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -241,7 +241,13 @@ public sealed class TaskStateService : ITaskStateService return new TransitionResult(true, null); } - public async Task CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct) + // allowFromIdle: only the external update_task_status(Cancelled) path sets this — it lets + // an Idle task be retired without deleting it. Every other caller (hub CancelReview, + // PlanningChainCoordinator's chain-walk, batch cancel) relies on Idle staying a no-op here; + // PlanningChainCoordinator specifically uses "parked back to Idle" as a deliberate opt-out + // signal, so do not flip this default. + public async Task CancelAsync( + string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false) { List cancelledChildIds; await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) @@ -250,7 +256,8 @@ public sealed class TaskStateService : ITaskStateService .Where(t => t.Id == taskId && (t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued || t.Status == TaskStatus.WaitingForReview - || t.Status == TaskStatus.WaitingForChildren)) + || t.Status == TaskStatus.WaitingForChildren + || (allowFromIdle && t.Status == TaskStatus.Idle))) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Cancelled) .SetProperty(t => t.FinishedAt, finishedAt), ct); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 96ad8f59..c565c634 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -380,6 +380,32 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.Equal(TaskStatus.WaitingForReview, reloadedParent!.Status); } + [Fact] + public async Task UpdateTaskStatus_Cancelled_CancelsAnIdleTask() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None); + + Assert.Equal("Cancelled", dto.Status); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Cancelled, loaded!.Status); + } + + [Fact] + public async Task UpdateTaskStatus_Done_StillRejected() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None)); + Assert.Contains("not settable externally", ex.Message); + } + private ExternalMcpService NewService() => BuildSut(CreateQueue()); private async Task SeedIdleTask(string title = "t")