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