feat(mcp): allow Done via update_task_status with worktree guard
External tasks finished outside a ClaudeDo run had no way to close out their tracking task; update_task_status now permits Done alongside Idle/Queued/Cancelled, refusing it when the task has an active worktree so review_task stays the only path that merges.
This commit is contained in:
@@ -189,6 +189,22 @@ public sealed class BatchMcpToolsTests : IDisposable
|
||||
Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t2.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateTaskStatus_Done_MixedWorktreeState_ReportsPerItemAndDoesNotAbort()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var noWorktree = await SeedTaskAsync(listId, "no-wt", TaskStatus.Idle);
|
||||
var missing = "does-not-exist";
|
||||
var sut = BuildSut();
|
||||
|
||||
var results = await sut.BatchUpdateTaskStatus(
|
||||
new[] { noWorktree.Id, missing }, "Done", CancellationToken.None);
|
||||
|
||||
Assert.True(results.Single(r => r.TaskId == noWorktree.Id).Ok);
|
||||
Assert.False(results.Single(r => r.TaskId == missing).Ok);
|
||||
Assert.Equal(TaskStatus.Done, (await _tasks.GetByIdAsync(noWorktree.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchTools_RejectEmptyBatch()
|
||||
{
|
||||
|
||||
@@ -395,17 +395,47 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTaskStatus_Done_StillRejected()
|
||||
public async Task UpdateTaskStatus_WaitingForReview_StillRejected()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
|
||||
() => sut.UpdateTaskStatus(task.Id, "WaitingForReview", CancellationToken.None));
|
||||
Assert.Contains("not settable externally", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTaskStatus_Done_NoWorktree_SetsDoneAndFinishedAt()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None);
|
||||
|
||||
Assert.Equal("Done", dto.Status);
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.Done, loaded!.Status);
|
||||
Assert.NotNull(loaded.FinishedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTaskStatus_Done_WithActiveWorktree_Throws()
|
||||
{
|
||||
if (!GitAvailable) return;
|
||||
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
|
||||
|
||||
Assert.Contains("active worktree", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.WaitingForReview, loaded!.Status);
|
||||
}
|
||||
|
||||
private ExternalMcpService NewService() => BuildSut(CreateQueue());
|
||||
|
||||
private async Task<string> SeedIdleTask(string title = "t")
|
||||
|
||||
Reference in New Issue
Block a user