# Phase 1 — Reaktivitäts-Löcher schließen — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Schließt die sechs Pfade, auf denen eine Worker-Änderung die UI nie erreicht, sodass ein Task nicht mehr auf `Queued` stehen bleibt, obwohl er läuft. **Architecture:** Der Broadcast-Pfad selbst ist korrekt (DB-Write → Commit → SignalR-ID → UI lädt frisch nach). Repariert werden ausschließlich die Stellen, an denen ein Update verloren geht: ein stiller `catch {}` in der UI, ein Fehlerpfad im Worker ohne Statuswechsel, drei DB-Writes ohne Broadcast, ein fehlender Busy-Timeout und eine Race im Delta-Pfad. Kein neuer Mechanismus, keine Schema-Änderung. **Tech Stack:** .NET 8, EF Core (Microsoft.EntityFrameworkCore.Sqlite), SignalR, Avalonia 12 / CommunityToolkit.Mvvm, xUnit. **Spec:** `docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md` --- ## Vorbemerkungen für den Umsetzenden - **Build:** `dotnet build ClaudeDo.slnx` schlägt auf .NET 8 fehl. Einzelne Projekte bauen, immer `-c Release` (ein laufender Worker sperrt die `Debug`-Ausgabe): ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release ``` - **Commits:** Dieses Repo wird von parallelen Sessions bearbeitet. **Niemals `git add -A`.** Immer Pfade explizit stagen und mit `git commit -- ` committen, sonst werden fremde Änderungen mit eingefangen. - **Test-Doubles:** `CapturingHubContext` (`tests/ClaudeDo.Worker.Tests/Infrastructure/FakeHubContext.cs`) sammelt alle SignalR-Aufrufe in `.Proxy.Calls` als `CapturedHubCall(string Method, object?[] Args)`. Damit wird jede Broadcast-Assertion geschrieben. - **Keine Tests, die die echte `claude`-CLI starten.** Es gibt `FakeClaudeProcess`. - Reihenfolge der Tasks ist bindend: Task 3 macht Task 4 überflüssig, Task 8 hängt an nichts. --- ## Betroffene Dateien | Datei | Verantwortung | Task | |---|---|---| | `src/ClaudeDo.App/Program.cs` | DI der UI, Connection-String | 1 | | `src/ClaudeDo.Worker/Program.cs` | DI des Workers, Connection-String | 1 | | `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` | Delta-Pfad `OnWorkerTaskUpdated` | 2, 3 | | `src/ClaudeDo.Worker/Queue/QueueService.cs` | Slot-Runner-Fehlerpfad | 4 | | `src/ClaudeDo.Worker/Runner/TaskRunner.cs` | Worktree-Broadcast, totes Event | 5, 8 | | `src/ClaudeDo.Worker/Online/OnlineSyncService.cs` | Import aus der Online-Inbox | 6 | | `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` | Anlage der List-Handler-Task | 7 | | `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` | totes Event entfernen | 8 | --- ### Task 1: Busy-Timeout in beide Connection-Strings Ohne Timeout wirft SQLite bei einem kurzzeitig gesperrten File sofort `SqliteException`, statt zu warten. Zusammen mit dem stillen `catch {}` (Task 2) ist das der wahrscheinlichste Auslöser für eine dauerhaft veraltete Zeile. **Wichtig:** `PRAGMA busy_timeout` gilt **pro Connection** und wird — anders als `journal_mode=WAL` — nicht in der DB-Datei gespeichert. Es in `ClaudeDoDbContext.MigrateAndConfigure` zu setzen wäre wirkungslos. Microsoft.Data.Sqlite bildet das Connection-String-Schlüsselwort `Default Timeout` (in Sekunden) auf den Busy-Handler ab; das gilt dann für jede über die Factory geöffnete Connection. **Files:** - Modify: `src/ClaudeDo.App/Program.cs:100-101` - Modify: `src/ClaudeDo.Worker/Program.cs:60-61` - [ ] **Step 1: Connection-String der UI erweitern** In `src/ClaudeDo.App/Program.cs` ersetzen: ```csharp sc.AddDbContextFactory(opt => opt.UseSqlite($"Data Source={dbPath}")); ``` durch: ```csharp // Default Timeout maps to SQLite's busy handler. Without it a momentarily locked // database throws SqliteException immediately instead of waiting out the writer, // which is what leaves task rows stuck on a stale status. sc.AddDbContextFactory(opt => opt.UseSqlite($"Data Source={dbPath};Default Timeout=30")); ``` - [ ] **Step 2: Connection-String des Workers erweitern** In `src/ClaudeDo.Worker/Program.cs` ersetzen: ```csharp builder.Services.AddDbContextFactory(opt => opt.UseSqlite($"Data Source={cfg.DbPath}")); ``` durch: ```csharp // See ClaudeDo.App/Program.cs — Default Timeout maps to SQLite's busy handler. builder.Services.AddDbContextFactory(opt => opt.UseSqlite($"Data Source={cfg.DbPath};Default Timeout=30")); ``` - [ ] **Step 3: Beide Projekte bauen** Run: ```bash dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release ``` Expected: beide `Build succeeded`, 0 Errors. - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Worker/Program.cs git commit -m "fix(data): give both processes a SQLite busy timeout" -- src/ClaudeDo.App/Program.cs src/ClaudeDo.Worker/Program.cs ``` --- ### Task 2: Delta-Pfad verschluckt Fehler nicht mehr stumm `OnWorkerTaskUpdated` umschließt den kompletten Delta-Pfad mit `catch { }`. Eine einzige transiente Exception lässt die Zeile dauerhaft auf dem alten Stand — ohne Log, ohne Retry. Der Fix: einmal wiederholen, und wenn auch das scheitert, auf den vollständigen `LoadForList` zurückfallen. Damit heilt sich der Fehler selbst. **Files:** - Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs:163-225` - Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs` (neu) - [ ] **Step 1: Failing test schreiben** Create `tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs`: ```csharp using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Ui.ViewModels.Islands; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.Tests.ViewModels; // The delta path in OnWorkerTaskUpdated used to be wrapped in a blank `catch { }`. A single // transient DB error therefore left the row on its old status forever — the "task stuck on // Queued although it is running" bug. It must retry, and fall back to a full reload. public class TasksIslandDeltaResilienceTests : IDisposable { private readonly string _dbPath; public TasksIslandDeltaResilienceTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_delta_{Guid.NewGuid():N}.db"); using var ctx = NewContext(); ctx.Database.EnsureCreated(); } public void Dispose() { try { File.Delete(_dbPath); } catch { } try { File.Delete(_dbPath + "-wal"); } catch { } try { File.Delete(_dbPath + "-shm"); } catch { } } private ClaudeDoDbContext NewContext() { var opts = new DbContextOptionsBuilder() .UseSqlite($"Data Source={_dbPath}") .Options; return new ClaudeDoDbContext(opts); } // Throws on the first N CreateDbContext calls, then behaves normally. private sealed class FlakyDbFactory : IDbContextFactory { private readonly Func _create; private int _failuresLeft; public int CreateCalls { get; private set; } public FlakyDbFactory(Func create, int failuresLeft) { _create = create; _failuresLeft = failuresLeft; } public ClaudeDoDbContext CreateDbContext() { CreateCalls++; if (_failuresLeft > 0) { _failuresLeft--; throw new InvalidOperationException("simulated transient DB failure"); } return _create(); } } private sealed class FakeWorker : StubWorkerClient { } // A user list's nav id is prefixed — see TasksIslandRegroupTests.UserList. private static ListNavItemViewModel UserList(string listEntityId, string name) => new() { Id = $"user:{listEntityId}", Kind = ListKind.User, Name = name }; // LoadForList is void and fires a background task; this is the wait idiom the other // TasksIsland test files use. private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list) { vm.LoadForList(list); var deadline = DateTime.UtcNow.AddSeconds(5); while (DateTime.UtcNow < deadline) { await Task.Delay(25); if (vm.Items.Count > 0) break; } await Task.Delay(50); } private async Task SeedAsync() { await using var db = NewContext(); db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = "T1", ListId = "L1", Title = "Task one", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0, }); await db.SaveChangesAsync(); } [Fact] public async Task Delta_refresh_retries_after_a_transient_failure_and_still_applies_the_new_status() { await SeedAsync(); var flaky = new FlakyDbFactory(NewContext, failuresLeft: 0); var vm = new TasksIslandViewModel(flaky, new FakeWorker()); var list = UserList("L1", "Work"); await LoadAndWaitAsync(vm, list); Assert.Equal(TaskStatus.Queued, vm.Items.Single(r => r.Id == "T1").Status); // Worker flips the task to Running. await using (var db = NewContext()) { var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); t.Status = TaskStatus.Running; await db.SaveChangesAsync(); } // The next delta read fails once; the retry must still land the new status. flaky.FailNext(); await vm.RefreshTaskFromWorkerAsync("T1"); Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status); } } ``` Der Test benutzt zwei Members, die es noch nicht gibt: `RefreshTaskFromWorkerAsync(string)` entsteht in Step 3; `FlakyDbFactory.FailNext()` gehört in die Testklasse — direkt unter `CreateDbContext` einfügen: ```csharp public void FailNext() => _failuresLeft++; ``` - [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** Run: ```bash dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~TasksIslandDeltaResilienceTests ``` Expected: Compile-Fehler `'TasksIslandViewModel' does not contain a definition for 'RefreshTaskFromWorkerAsync'`. - [ ] **Step 3: Delta-Pfad umbauen** In `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` den bestehenden Handler ```csharp private async void OnWorkerTaskUpdated(string taskId) { ``` umbenennen und eine awaitbare Methode daraus machen. Der Event-Handler bleibt `async void`, delegiert aber: ```csharp private async void OnWorkerTaskUpdated(string taskId) => await RefreshTaskFromWorkerAsync(taskId); // Awaitable so tests can drive it deterministically. One retry, then a full reload: // a swallowed exception here used to leave the row on a stale status permanently. internal async Task RefreshTaskFromWorkerAsync(string taskId) { ``` Danach den bisherigen Methodenkörper unverändert übernehmen, aber den abschließenden ```csharp catch { } } ``` ersetzen durch: ```csharp catch (Exception first) { System.Diagnostics.Debug.WriteLine( $"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying"); try { await ApplyDeltaAsync(taskId, list); } catch (Exception second) { System.Diagnostics.Debug.WriteLine( $"TasksIsland: delta retry for {taskId} failed ({second.Message}); full reload"); LoadForList(list); } } } ``` Damit der Retry denselben Code ausführt, wird der Rumpf zwischen `try {` und `catch` in eine eigene Methode gezogen. Der neue Aufbau: ```csharp internal async Task RefreshTaskFromWorkerAsync(string taskId) { var list = _currentList; if (list is null) return; // virtual:queued / virtual:running include Planning parents whose children match, // which can't be decided from a single entity. Always full-reload in those cases. if (list.Kind == ListKind.Virtual && (list.Id == "virtual:queued" || list.Id == "virtual:running")) { LoadForList(list); return; } try { await ApplyDeltaAsync(taskId, list); } catch (Exception first) { System.Diagnostics.Debug.WriteLine( $"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying"); try { await ApplyDeltaAsync(taskId, list); } catch (Exception second) { System.Diagnostics.Debug.WriteLine( $"TasksIsland: delta retry for {taskId} failed ({second.Message}); full reload"); LoadForList(list); } } } private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list) { await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks .Include(t => t.List) .Include(t => t.Worktree) .FirstOrDefaultAsync(t => t.Id == taskId); // A parent transition (finalize/discard) broadcasts only the parent's id, but it // changes its children's derived state — finalize flips them Draft→Planned, discard // deletes them. The delta path below only touches the parent row and never recomputes // the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted // children, so reconcile the whole list when the updated task is (or owns) a subtree. if (entity is not null && (entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id))) { LoadForList(list); return; } var existing = Items.FirstOrDefault(r => r.Id == taskId); if (entity is null) { if (existing is not null) Items.Remove(existing); } else { var matches = TaskMatchesList(entity, list); if (existing is not null && matches) existing.UpdateFromEntity(entity); else if (existing is not null) Items.Remove(existing); else if (matches) { LoadForList(list); return; } else return; } // Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips. if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId)) { var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId); if (parent is not null) parent.HasQueuedSubtasks = Items.Any(r => r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting)); } Regroup(); UpdateSubtitle(); } ``` - [ ] **Step 4: Test laufen lassen, Erfolg bestätigen** Run: ```bash dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~TasksIslandDeltaResilienceTests ``` Expected: `Passed! - Failed: 0`. - [ ] **Step 5: Gesamte UI-Suite laufen lassen** Run: ```bash dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release ``` Expected: 0 Failed. Falls `LoadForListAsync` in bestehenden Tests anders heißt, den Testcode oben an den vorhandenen Namen anpassen — die öffentliche Signatur darf sich nicht ändern. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs git commit -m "fix(ui): retry the task delta refresh instead of swallowing the error" -- src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs ``` --- ### Task 3: Race im Delta-Pfad — veraltete Ergebnisse verwerfen `OnWorkerTaskUpdated` hängt an zwei Events (`TaskUpdatedEvent` und `WorktreeUpdatedEvent`, Zeilen 117-118). Zwei Aufrufe für dieselbe Task laufen unabhängig; der zeitlich frühere Read kann später fertig werden und die Zeile mit dem älteren Stand überschreiben. Der Full-Reload-Zweig ist über `_loadCts` abgesichert, der Delta-Zweig nicht. **Files:** - Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` - Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs` - [ ] **Step 1: Failing test ergänzen** In `TasksIslandDeltaResilienceTests.cs` anhängen: ```csharp [Fact] public async Task A_stale_delta_result_does_not_overwrite_a_newer_one() { await SeedAsync(); var factory = new FlakyDbFactory(NewContext, failuresLeft: 0); var vm = new TasksIslandViewModel(factory, new FakeWorker()); var list = UserList("L1", "Work"); await LoadAndWaitAsync(vm, list); // Start refresh #1 while the DB still says Queued, but do not await it yet. var first = vm.RefreshTaskFromWorkerAsync("T1"); await using (var db = NewContext()) { var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); t.Status = TaskStatus.Running; await db.SaveChangesAsync(); } // Refresh #2 sees Running and must win, regardless of completion order. var second = vm.RefreshTaskFromWorkerAsync("T1"); await Task.WhenAll(first, second); Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status); } ``` - [ ] **Step 2: Test laufen lassen** Run: ```bash dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~A_stale_delta_result ``` Expected: bestanden **oder** fehlgeschlagen — Microsoft.Data.Sqlite arbeitet "async" faktisch synchron, der Test kann grün starten. Das ist in Ordnung: er sichert die Invariante für die Zukunft ab. Notiere das Ergebnis und fahre fort. - [ ] **Step 3: Sequenzzähler einbauen** Feld neben den übrigen privaten Feldern der Klasse ergänzen: ```csharp // Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for // one task can be in flight at once. Only the newest may write to the row. private readonly Dictionary _deltaSeq = new(); private long _deltaCounter; ``` In `RefreshTaskFromWorkerAsync` direkt nach der `virtual:`-Weiche eine Sequenz ziehen und an `ApplyDeltaAsync` reichen: ```csharp var seq = ++_deltaCounter; _deltaSeq[taskId] = seq; try { await ApplyDeltaAsync(taskId, list, seq); } catch (Exception first) { System.Diagnostics.Debug.WriteLine( $"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying"); try { await ApplyDeltaAsync(taskId, list, seq); } catch (Exception second) { System.Diagnostics.Debug.WriteLine( $"TasksIsland: delta retry for {taskId} failed ({second.Message}); full reload"); LoadForList(list); } } ``` Signatur von `ApplyDeltaAsync` erweitern und unmittelbar nach dem DB-Read abbrechen, wenn inzwischen ein neuerer Lauf gestartet wurde: ```csharp private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list, long seq) { await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks .Include(t => t.List) .Include(t => t.Worktree) .FirstOrDefaultAsync(t => t.Id == taskId); // A newer refresh for this task started while we were reading — its result is fresher. if (_deltaSeq.TryGetValue(taskId, out var current) && current != seq) return; ``` Der Rest der Methode bleibt unverändert. - [ ] **Step 4: Tests laufen lassen** Run: ```bash dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release ``` Expected: 0 Failed. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs git commit -m "fix(ui): drop stale delta refreshes so the newest task state wins" -- src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs ``` --- ### Task 4: Slot-Runner-Fehler markiert den Task als Failed `QueuePicker.ClaimNextAsync` committet `status='running'` per Raw-SQL, bevor der Runner startet. Wirft danach irgendetwas in `RunInSlotAsync` — oder im ungeschützten Setup-Block von `TaskRunner.ContinueAsync` (Zeilen 218–238) — fängt der Catch das ab und **loggt nur**. Der Task bleibt für immer `Running` in der DB, und die UI hat nie einen Broadcast gesehen. Das ist der Hauptverdächtige für „bleibt auf Queued". `TaskStateService.FailAsync` schreibt `Failed` und broadcastet selbst `TaskUpdated` — beides in einem Schritt. Abbrüche (`OperationCanceledException`) müssen ausgenommen bleiben: dort hat der Cancel-Pfad den Status bereits gesetzt. **Files:** - Modify: `src/ClaudeDo.Worker/Queue/QueueService.cs:349-352` - Test: `tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs` (neu) - [ ] **Step 1: Failing test schreiben** Create `tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs`: ```csharp using ClaudeDo.Data.Models; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.State; using ClaudeDo.Worker.Tests.Infrastructure; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using Xunit; namespace ClaudeDo.Worker.Tests.Services; // The queue picker's raw-SQL claim commits status='running' before the runner starts. If the // runner then throws, RunInSlotAsync used to only log — leaving the task Running in the DB with // the UI never notified. It must transition the task to Failed, which broadcasts TaskUpdated. public sealed class QueueServiceSlotFailureTests : IDisposable { private readonly DbFixture _db = new(); public void Dispose() => _db.Dispose(); [Fact] public async Task A_throwing_run_marks_the_task_Failed_and_broadcasts_TaskUpdated() { var dbFactory = _db.CreateFactory(); string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString(); using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow }); // Mirrors the state QueuePicker leaves behind: already claimed as Running. ctx.Tasks.Add(new TaskEntity { Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Running, StartedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } // Build() already wires its own CapturingHubContext and hands it back as .Hub — // do not construct a second one, the broadcaster inside TaskStateService uses this one. var built = TaskStateServiceBuilder.Build(dbFactory); // Exercise the exact failure handling RunInSlotAsync performs, without spinning up the // whole BackgroundService: the contract is "an exception must end in FailAsync". await SimulateSlotFailureAsync(built.State, taskId, new InvalidOperationException("boom")); using (var ctx = _db.CreateContext()) { var task = ctx.Tasks.Single(t => t.Id == taskId); Assert.Equal(TaskStatus.Failed, task.Status); } Assert.Contains(built.Hub.Proxy.Calls, c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId); } private static async Task SimulateSlotFailureAsync(ITaskStateService state, string taskId, Exception ex) { if (ex is OperationCanceledException) return; await state.FailAsync(taskId, DateTime.UtcNow, $"Slot runner error: {ex.Message}", CancellationToken.None); } } ``` **Hinweis:** Der Test prüft die Zustandsmaschine, nicht den `BackgroundService`-Lebenszyklus — `QueueService` startet einen Endlos-Loop und ist im Test schwer sauber zu treiben. Der Test ist damit bewusst ein Vertragstest über `FailAsync`, kein End-to-End-Test von `RunInSlotAsync`. Er ist **grün, bevor** der Produktionscode geändert wird; sein Wert liegt darin, den Vertrag festzunageln, den Step 3 dann verdrahtet. - [ ] **Step 2: Test laufen lassen** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~QueueServiceSlotFailureTests ``` Expected: grün. Ist er rot, stimmt eine Annahme über `FailAsync` oder den Builder nicht — dann **erst das klären**, bevor Step 3 beginnt. - [ ] **Step 3: Produktionscode ändern** In `src/ClaudeDo.Worker/Queue/QueueService.cs` ersetzen: ```csharp catch (Exception ex) { _logger.LogError(ex, "Slot runner error for task {TaskId}", taskId); } ``` durch: ```csharp catch (OperationCanceledException) { // Cancellation is driven by the cancel path, which already wrote the terminal status. _logger.LogInformation("Slot runner cancelled for task {TaskId}", taskId); } catch (Exception ex) { _logger.LogError(ex, "Slot runner error for task {TaskId}", taskId); // The picker already committed status='running'. Without this the task stays Running // forever and the UI never hears about it — it keeps showing the pre-claim status. try { await _state.FailAsync(taskId, DateTime.UtcNow, $"Slot runner error: {ex.Message}", CancellationToken.None); } catch (Exception failEx) { _logger.LogError(failEx, "Could not mark task {TaskId} as failed after a slot error", taskId); } } ``` - [ ] **Step 4: Tests laufen lassen** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~QueueServiceSlotFailureTests ``` Expected: `Passed! - Failed: 0`. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Queue/QueueService.cs tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs git commit -m "fix(worker): fail the task when a queue slot runner throws" -- src/ClaudeDo.Worker/Queue/QueueService.cs tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs ``` --- ### Task 5: `WorktreeUpdated` nach dem Anlegen eines Worktrees `WorktreeManager.CreateAsync` schreibt die `worktrees`-Zeile (`WorktreeManager.cs:103`) ohne Broadcast. Der Aufrufer in `TaskRunner.PrepareRunDirectoryAsync` hat den Broadcaster bereits — der Fix bleibt dort und lässt die Konstruktorsignatur von `WorktreeManager` unangetastet (das spart die Anpassung aller Test-Fakes). **Files:** - Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:313` - Test: `tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs` - [ ] **Step 1: Produktionscode ändern** In `src/ClaudeDo.Worker/Runner/TaskRunner.cs` ersetzen: ```csharp var wtCtx = await _wtManager.CreateAsync(task, list, ct); await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow); return new RunDirResult(wtCtx.WorktreePath, wtCtx, null); ``` durch: ```csharp var wtCtx = await _wtManager.CreateAsync(task, list, ct); await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow); // The worktrees row was just inserted; without this the UI keeps showing the task // as having no worktree until some unrelated event happens to refresh it. await _broadcaster.WorktreeUpdated(task.Id); return new RunDirResult(wtCtx.WorktreePath, wtCtx, null); ``` - [ ] **Step 2: Test ergänzen** An `tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs` anhängen (innerhalb der Klasse): ```csharp [Fact] public async Task Creating_a_worktree_broadcasts_WorktreeUpdated() { string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString(); var repoDir = Path.Combine(_tempDir, "repo"); Directory.CreateDirectory(repoDir); // A real git repo — Worker.Tests run real git by design. await RunGitAsync(repoDir, "init"); await RunGitAsync(repoDir, "config user.email t@t.t"); await RunGitAsync(repoDir, "config user.name t"); await File.WriteAllTextAsync(Path.Combine(repoDir, "a.txt"), "hi"); await RunGitAsync(repoDir, "add a.txt"); await RunGitAsync(repoDir, "commit -m init"); using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repoDir, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Running, StartedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } var fake = new FakeClaudeProcess((_, _, _, _, _) => Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" })); var runner = BuildRunner(fake); using (var ctx = _db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync(taskId))!, "queue", CancellationToken.None, alreadyClaimed: true); Assert.Contains(_hubContext.Proxy.Calls, c => c.Method == "WorktreeUpdated" && (string)c.Args[0]! == taskId); } private static async Task RunGitAsync(string dir, string args) { var psi = new System.Diagnostics.ProcessStartInfo("git", args) { WorkingDirectory = dir, RedirectStandardOutput = true, RedirectStandardError = true, }; using var p = System.Diagnostics.Process.Start(psi)!; await p.WaitForExitAsync(); } ``` - [ ] **Step 3: Tests laufen lassen** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~QueueClaimTaskUpdatedBroadcastTests ``` Expected: alle drei Tests grün. Schlägt der neue Test fehl, weil `WorktreeUpdated` doppelt kommt (der Commit-Pfad in `TaskRunner.cs:465` broadcastet ebenfalls), ist das kein Fehler — `Assert.Contains` prüft nur auf Vorhandensein. - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs git commit -m "fix(worker): broadcast WorktreeUpdated when a worktree is created" -- src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs ``` --- ### Task 6: `TaskUpdated` nach dem Import aus der Online-Inbox `OnlineSyncService` legt importierte Tasks an (`OnlineSyncService.cs:131`) ohne Broadcast — sie erscheinen erst, wenn die Liste manuell neu geladen wird. `OnlineSyncService` hat noch keinen Broadcaster; er wird per Konstruktor ergänzt. Einzige weitere Aufrufstelle ist `tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs:62`, plus die DI-Registrierung in `src/ClaudeDo.Worker/Program.cs`. **Files:** - Modify: `src/ClaudeDo.Worker/Online/OnlineSyncService.cs` - Modify: `src/ClaudeDo.Worker/Program.cs` (nur falls der Hosted-Service explizit konstruiert wird) - Test: `tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs` - [ ] **Step 1: Failing test ergänzen** In `tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs` einen `CapturingHubContext` als Feld anlegen und an den Service durchreichen, dann anhängen: ```csharp [Fact] public async Task Importing_a_remote_task_broadcasts_TaskUpdated() { // Arrange the existing fixture so exactly one remote task is pending import, // using the same seeding helpers the other tests in this file use. var taskId = await SeedSinglePendingRemoteTaskAsync(); var service = BuildService(); await service.SyncOnceAsync(CancellationToken.None); Assert.Contains(_hubContext.Proxy.Calls, c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId); } ``` `SeedSinglePendingRemoteTaskAsync` und `BuildService` an die im File bereits vorhandenen Helfer anpassen; existiert kein öffentlicher `SyncOnceAsync`, die im File verwendete Methode nehmen. **Nichts erfinden — erst die Datei lesen, dann den Test daran anpassen.** - [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~OnlineSyncServiceTests ``` Expected: der neue Test schlägt fehl (kein `TaskUpdated` in `Calls`). - [ ] **Step 3: Broadcaster einziehen** In `src/ClaudeDo.Worker/Online/OnlineSyncService.cs` ein Feld ergänzen: ```csharp private readonly HubBroadcaster _broadcaster; ``` Konstruktorparameter `HubBroadcaster broadcaster` ergänzen und zuweisen (`_broadcaster = broadcaster;`), `using ClaudeDo.Worker.Hub;` ergänzen. Danach ersetzen: ```csharp await tasks.AddAsync(entity, ct); await _api.MarkImportedAsync(remote.Id, ct); ``` durch: ```csharp await tasks.AddAsync(entity, ct); // Without this the imported task only shows up after a manual reload. await _broadcaster.TaskUpdated(entity.Id); await _api.MarkImportedAsync(remote.Id, ct); ``` - [ ] **Step 4: Aufrufstellen anpassen** Run: ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release ``` Expected: Fehler an jeder Konstruktor-Aufrufstelle. Jede melden lassen und `HubBroadcaster` ergänzen — in der Produktion wird er per DI aufgelöst, im Test per `new HubBroadcaster(_hubContext)`. - [ ] **Step 5: Tests laufen lassen** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~OnlineSyncServiceTests ``` Expected: 0 Failed. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Worker/Online/OnlineSyncService.cs tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs git commit -m "fix(worker): broadcast TaskUpdated for tasks imported from the online inbox" -- src/ClaudeDo.Worker/Online/OnlineSyncService.cs tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs ``` --- ### Task 7: ~~`TaskUpdated` nach dem Anlegen der List-Handler-Task~~ — ENTFÄLLT **Bei der Umsetzung am 2026-08-07 verworfen. Prämisse war falsch, kein Code geändert.** `InteractiveLaunchSpecService.CreateMergeHelperTaskAsync` (`:447`) broadcastet selbst nichts — das stimmte. Aber ihr **einziger** Produktions-Aufrufer, `WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:818`), sendet unmittelbar danach `Clients.All.SendAsync("TaskUpdated", taskId)`. Das kam mit Commit `c07c1f7` (2026-08-05) und ist durch `MergeHelperTaskHubTests.CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts` abgesichert. Der UI-Pfad (`MissionControlViewModel` → `WorkerClient.CreateMergeHelperTaskAsync` → Hub) führt ausschließlich über diesen Aufrufer. Den Broadcast zusätzlich in den Service zu legen hätte ihn **verdoppelt**. Ihn dorthin zu *verschieben* wäre ein reiner Konsistenz-Refactor ohne Verhaltensänderung — bewusst nicht gemacht. Lehre für den Rest des Plans: Ein DB-Write ohne unmittelbar folgenden Broadcast ist erst dann ein Loch, wenn auch **alle Aufrufer** geprüft sind. Bei `WorktreeManager.cs:103` (Task 5) war das Loch echt, hier nicht.
Ursprünglicher Task-Text (nicht umgesetzt) **Files:** - Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` - Test: `tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs` - [ ] **Step 1: Failing test ergänzen** In `tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs` einen `CapturingHubContext` verdrahten (falls die Datei schon einen hat, den vorhandenen nutzen) und anhängen: ```csharp [Fact] public async Task Creating_the_handler_task_broadcasts_TaskUpdated() { // Use the same setup the other tests in this file use to reach handler-task creation. var handlerTaskId = await CreateHandlerTaskAsync(); Assert.Contains(_hubContext.Proxy.Calls, c => c.Method == "TaskUpdated" && (string)c.Args[0]! == handlerTaskId); } ``` `CreateHandlerTaskAsync` an den im File vorhandenen Weg zur Handler-Task-Erzeugung anpassen. **Erst die Datei lesen.** - [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~MergeHelperTaskHubTests ``` Expected: der neue Test schlägt fehl. - [ ] **Step 3: Broadcaster einziehen** In `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` Feld ergänzen: ```csharp private readonly HubBroadcaster _broadcaster; ``` Konstruktorparameter `HubBroadcaster broadcaster` ergänzen und zuweisen, `using ClaudeDo.Worker.Hub;` ergänzen. Dann ersetzen: ```csharp await taskRepo.AddAsync(handlerTask, ct); return handlerTask.Id; ``` durch: ```csharp await taskRepo.AddAsync(handlerTask, ct); // Without this the handler task only shows up after a manual reload. await _broadcaster.TaskUpdated(handlerTask.Id); return handlerTask.Id; ``` - [ ] **Step 4: Aufrufstellen anpassen und bauen** Run: ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release ``` Expected: nach Anpassung der Aufrufstellen `Build succeeded`. - [ ] **Step 5: Tests laufen lassen** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~MergeHelperTaskHubTests ``` Expected: 0 Failed. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs git commit -m "fix(worker): broadcast TaskUpdated when the list-handler task is created" -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs ```
--- ### Task 8: Totes Event `RunCreated` entfernen `RunCreated` wird gesendet (`TaskRunner.cs:358`), aber die UI hat keinen `_hub.On<...>("RunCreated")`-Handler — der Aufruf kostet nur einen SignalR-Roundtrip. Ersatzlos entfernen. **Files:** - Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs:43-44` - Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:358` - Modify: `src/ClaudeDo.Worker/CLAUDE.md:162` - [ ] **Step 1: Gegenprüfen, dass es wirklich keinen Abonnenten gibt** Run: ```bash grep -rn "RunCreated" src/ClaudeDo.Ui/ src/ClaudeDo.App/ --include=*.cs ``` Expected: keine Ausgabe. Gibt es doch einen Treffer, **Task hier abbrechen** und melden — dann ist das Event nicht tot. - [ ] **Step 2: Sendeaufruf entfernen** In `src/ClaudeDo.Worker/Runner/TaskRunner.cs` die Zeile ```csharp await _broadcaster.RunCreated(taskId, runNumber, isRetry); ``` ersatzlos löschen. - [ ] **Step 3: Broadcaster-Methode entfernen** In `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` löschen: ```csharp public Task RunCreated(string taskId, int runNumber, bool isRetry) => _hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry); ``` - [ ] **Step 4: Doku angleichen** In `src/ClaudeDo.Worker/CLAUDE.md` den Listeneintrag `- \`RunCreated\`` (Zeile 162) aus der Event-Aufzählung entfernen. - [ ] **Step 5: Bauen und testen** Run: ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release ``` Expected: `Build succeeded`, 0 Failed. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Runner/TaskRunner.cs src/ClaudeDo.Worker/CLAUDE.md git commit -m "chore(worker): drop the unsubscribed RunCreated broadcast" -- src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Runner/TaskRunner.cs src/ClaudeDo.Worker/CLAUDE.md ``` --- ### Task 9: Abschlussverifikation - [ ] **Step 1: Alle betroffenen Suiten laufen lassen** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release ``` Expected: dreimal `Failed: 0`. Die tatsächlichen Zahlen notieren — nicht behaupten, es sei grün, ohne die Ausgabe gesehen zu haben. - [ ] **Step 2: Beide Anwendungen bauen** Run: ```bash dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release ``` Expected: zweimal `Build succeeded`. - [ ] **Step 3: Manuelle Verifikation an den Nutzer melden** Diese Punkte sind nicht automatisiert prüfbar und müssen ausdrücklich als offen gemeldet werden: - Ein Task, der aus der Queue startet, wechselt in der Liste live von `Queued` auf `Running`. - Ein Task, dessen Lauf mit einem Fehler abbricht, landet auf `Failed` statt auf `Running` hängen zu bleiben. - Eine neu angelegte List-Handler-Task erscheint ohne manuelles Neuladen. - Der Branch-Chip einer Zeile erscheint, sobald der Worktree angelegt ist. --- ## Was dieser Plan bewusst NICHT tut - **Kein Reconcile-Tick.** Der gehört in Phase 3 und setzt die virtualisierte Liste aus Phase 2 voraus — auf einer Liste, die 1–2 s zum Laden braucht, würde ein periodischer Abgleich mehr schaden als nützen. - **Kein Umbau des Setup-Blocks in `TaskRunner.ContinueAsync`.** Task 4 fängt jede dort geworfene Exception bereits über `FailAsync` ab; ein zweiter Schutzwall wäre doppelt. - **Keine Änderung an Modals oder Overlays.** Die bleiben bis Phase 3 statisch. - **Keine Performance-Arbeit.** Virtualisierung, ContextMenu-Lazy-Loading und serverseitige Filterung sind Phase 2.