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")