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:
@@ -96,6 +96,16 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// a list whose working dir has uncommitted changes (null otherwise) — a new worktree forks
|
||||
/// from the last commit, not the working tree, so those changes won't be included.</summary>
|
||||
Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||
/// <summary>Guarded "manual done toggle" (checks the task is currently Idle server-side
|
||||
/// before flipping it to Done) — replaces a raw EF write in the task-list checkbox path.</summary>
|
||||
Task SetTaskDoneAsync(string taskId);
|
||||
/// <summary>Guarded un-toggle (checks the task is currently Done server-side before
|
||||
/// flipping it back to Idle).</summary>
|
||||
Task UnsetTaskDoneAsync(string taskId);
|
||||
/// <summary>Guarded "remove from queue" (checks the task is currently Queued server-side
|
||||
/// and clears BlockedByTaskId in the same update) — call once per row (parent and each
|
||||
/// queued child) rather than cascading server-side.</summary>
|
||||
Task DequeueTaskAsync(string taskId);
|
||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
||||
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
||||
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
||||
|
||||
@@ -536,6 +536,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
return result?.BaseDirty;
|
||||
}
|
||||
|
||||
public Task SetTaskDoneAsync(string taskId)
|
||||
=> InvokeTimedAsync("SetTaskDone", () => _hub.InvokeAsync("SetTaskDone", taskId));
|
||||
|
||||
public Task UnsetTaskDoneAsync(string taskId)
|
||||
=> InvokeTimedAsync("UnsetTaskDone", () => _hub.InvokeAsync("UnsetTaskDone", taskId));
|
||||
|
||||
public Task DequeueTaskAsync(string taskId)
|
||||
=> InvokeTimedAsync("DequeueTask", () => _hub.InvokeAsync("DequeueTask", taskId));
|
||||
|
||||
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
||||
{
|
||||
LastApproveTarget = targetBranch;
|
||||
|
||||
@@ -1024,17 +1024,25 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
private async System.Threading.Tasks.Task ToggleDoneAsync()
|
||||
{
|
||||
if (Task is null) return;
|
||||
Task.Done = !Task.Done;
|
||||
await using var ctx = _dbFactory.CreateDbContext();
|
||||
var repo = new TaskRepository(ctx);
|
||||
var entity = await repo.GetByIdAsync(Task.Id);
|
||||
if (entity is null) return;
|
||||
entity.Status = Task.Done
|
||||
? ClaudeDo.Data.Models.TaskStatus.Done
|
||||
: ClaudeDo.Data.Models.TaskStatus.Idle;
|
||||
Task.Status = entity.Status;
|
||||
Monitor.ApplyState(entity.Status);
|
||||
await repo.UpdateAsync(entity);
|
||||
|
||||
var newDone = !Task.Done;
|
||||
var previousStatus = Task.Status;
|
||||
Task.Done = newDone;
|
||||
var newStatus = newDone ? ClaudeDo.Data.Models.TaskStatus.Done : ClaudeDo.Data.Models.TaskStatus.Idle;
|
||||
Task.Status = newStatus;
|
||||
Monitor.ApplyState(newStatus);
|
||||
try
|
||||
{
|
||||
if (newDone) await _worker.SetTaskDoneAsync(Task.Id);
|
||||
else await _worker.UnsetTaskDoneAsync(Task.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Task.Done = !newDone;
|
||||
Task.Status = previousStatus;
|
||||
Monitor.ApplyState(previousStatus);
|
||||
ErrorReported?.Invoke(Loc.T("vm.detailsIsland.toggleDoneFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -1028,14 +1028,23 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private async Task ToggleDoneAsync(TaskRowViewModel row)
|
||||
{
|
||||
row.Done = !row.Done;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity != null)
|
||||
if (_worker is null) return;
|
||||
|
||||
var newDone = !row.Done;
|
||||
var previousStatus = row.Status;
|
||||
row.Done = newDone;
|
||||
row.Status = newDone ? TaskStatus.Done : TaskStatus.Idle;
|
||||
try
|
||||
{
|
||||
entity.Status = row.Done ? TaskStatus.Done : TaskStatus.Idle;
|
||||
row.Status = entity.Status;
|
||||
await db.SaveChangesAsync();
|
||||
if (newDone) await _worker.SetTaskDoneAsync(row.Id);
|
||||
else await _worker.UnsetTaskDoneAsync(row.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
row.Done = !newDone;
|
||||
row.Status = previousStatus;
|
||||
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.toggleDoneFailed", ex.Message));
|
||||
return;
|
||||
}
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
@@ -1248,39 +1257,56 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity is null) return;
|
||||
if (row is null || _worker is null) return;
|
||||
|
||||
// Cascade to queued children when present — covers both planning parents
|
||||
// (PlanningPhase != None) and bare parents that have a manually-queued
|
||||
// chain. The X button's visibility is gated by the same condition
|
||||
// (HasQueuedSubtasks), so the handler matches what the user can see.
|
||||
var queuedChildren = await db.Tasks
|
||||
.Where(t => t.ParentTaskId == row.Id && t.Status == TaskStatus.Queued)
|
||||
.ToListAsync();
|
||||
foreach (var c in queuedChildren)
|
||||
List<string> queuedChildIds;
|
||||
await using (var db = await _dbFactory.CreateDbContextAsync())
|
||||
{
|
||||
c.Status = TaskStatus.Idle;
|
||||
c.BlockedByTaskId = null;
|
||||
queuedChildIds = await db.Tasks.AsNoTracking()
|
||||
.Where(t => t.ParentTaskId == row.Id && t.Status == TaskStatus.Queued)
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync();
|
||||
}
|
||||
if (entity.Status == TaskStatus.Queued)
|
||||
entity.Status = TaskStatus.Idle;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
foreach (var c in queuedChildren)
|
||||
var failures = new List<string>();
|
||||
|
||||
foreach (var childId in queuedChildIds)
|
||||
{
|
||||
var childRow = Items.FirstOrDefault(r => r.Id == c.Id);
|
||||
if (childRow is not null)
|
||||
var childRow = Items.FirstOrDefault(r => r.Id == childId);
|
||||
if (childRow is null) continue;
|
||||
var previousBlockedBy = childRow.BlockedByTaskId;
|
||||
childRow.Status = TaskStatus.Idle;
|
||||
childRow.BlockedByTaskId = null;
|
||||
try { await _worker.DequeueTaskAsync(childId); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
childRow.Status = TaskStatus.Idle;
|
||||
childRow.BlockedByTaskId = null;
|
||||
childRow.Status = TaskStatus.Queued;
|
||||
childRow.BlockedByTaskId = previousBlockedBy;
|
||||
failures.Add(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
if (row.Status == TaskStatus.Queued)
|
||||
{
|
||||
row.Status = TaskStatus.Idle;
|
||||
row.HasQueuedSubtasks = false;
|
||||
try { await _worker.DequeueTaskAsync(row.Id); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
row.Status = TaskStatus.Queued;
|
||||
failures.Add(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
row.HasQueuedSubtasks = queuedChildIds
|
||||
.Select(id => Items.FirstOrDefault(r => r.Id == id))
|
||||
.Any(r => r?.Status == TaskStatus.Queued);
|
||||
|
||||
if (failures.Count > 0)
|
||||
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.removeFromQueueFailed", string.Join("; ", failures)));
|
||||
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
|
||||
Reference in New Issue
Block a user