chore(claude-do): merge fix(ui): Status-Writes über eine geguardete TaskStateService

ClaudeDo-Task: 4460f1f3-dde7-4c6d-9fa3-d19c2c8ff034
This commit is contained in:
mika kuns
2026-08-21 12:00:06 +02:00
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);
@@ -98,6 +98,9 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task UpdateSessionSkillAsync(string sourceUrl) => Task.CompletedTask;
public virtual Task RemoveSessionSkillAsync(string sourceUrl) => Task.CompletedTask;
public virtual Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status) => Task.FromResult<BaseDirtyWarningDto?>(null);
public virtual Task SetTaskDoneAsync(string taskId) => Task.CompletedTask;
public virtual Task UnsetTaskDoneAsync(string taskId) => Task.CompletedTask;
public virtual Task DequeueTaskAsync(string taskId) => Task.CompletedTask;
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
@@ -73,6 +73,8 @@ public class DetailsIslandErrorFeedbackTests : IDisposable
public override bool IsConnected => Connected;
public Exception? ThrowOnCancelTask;
public Exception? ThrowOnSetTaskStatus;
public Exception? ThrowOnSetTaskDone;
public Exception? ThrowOnUnsetTaskDone;
public override Task CancelTaskAsync(string taskId)
{
@@ -85,6 +87,18 @@ public class DetailsIslandErrorFeedbackTests : IDisposable
if (ThrowOnSetTaskStatus is not null) throw ThrowOnSetTaskStatus;
return Task.FromResult<BaseDirtyWarningDto?>(null);
}
public override Task SetTaskDoneAsync(string taskId)
{
if (ThrowOnSetTaskDone is not null) throw ThrowOnSetTaskDone;
return Task.CompletedTask;
}
public override Task UnsetTaskDoneAsync(string taskId)
{
if (ThrowOnUnsetTaskDone is not null) throw ThrowOnUnsetTaskDone;
return Task.CompletedTask;
}
}
private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
@@ -179,4 +193,60 @@ public class DetailsIslandErrorFeedbackTests : IDisposable
Assert.NotNull(reportedError);
Assert.Contains("reset offline", reportedError);
}
[Fact]
public async Task ToggleDone_MarkDone_WhenWorkerThrows_ReportsError_AndRevertsTask()
{
var worker = new ThrowingWorkerClient { ThrowOnSetTaskDone = new Exception("mark done offline") };
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-toggle-done-throw", Status = TaskStatus.Idle, Done = false });
vm.Monitor.ApplyState(TaskStatus.Idle);
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
await vm.ToggleDoneCommand.ExecuteAsync(null);
Assert.NotNull(reportedError);
Assert.Contains("mark done offline", reportedError);
Assert.False(vm.Task!.Done);
Assert.Equal(TaskStatus.Idle, vm.Task!.Status);
}
[Fact]
public async Task ToggleDone_Untoggle_WhenWorkerThrows_ReportsError_AndRevertsTask()
{
var worker = new ThrowingWorkerClient { ThrowOnUnsetTaskDone = new Exception("unmark done offline") };
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-untoggle-done-throw", Status = TaskStatus.Done, Done = true });
vm.Monitor.ApplyState(TaskStatus.Done);
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
await vm.ToggleDoneCommand.ExecuteAsync(null);
Assert.NotNull(reportedError);
Assert.Contains("unmark done offline", reportedError);
Assert.True(vm.Task!.Done);
Assert.Equal(TaskStatus.Done, vm.Task!.Status);
}
[Fact]
public async Task ToggleDone_MarkDone_WhenWorkerSucceeds_UpdatesTask_NoError()
{
var worker = new ThrowingWorkerClient();
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-toggle-done-ok", Status = TaskStatus.Idle, Done = false });
vm.Monitor.ApplyState(TaskStatus.Idle);
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
await vm.ToggleDoneCommand.ExecuteAsync(null);
Assert.Null(reportedError);
Assert.True(vm.Task!.Done);
Assert.Equal(TaskStatus.Done, vm.Task!.Status);
}
}
@@ -57,6 +57,10 @@ public class TasksIslandErrorFeedbackTests : IDisposable
public Exception? ThrowOnCancelTask;
public Exception? ThrowOnRejectToQueue;
public Exception? ThrowOnRejectToIdle;
public Exception? ThrowOnSetTaskDone;
public Exception? ThrowOnUnsetTaskDone;
public Exception? ThrowOnDequeueTask;
public readonly List<string> DequeuedTaskIds = new();
public override Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status)
{
@@ -81,6 +85,25 @@ public class TasksIslandErrorFeedbackTests : IDisposable
if (ThrowOnRejectToIdle is not null) throw ThrowOnRejectToIdle;
return Task.CompletedTask;
}
public override Task SetTaskDoneAsync(string taskId)
{
if (ThrowOnSetTaskDone is not null) throw ThrowOnSetTaskDone;
return Task.CompletedTask;
}
public override Task UnsetTaskDoneAsync(string taskId)
{
if (ThrowOnUnsetTaskDone is not null) throw ThrowOnUnsetTaskDone;
return Task.CompletedTask;
}
public override Task DequeueTaskAsync(string taskId)
{
DequeuedTaskIds.Add(taskId);
if (ThrowOnDequeueTask is not null) throw ThrowOnDequeueTask;
return Task.CompletedTask;
}
}
[Fact]
@@ -146,4 +169,113 @@ public class TasksIslandErrorFeedbackTests : IDisposable
Assert.NotNull(reportedError);
Assert.Contains("reject-to-idle offline", reportedError);
}
[Fact]
public async Task ToggleDone_MarkDone_WhenWorkerThrows_RaisesErrorReported_AndRevertsRow()
{
var worker = new ThrowingWorkerClient { ThrowOnSetTaskDone = new Exception("mark done offline") };
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker);
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
var row = new TaskRowViewModel { Id = "task-toggle-done-1", Status = TaskStatus.Idle, Done = false };
await vm.ToggleDoneCommand.ExecuteAsync(row);
Assert.NotNull(reportedError);
Assert.Contains("mark done offline", reportedError);
Assert.False(row.Done);
Assert.Equal(TaskStatus.Idle, row.Status);
}
[Fact]
public async Task ToggleDone_Untoggle_WhenWorkerThrows_RaisesErrorReported_AndRevertsRow()
{
var worker = new ThrowingWorkerClient { ThrowOnUnsetTaskDone = new Exception("unmark done offline") };
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker);
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
var row = new TaskRowViewModel { Id = "task-toggle-done-2", Status = TaskStatus.Done, Done = true };
await vm.ToggleDoneCommand.ExecuteAsync(row);
Assert.NotNull(reportedError);
Assert.Contains("unmark done offline", reportedError);
Assert.True(row.Done);
Assert.Equal(TaskStatus.Done, row.Status);
}
[Fact]
public async Task ToggleDone_MarkDone_WhenWorkerSucceeds_UpdatesRow_NoError()
{
var worker = new ThrowingWorkerClient();
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker);
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
var row = new TaskRowViewModel { Id = "task-toggle-done-3", Status = TaskStatus.Idle, Done = false };
await vm.ToggleDoneCommand.ExecuteAsync(row);
Assert.Null(reportedError);
Assert.True(row.Done);
Assert.Equal(TaskStatus.Done, row.Status);
}
[Fact]
public async Task RemoveFromQueue_WhenWorkerThrows_RaisesErrorReported_AndRevertsRow()
{
var worker = new ThrowingWorkerClient { ThrowOnDequeueTask = new Exception("dequeue offline") };
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker);
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
var row = new TaskRowViewModel { Id = "task-dequeue-1", Status = TaskStatus.Queued };
await vm.RemoveFromQueueCommand.ExecuteAsync(row);
Assert.NotNull(reportedError);
Assert.Contains("dequeue offline", reportedError);
Assert.Equal(TaskStatus.Queued, row.Status);
}
[Fact]
public async Task RemoveFromQueue_CascadesToQueuedChildren_ViaGuardedHubCall()
{
var listId = Guid.NewGuid().ToString();
var parentId = Guid.NewGuid().ToString();
var childId = Guid.NewGuid().ToString();
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity
{
Id = parentId, ListId = listId, Title = "parent", Status = TaskStatus.Queued,
Number = 1, CreatedAt = DateTime.UtcNow,
});
db.Tasks.Add(new TaskEntity
{
Id = childId, ListId = listId, Title = "child", Status = TaskStatus.Queued,
Number = 2, ParentTaskId = parentId, BlockedByTaskId = parentId, CreatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
var worker = new ThrowingWorkerClient();
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker);
var parentRow = new TaskRowViewModel { Id = parentId, Status = TaskStatus.Queued, HasQueuedSubtasks = true };
var childRow = new TaskRowViewModel { Id = childId, ParentTaskId = parentId, Status = TaskStatus.Queued, BlockedByTaskId = parentId };
vm.Items.Add(parentRow);
vm.Items.Add(childRow);
await vm.RemoveFromQueueCommand.ExecuteAsync(parentRow);
Assert.Contains(childId, worker.DequeuedTaskIds);
Assert.Contains(parentId, worker.DequeuedTaskIds);
Assert.Equal(TaskStatus.Idle, childRow.Status);
Assert.Null(childRow.BlockedByTaskId);
Assert.Equal(TaskStatus.Idle, parentRow.Status);
Assert.False(parentRow.HasQueuedSubtasks);
}
}
@@ -1,5 +1,6 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -39,8 +40,31 @@ public class TasksIslandRemoveFromQueueTests : IDisposable
public ClaudeDoDbContext CreateDbContext() => _create();
}
private TasksIslandViewModel BuildViewModel() =>
new(new TestDbFactory(NewContext), worker: null);
// RemoveFromQueueAsync now routes the dequeue through IWorkerClient.DequeueTaskAsync (the
// guarded TaskStateService transition) instead of writing the DB directly. This fake performs
// the same write the real hub would, against the same DB, so these tests still exercise the
// ViewModel's cascade/read logic without needing a live worker.
private sealed class DequeuingWorkerClient : StubWorkerClient
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public DequeuingWorkerClient(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
public override async Task DequeueTaskAsync(string taskId)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId && t.Status == TaskStatus.Queued);
if (entity is null) return;
entity.Status = TaskStatus.Idle;
entity.BlockedByTaskId = null;
await db.SaveChangesAsync();
}
}
private TasksIslandViewModel BuildViewModel()
{
var factory = new TestDbFactory(NewContext);
return new(factory, worker: new DequeuingWorkerClient(factory));
}
private async Task SeedParentWithChainAsync(
string parentId,
@@ -0,0 +1,101 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;
namespace ClaudeDo.Worker.Tests.Hub;
/// UpdateListConfig's "all fields blank -> delete the row" branch used to delete unconditionally,
/// silently dropping SerializeOnFileOverlap -- a flag with no UI/hub affordance of its own (set
/// only via set_list_config or directly against ListConfigEntity).
public sealed class ListConfigHubTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
private WorkerHub CreateHub()
{
var factory = _db.CreateFactory();
var broadcaster = new HubBroadcaster(new CapturingHubContext());
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, factory,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;
}
// Each helper opens (and disposes) its own short-lived context/repository -- ListRepository's
// GetConfigAsync doesn't AsNoTracking(), so reusing one long-lived instance across a hub call
// that writes via a *different* context would return a stale, identity-mapped entity.
private async Task<string> SeedListAsync()
{
var listId = Guid.NewGuid().ToString();
await using var ctx = _db.CreateContext();
await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
return listId;
}
private async Task SeedConfigAsync(string listId, string? model = null, bool serializeOnFileOverlap = false)
{
await using var ctx = _db.CreateContext();
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity
{
ListId = listId, Model = model, SerializeOnFileOverlap = serializeOnFileOverlap,
});
}
private async Task<ListConfigEntity?> GetConfigAsync(string listId)
{
await using var ctx = _db.CreateContext();
return await new ListRepository(ctx).GetConfigAsync(listId);
}
[Fact]
public async Task UpdateListConfig_AllBlank_NoUnrelatedSettings_DeletesRow()
{
var hub = CreateHub();
var listId = await SeedListAsync();
await SeedConfigAsync(listId, model: "opus");
await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null));
Assert.Null(await GetConfigAsync(listId));
}
[Fact]
public async Task UpdateListConfig_AllBlank_WithSerializeOnFileOverlap_KeepsFlag_RowSurvives()
{
var hub = CreateHub();
var listId = await SeedListAsync();
await SeedConfigAsync(listId, model: "opus", serializeOnFileOverlap: true);
await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null));
var config = await GetConfigAsync(listId);
Assert.NotNull(config);
Assert.True(config!.SerializeOnFileOverlap);
Assert.Null(config.Model);
}
[Fact]
public async Task UpdateListConfig_WithModel_UpsertsNormally_PreservesSerializeOnFileOverlap()
{
var hub = CreateHub();
var listId = await SeedListAsync();
await SeedConfigAsync(listId, serializeOnFileOverlap: true);
await hub.UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null));
var config = await GetConfigAsync(listId);
Assert.NotNull(config);
Assert.Equal("opus", config!.Model);
Assert.True(config.SerializeOnFileOverlap);
}
}
@@ -214,6 +214,51 @@ public sealed class MergeHelperTaskHubTests : IDisposable
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
}
// The status gate (Idle or Failed) must run before either mutation branch below, so a
// Done/Cancelled task can never get its worktree committed or its HandlerHeadCommit stamped
// and then rejected by SubmitInteractiveForReviewAsync, stranding the work.
[Fact]
public async Task SubmitTaskForReview_DoneTask_WorktreePath_ThrowsBeforeCommit_WorktreeUntouched()
{
var listId = await SeedListAsync(Path.GetTempPath());
var task = await SeedTaskAsync(listId, TaskStatus.Done);
var worktree = new WorktreeEntity
{
TaskId = task.Id,
Path = Path.Combine(Path.GetTempPath(), $"cd_no_such_worktree_{Guid.NewGuid():N}"),
BranchName = "claudedo/does-not-matter",
BaseCommit = "abc123",
State = WorktreeState.Active,
CreatedAt = DateTime.UtcNow,
};
await new WorktreeRepository(_ctx).AddAsync(worktree);
var hub = CreateHub();
var ex = await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
Assert.Contains("Idle or Failed", ex.Message);
var reloadedWorktree = await new WorktreeRepository(_ctx).GetByTaskIdAsync(task.Id);
Assert.Null(reloadedWorktree!.HeadCommit);
var reloadedTask = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, reloadedTask!.Status);
}
[Fact]
public async Task SubmitTaskForReview_CancelledTask_HandlerPath_ThrowsBeforeStamp_HandlerHeadCommitUntouched()
{
var listId = await SeedListAsync(Path.GetTempPath());
var task = await SeedTaskAsync(listId, TaskStatus.Cancelled, handlerBaseCommit: "abc123");
var hub = CreateHub();
var ex = await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
Assert.Contains("Idle or Failed", ex.Message);
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Null(reloaded!.HandlerHeadCommit);
Assert.Equal(TaskStatus.Cancelled, reloaded.Status);
}
}
// RecordingClientProxy / FakeHubCallerClients / FakeHubCallerContext are defined once for the
@@ -0,0 +1,160 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Hub;
/// Covers the guarded "manual done toggle" (SetTaskDone/UnsetTaskDone) and "remove from queue"
/// (DequeueTask) hub methods added to replace the UI's raw EF writes -- each checks the expected
/// starting status server-side so a concurrent picker claim can't be silently overwritten.
public sealed class TaskDoneDequeueHubTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly RecordingClientProxy _proxy = new();
public TaskDoneDequeueHubTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private WorkerHub CreateHub()
{
var factory = _db.CreateFactory();
var built = TaskStateServiceBuilder.Build(factory);
var hub = new WorkerHub(
null!, null!, null!, null!, null!, factory, null!, null!, null!,
null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(_proxy);
hub.Context = new FakeHubCallerContext();
return hub;
}
private async Task<string> SeedListAsync()
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
return listId;
}
private async Task<TaskEntity> SeedTaskAsync(
string listId, TaskStatus status, string? blockedByTaskId = null, string title = "T")
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Status = status,
BlockedByTaskId = blockedByTaskId,
CreatedAt = DateTime.UtcNow,
};
await _tasks.AddAsync(task);
return task;
}
// ── SetTaskDone ──
[Fact]
public async Task SetTaskDone_FromIdle_TransitionsToDone()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, TaskStatus.Idle);
var hub = CreateHub();
await hub.SetTaskDone(task.Id);
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, reloaded!.Status);
}
[Fact]
public async Task SetTaskDone_FromRunning_Throws_AndLeavesStatusUnchanged()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, TaskStatus.Running);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.SetTaskDone(task.Id));
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Running, reloaded!.Status);
}
// ── UnsetTaskDone ──
[Fact]
public async Task UnsetTaskDone_FromDone_TransitionsToIdle()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, TaskStatus.Done);
var hub = CreateHub();
await hub.UnsetTaskDone(task.Id);
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Idle, reloaded!.Status);
}
[Fact]
public async Task UnsetTaskDone_FromRunning_Throws_AndLeavesStatusUnchanged()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, TaskStatus.Running);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.UnsetTaskDone(task.Id));
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Running, reloaded!.Status);
}
// ── DequeueTask ──
[Fact]
public async Task DequeueTask_FromQueued_TransitionsToIdle_AndClearsBlockedByTaskId()
{
var listId = await SeedListAsync();
var pred = await SeedTaskAsync(listId, TaskStatus.Queued);
var task = await SeedTaskAsync(listId, TaskStatus.Queued, blockedByTaskId: pred.Id);
var hub = CreateHub();
await hub.DequeueTask(task.Id);
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Idle, reloaded!.Status);
Assert.Null(reloaded.BlockedByTaskId);
}
[Fact]
public async Task DequeueTask_FromRunning_Throws_AndLeavesStatusUnchanged()
{
// Simulates the picker having already claimed the task between the UI reading its
// row and the dequeue call landing -- the DB status must not be clobbered.
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, TaskStatus.Running);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.DequeueTask(task.Id));
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Running, reloaded!.Status);
}
}
@@ -871,6 +871,9 @@ file sealed class ApproveObservingTaskStateService : ITaskStateService
public Task<TransitionResult> RejectToIdleAsync(string taskId, CancellationToken ct) => _inner.RejectToIdleAsync(taskId, ct);
public Task<TransitionResult> ClearReviewFeedbackAsync(string taskId, CancellationToken ct) => _inner.ClearReviewFeedbackAsync(taskId, ct);
public Task<TransitionResult> ForceSetStatusAsync(string taskId, TaskStatus status, CancellationToken ct) => _inner.ForceSetStatusAsync(taskId, status, ct);
public Task<TransitionResult> MarkDoneAsync(string taskId, DateTime finishedAt, CancellationToken ct) => _inner.MarkDoneAsync(taskId, finishedAt, ct);
public Task<TransitionResult> UnmarkDoneAsync(string taskId, CancellationToken ct) => _inner.UnmarkDoneAsync(taskId, ct);
public Task<TransitionResult> DequeueToIdleAsync(string taskId, CancellationToken ct) => _inner.DequeueToIdleAsync(taskId, ct);
public Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct) => _inner.StartPlanningAsync(parentId, ct);
public Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct) => _inner.FinalizePlanningAsync(parentId, ct);
public Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct) => _inner.BlockOnAsync(taskId, predecessorTaskId, ct);
@@ -477,6 +477,84 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
// ─── MarkDoneAsync / UnmarkDoneAsync ─────────────────────────────────
[Fact]
public async Task MarkDoneAsync_FromIdle_TransitionsToDone_AndStampsFinishedAt()
{
var id = await SeedTaskAsync(TaskStatus.Idle);
var now = DateTime.UtcNow;
var result = await _sut.MarkDoneAsync(id, now, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.Done, t.Status);
Assert.Equal(now, t.FinishedAt);
}
[Fact]
public async Task MarkDoneAsync_FromRunning_Rejects_AndLeavesStatusUnchanged()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.MarkDoneAsync(id, DateTime.UtcNow, default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
[Fact]
public async Task UnmarkDoneAsync_FromDone_TransitionsToIdle()
{
var id = await SeedTaskAsync(TaskStatus.Done);
var result = await _sut.UnmarkDoneAsync(id, default);
Assert.True(result.Ok);
Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id));
}
[Fact]
public async Task UnmarkDoneAsync_FromRunning_Rejects_AndLeavesStatusUnchanged()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.UnmarkDoneAsync(id, default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
// ─── DequeueToIdleAsync ───────────────────────────────────────────────
[Fact]
public async Task DequeueToIdleAsync_FromQueued_TransitionsToIdle_AndClearsBlockedByTaskId()
{
var pred = await SeedTaskAsync(TaskStatus.Queued);
var id = await SeedTaskAsync(TaskStatus.Queued, blockedBy: pred);
var result = await _sut.DequeueToIdleAsync(id, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.Idle, t.Status);
Assert.Null(t.BlockedByTaskId);
}
[Fact]
public async Task DequeueToIdleAsync_FromRunning_Rejects_AndLeavesStatusUnchanged()
{
// Simulates the picker having already claimed the task between the UI reading
// its row and the dequeue call landing.
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.DequeueToIdleAsync(id, default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
// ─── StartPlanningAsync ───────────────────────────────────────────────
[Fact]
@@ -65,6 +65,9 @@ sealed class FakeWorkerClient : IWorkerClient
SetTaskStatusCalls.Add((taskId, status));
return Task.FromResult<BaseDirtyWarningDto?>(null);
}
public Task SetTaskDoneAsync(string taskId) => Task.CompletedTask;
public Task UnsetTaskDoneAsync(string taskId) => Task.CompletedTask;
public Task DequeueTaskAsync(string taskId) => Task.CompletedTask;
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));