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:
@@ -686,16 +686,18 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
var sessionSkills = SkillsToJson(dto.SessionSkills);
|
||||
var verifyCommand = dto.VerifyCommand.NullIfBlank();
|
||||
|
||||
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null)
|
||||
// Preserve SerializeOnFileOverlap: it has no UI/hub affordance yet (set via
|
||||
// set_list_config or directly against ListConfigEntity), so a save from this path
|
||||
// must not silently drop it -- neither by deleting the row nor by overwriting it.
|
||||
var existing = await repo.GetConfigAsync(dto.ListId);
|
||||
var hasUnrelatedSettings = existing?.SerializeOnFileOverlap ?? false;
|
||||
|
||||
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && !hasUnrelatedSettings)
|
||||
{
|
||||
await repo.DeleteConfigAsync(dto.ListId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Preserve SerializeOnFileOverlap: it has no UI/hub affordance yet (set via
|
||||
// set_list_config or directly against ListConfigEntity), so a save from this path
|
||||
// must not silently clear it.
|
||||
var existing = await repo.GetConfigAsync(dto.ListId);
|
||||
await repo.SetConfigAsync(new ListConfigEntity
|
||||
{
|
||||
ListId = dto.ListId,
|
||||
@@ -735,6 +737,28 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
result.BaseDirty is { } w ? new BaseDirtyWarningDto(w.ModifiedCount, w.UntrackedCount) : null);
|
||||
}
|
||||
|
||||
// Guarded "manual done toggle" affordance (task list checkbox) -- checks the expected
|
||||
// starting status server-side so a concurrent picker claim isn't silently overwritten.
|
||||
public async Task SetTaskDone(string taskId)
|
||||
{
|
||||
var result = await _state.MarkDoneAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted);
|
||||
if (!result.Ok) throw new HubException(result.Reason ?? "mark done failed");
|
||||
}
|
||||
|
||||
public async Task UnsetTaskDone(string taskId)
|
||||
{
|
||||
var result = await _state.UnmarkDoneAsync(taskId, Context.ConnectionAborted);
|
||||
if (!result.Ok) throw new HubException(result.Reason ?? "unmark done failed");
|
||||
}
|
||||
|
||||
// Guarded "remove from queue" affordance. Called once per row (parent, then each queued
|
||||
// child) by the UI so a cascade never bypasses the same-status guard.
|
||||
public async Task DequeueTask(string taskId)
|
||||
{
|
||||
var result = await _state.DequeueToIdleAsync(taskId, Context.ConnectionAborted);
|
||||
if (!result.Ok) throw new HubException(result.Reason ?? "dequeue failed");
|
||||
}
|
||||
|
||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
@@ -914,6 +938,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
throw new InvalidOperationException("Can't submit a running or queued task — interrupt it first.");
|
||||
if (task.Status is TaskStatus.WaitingForReview or TaskStatus.WaitingForChildren)
|
||||
throw new InvalidOperationException("Task is already awaiting review.");
|
||||
// SubmitInteractiveForReviewAsync below only accepts Idle/Failed -- check it up front too,
|
||||
// before either mutation branch, so a Done or Cancelled task can't get committed / have its
|
||||
// HandlerHeadCommit stamped and then be rejected, stranding the work on an orphaned branch.
|
||||
if (task.Status is not (TaskStatus.Idle or TaskStatus.Failed))
|
||||
throw new InvalidOperationException("Task must be Idle or Failed to submit for review.");
|
||||
|
||||
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted);
|
||||
if (worktree is not null)
|
||||
|
||||
Reference in New Issue
Block a user