# 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 -- <pfade>` 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<ClaudeDoDbContext>(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<ClaudeDoDbContext>(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<ClaudeDoDbContext>(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<ClaudeDoDbContext>(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<ClaudeDoDbContext>()
            .UseSqlite($"Data Source={_dbPath}")
            .Options;
        return new ClaudeDoDbContext(opts);
    }

    // Throws on the first N CreateDbContext calls, then behaves normally.
    private sealed class FlakyDbFactory : IDbContextFactory<ClaudeDoDbContext>
    {
        private readonly Func<ClaudeDoDbContext> _create;
        private int _failuresLeft;
        public int CreateCalls { get; private set; }

        public FlakyDbFactory(Func<ClaudeDoDbContext> 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<string, long> _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.

<details>
<summary>Ursprünglicher Task-Text (nicht umgesetzt)</summary>

**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
```

</details>

---

### 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.
