diff --git a/docs/superpowers/plans/2026-08-07-phase1-reaktivitaets-loecher.md b/docs/superpowers/plans/2026-08-07-phase1-reaktivitaets-loecher.md new file mode 100644 index 00000000..65ec1793 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-phase1-reaktivitaets-loecher.md @@ -0,0 +1,1051 @@ +# 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 + +`InteractiveLaunchSpecService` legt die Handler-Task an (`InteractiveLaunchSpecService.cs:447`) ohne Broadcast. Einzige weitere Aufrufstelle des Konstruktors ist `tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs:64`. + +**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. diff --git a/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md b/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md index 346b647e..01275a0e 100644 --- a/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md +++ b/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md @@ -27,7 +27,7 @@ Das eigentliche Problem: **ein einziger verlorener Event ist permanent.** Der ei Dazu zwei kleinere Befunde: - **Race im Delta-Pfad.** `OnWorkerTaskUpdated` ist `async void` und hängt an *zwei* Events (`TaskUpdatedEvent` und `WorktreeUpdatedEvent`, `TasksIslandViewModel.cs:117-118`). Der Full-Reload-Zweig ist per `_loadCts` gegen Überholen abgesichert, der Delta-Zweig nicht — ein älterer Read kann einen neueren überschreiben. -- **`busy_timeout` ist nicht gesetzt** (`src/ClaudeDo.Data/ClaudeDoDbContext.cs:93`). Ohne Timeout schlägt ein seltener `SQLITE_BUSY` sofort als Exception durch, statt kurz zu warten — das erhöht die Wahrscheinlichkeit von Loch 1. +- **Kein Busy-Timeout konfiguriert.** Die Connection-Strings beider Prozesse sind blanke `Data Source=…` (`src/ClaudeDo.App/Program.cs:100-101`, `src/ClaudeDo.Worker/Program.cs:60-61`). Ohne Timeout schlägt ein seltener `SQLITE_BUSY` sofort als Exception durch, statt kurz zu warten — das erhöht die Wahrscheinlichkeit von Loch 1. Achtung: `PRAGMA busy_timeout` ist **per Connection** und wird — anders als `journal_mode=WAL` — *nicht* in der DB-Datei persistiert. Es in `ClaudeDoDbContext.MigrateAndConfigure` zu setzen würde nur die Startup-Connection betreffen und wäre wirkungslos; es gehört in den Connection-String (`Default Timeout=`), den Microsoft.Data.Sqlite auf den Busy-Handler abbildet. - **`RunCreated` ist ein totes Event.** Wird in `TaskRunner.cs:358` gesendet, hat aber keinen einzigen Abonnenten in der UI. ### Performance: der Engpass ist das Rendering, nicht die Datenbank @@ -95,15 +95,16 @@ Unabhängig von Phase 2 und 3, kann sofort starten. | Fix | Ort | |---|---| | `catch { }` ersetzen durch Log + einmaligen Retry. **Kein** Footer-Error — das ist ein Hintergrund-Refresh, keine Nutzeraktion. | `TasksIslandViewModel.cs:224` | -| `PRAGMA busy_timeout` setzen (gilt für beide Prozesse, da in `MigrateAndConfigure`) | `ClaudeDoDbContext.cs:93` | -| Catch-Block ruft `_state.FailAsync` und broadcastet, statt nur zu loggen | `QueueService.cs:349-352` | -| Setup-Block (Zeilen 218–238) in den bestehenden `try` ziehen, damit `MarkFailed` greift | `TaskRunner.ContinueAsync` | +| `Default Timeout=30` in beide Connection-Strings (nicht als PRAGMA — siehe Analyse) | `App/Program.cs:100-101`, `Worker/Program.cs:60-61` | +| Catch-Block ruft `_state.FailAsync` (das selbst broadcastet), statt nur zu loggen; `OperationCanceledException` bleibt ausgenommen | `QueueService.cs:349-352` | | `WorktreeUpdated` nach dem Insert broadcasten | `Runner/WorktreeManager.cs:103` | | `TaskUpdated` nach dem Insert broadcasten | `Online/OnlineSyncService.cs:131` | | `TaskUpdated` nach dem Insert broadcasten | `Runner/InteractiveLaunchSpecService.cs:447` | | Monotone Sequenznummer pro TaskId im Delta-Pfad; Ergebnisse mit veralteter Sequenz verwerfen | `OnWorkerTaskUpdated` | | `RunCreated` ersatzlos entfernen (totes Event ohne Abonnent) | `HubBroadcaster`, `TaskRunner.cs:358` | +Der ungeschützte Setup-Block in `TaskRunner.ContinueAsync` (Zeilen 218–238) wird **nicht** separat umgebaut: sobald `RunInSlotAsync` im Fehlerfall `FailAsync` ruft, ist jede dort geworfene Exception abgedeckt — der Task landet auf `Failed` und der Broadcast erfolgt. Ein zweiter Schutzwall wäre doppelt. + ### Phase 2 — Flache virtualisierte Liste **Datenmodell.** `Regroup()` erzeugt statt drei Collections **eine** `Rows`-Collection vom Union-Typ (`HeaderRow` | `TaskRowViewModel`), abgeleitet aus der bereits vorhandenen flachen `Items`. Gruppenüberschriften werden zu regulären Einträgen: