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:
@@ -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}" }
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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