1063 lines
42 KiB
Markdown
1063 lines
42 KiB
Markdown
# 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.
|