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
+1 -1
View File
@@ -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)
+34 -5
View File
@@ -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);