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
+2 -2
View File
@@ -685,7 +685,7 @@
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"queue": { "baseDirtyWarning": "Achtung: Das Repo dieser Liste hat uncommittete Änderungen ({0} geändert, {1} untracked) — ein neuer Worktree startet vom letzten Commit und enthält sie nicht." },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}" },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
@@ -703,7 +703,7 @@
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
"listSettings": { "untitled": "Unbenannt" },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}" },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}" },
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
},
+2 -2
View File
@@ -685,7 +685,7 @@
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"queue": { "baseDirtyWarning": "Heads up: this list's repo has uncommitted changes ({0} modified, {1} untracked) — a new worktree starts from the last commit and won't include them." },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
@@ -703,7 +703,7 @@
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
"listSettings": { "untitled": "Untitled" },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}" },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}" },
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
},
@@ -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);
+9
View File
@@ -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();
+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);