fix(worker): close status-force bypasses on Running tasks

update_task_status Idle/Done, WorkerHub.SetTaskStatus, and the UI's Mark
Done/Cancelled menu items could all force a Running task's status without
going through TaskStateService's guards, leaving its CLI process untracked
and its worktree writes silently dropped. Idle now routes through the
already-guarded TaskStateService.ResetToIdleAsync instead of the repo's
unconditional ResetToManualAsync; Done and SetTaskStatus explicitly reject
Running; the context menu disables both entries with a tooltip while
running. TaskRunner also now logs a warning whenever a terminal transition
(SubmitForChildren/SubmitForReview/Complete/Fail) is rejected instead of
silently discarding the run result. DeleteTask's MCP path gets the same
friendly foreign-key message WorkerHub.DeleteTask already had for a task
with children.
This commit is contained in:
Mika Kuns
2026-08-26 15:01:13 +02:00
parent c4928b4def
commit 299c7b749c
7 changed files with 183 additions and 11 deletions
@@ -900,6 +900,21 @@ public sealed class ExternalMcpServiceTests : IDisposable
sut.DeleteTask(task.Id, CancellationToken.None));
}
[Fact]
public async Task DeleteTask_WithChildren_ThrowsUnderstandableMessage()
{
var listId = await SeedListAsync();
var parent = await SeedTaskAsync(listId, status: TaskStatus.WaitingForChildren);
await SeedTaskAsync(listId, status: TaskStatus.Idle, parentId: parent.Id);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.DeleteTask(parent.Id, CancellationToken.None));
Assert.Contains("child task", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.NotNull(await _tasks.GetByIdAsync(parent.Id));
}
[Fact]
public async Task DeleteTask_NotFound_Throws()
{
@@ -981,6 +996,35 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Equal(TaskStatus.WaitingForReview, loaded!.Status);
}
[Fact]
public async Task UpdateTaskStatus_Idle_OnRunningTask_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.UpdateTaskStatus(task.Id, "Idle", CancellationToken.None));
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Running, loaded!.Status);
}
[Fact]
public async Task UpdateTaskStatus_Done_OnRunningTask_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Running, loaded!.Status);
}
private ExternalMcpService NewService() => BuildSut(CreateQueue());
private async Task<string> SeedIdleTask(string title = "t")
@@ -0,0 +1,86 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Hub;
public sealed class SetTaskStatusHubTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
private WorkerHub CreateHub()
{
var factory = _db.CreateFactory();
var built = TaskStateServiceBuilder.Build(factory);
var broadcaster = new HubBroadcaster(new CapturingHubContext());
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, factory,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
built.State,
null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;
}
private async Task<string> SeedTaskAsync(TaskStatus status)
{
using var ctx = _db.CreateContext();
var listId = Guid.NewGuid().ToString();
await new ListRepository(ctx).AddAsync(new ListEntity
{
Id = listId, Name = "L", CreatedAt = DateTime.UtcNow,
});
var taskId = Guid.NewGuid().ToString();
await new TaskRepository(ctx).AddAsync(new TaskEntity
{
Id = taskId, ListId = listId, Title = "T",
Status = status, CreatedAt = DateTime.UtcNow, CommitType = "feat",
});
return taskId;
}
[Fact]
public async Task SetTaskStatus_RunningTask_Throws_AndLeavesStatusUnchanged()
{
var taskId = await SeedTaskAsync(TaskStatus.Running);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.SetTaskStatus(taskId, "Done"));
await using var ctx = _db.CreateContext();
var task = await ctx.Tasks.FindAsync(taskId);
Assert.Equal(TaskStatus.Running, task!.Status);
}
[Fact]
public async Task SetTaskStatus_IdleTask_StillSucceeds()
{
var taskId = await SeedTaskAsync(TaskStatus.Idle);
var hub = CreateHub();
await hub.SetTaskStatus(taskId, "Cancelled");
await using var ctx = _db.CreateContext();
var task = await ctx.Tasks.FindAsync(taskId);
Assert.Equal(TaskStatus.Cancelled, task!.Status);
}
[Fact]
public async Task SetTaskStatus_Missing_Throws()
{
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.SetTaskStatus("does-not-exist", "Done"));
}
}