fix(worker): route done-toggle and dequeue through guarded TaskStateService transitions

The task-list done toggle (both islands) and RemoveFromQueue wrote TaskEntity.Status
directly via EF, bypassing TaskStateService: no TaskUpdated broadcast, no guard against
a concurrent picker claim (lost update), and no status-based filter. Added guarded
MarkDoneAsync/UnmarkDoneAsync/DequeueToIdleAsync transitions plus matching hub methods
(SetTaskDone/UnsetTaskDone/DequeueTask) and IWorkerClient wrappers; the three UI call
sites now route through the hub with optimistic-then-revert row updates and
ErrorReported on failure. RemoveFromQueueAsync dequeues each queued child individually
through the same guarded path instead of cascading via a raw EF update.

Also closes two hub guard gaps: UpdateListConfig's delete branch now preserves a list's
SerializeOnFileOverlap flag instead of dropping it, and SubmitTaskForReview's Idle/Failed
status gate now runs before either mutation branch so a Done/Cancelled task can't get
committed or stamped and then rejected.
This commit is contained in:
mika kuns
2026-08-21 11:47:24 +02:00
parent ce98f65ca5
commit 7f337b36c7
20 changed files with 806 additions and 49 deletions
@@ -214,6 +214,51 @@ public sealed class MergeHelperTaskHubTests : IDisposable
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
}
// The status gate (Idle or Failed) must run before either mutation branch below, so a
// Done/Cancelled task can never get its worktree committed or its HandlerHeadCommit stamped
// and then rejected by SubmitInteractiveForReviewAsync, stranding the work.
[Fact]
public async Task SubmitTaskForReview_DoneTask_WorktreePath_ThrowsBeforeCommit_WorktreeUntouched()
{
var listId = await SeedListAsync(Path.GetTempPath());
var task = await SeedTaskAsync(listId, TaskStatus.Done);
var worktree = new WorktreeEntity
{
TaskId = task.Id,
Path = Path.Combine(Path.GetTempPath(), $"cd_no_such_worktree_{Guid.NewGuid():N}"),
BranchName = "claudedo/does-not-matter",
BaseCommit = "abc123",
State = WorktreeState.Active,
CreatedAt = DateTime.UtcNow,
};
await new WorktreeRepository(_ctx).AddAsync(worktree);
var hub = CreateHub();
var ex = await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
Assert.Contains("Idle or Failed", ex.Message);
var reloadedWorktree = await new WorktreeRepository(_ctx).GetByTaskIdAsync(task.Id);
Assert.Null(reloadedWorktree!.HeadCommit);
var reloadedTask = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, reloadedTask!.Status);
}
[Fact]
public async Task SubmitTaskForReview_CancelledTask_HandlerPath_ThrowsBeforeStamp_HandlerHeadCommitUntouched()
{
var listId = await SeedListAsync(Path.GetTempPath());
var task = await SeedTaskAsync(listId, TaskStatus.Cancelled, handlerBaseCommit: "abc123");
var hub = CreateHub();
var ex = await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
Assert.Contains("Idle or Failed", ex.Message);
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Null(reloaded!.HandlerHeadCommit);
Assert.Equal(TaskStatus.Cancelled, reloaded.Status);
}
}
// RecordingClientProxy / FakeHubCallerClients / FakeHubCallerContext are defined once for the