Files
ClaudeDo/docs/superpowers/plans/2026-08-07-phase1-reaktivitaets-loecher.md
T

41 KiB
Raw Blame History

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):
    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:

        sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
            opt.UseSqlite($"Data Source={dbPath}"));

durch:

        // 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:

builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
    opt.UseSqlite($"Data Source={cfg.DbPath}"));

durch:

// 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:

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
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:

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:

        public void FailNext() => _failuresLeft++;
  • Step 2: Test laufen lassen, Fehlschlag bestätigen

Run:

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

    private async void OnWorkerTaskUpdated(string taskId)
    {

umbenennen und eine awaitbare Methode daraus machen. Der Event-Handler bleibt async void, delegiert aber:

    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

        catch { }
    }

ersetzen durch:

        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:

    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:

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:

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
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:

    [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:

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:

    // 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:

        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:

    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:

dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release

Expected: 0 Failed.

  • Step 5: Commit
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 218238) — 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:

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:

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:

        catch (Exception ex)
        {
            _logger.LogError(ex, "Slot runner error for task {TaskId}", taskId);
        }

durch:

        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:

dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~QueueServiceSlotFailureTests

Expected: Passed! - Failed: 0.

  • Step 5: Commit
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:

                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:

                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):

    [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:

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
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:

    [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:

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:

    private readonly HubBroadcaster _broadcaster;

Konstruktorparameter HubBroadcaster broadcaster ergänzen und zuweisen (_broadcaster = broadcaster;), using ClaudeDo.Worker.Hub; ergänzen. Danach ersetzen:

            await tasks.AddAsync(entity, ct);
            await _api.MarkImportedAsync(remote.Id, ct);

durch:

            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:

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:

dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~OnlineSyncServiceTests

Expected: 0 Failed.

  • Step 6: Commit
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:

    [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:

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:

    private readonly HubBroadcaster _broadcaster;

Konstruktorparameter HubBroadcaster broadcaster ergänzen und zuweisen, using ClaudeDo.Worker.Hub; ergänzen. Dann ersetzen:

        await taskRepo.AddAsync(handlerTask, ct);

        return handlerTask.Id;

durch:

        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:

dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release

Expected: nach Anpassung der Aufrufstellen Build succeeded.

  • Step 5: Tests laufen lassen

Run:

dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~MergeHelperTaskHubTests

Expected: 0 Failed.

  • Step 6: Commit
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:

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

        await _broadcaster.RunCreated(taskId, runNumber, isRetry);

ersatzlos löschen.

  • Step 3: Broadcaster-Methode entfernen

In src/ClaudeDo.Worker/Hub/HubBroadcaster.cs löschen:

    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:

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
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:

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:

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