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:
@@ -69,7 +69,7 @@ not conflated.
|
||||
Allowed transitions (enforced by `TaskStateService`):
|
||||
|
||||
```
|
||||
Idle → Queued | Running (RunNow) | Cancelled (external update_task_status only, allowFromIdle: true)
|
||||
Idle → Queued | Running (RunNow) | Done (manual toggle) | Cancelled (external update_task_status only, allowFromIdle: true)
|
||||
Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap)
|
||||
Running → WaitingForReview (standalone success, no children)
|
||||
| WaitingForChildren (parent with pending children)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -21,6 +21,16 @@ public interface ITaskStateService
|
||||
|
||||
Task<TransitionResult> ForceSetStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status, CancellationToken ct);
|
||||
|
||||
// Guarded "manual done toggle" affordance (task list checkbox): unlike ForceSetStatusAsync,
|
||||
// these check the expected starting status in the same ExecuteUpdateAsync filter so a
|
||||
// concurrent picker claim (Idle/Done -> Running) is not silently overwritten.
|
||||
Task<TransitionResult> MarkDoneAsync(string taskId, DateTime finishedAt, CancellationToken ct);
|
||||
Task<TransitionResult> UnmarkDoneAsync(string taskId, CancellationToken ct);
|
||||
|
||||
// Guarded "remove from queue" affordance. Expects the task to still be Queued; clears
|
||||
// BlockedByTaskId in the same update so a queued chain child never ends up Idle-but-blocked.
|
||||
Task<TransitionResult> DequeueToIdleAsync(string taskId, CancellationToken ct);
|
||||
|
||||
Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);
|
||||
Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct);
|
||||
|
||||
|
||||
@@ -379,6 +379,52 @@ public sealed class TaskStateService : ITaskStateService
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> MarkDoneAsync(string taskId, DateTime finishedAt, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status == TaskStatus.Idle)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Done)
|
||||
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task is not Idle; cannot mark done.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> UnmarkDoneAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status == TaskStatus.Done)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Idle), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task is not Done; cannot unmark.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> DequeueToIdleAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status == TaskStatus.Queued)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Idle)
|
||||
.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task is not queued; cannot remove from queue.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
Reference in New Issue
Block a user