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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user