17 KiB
Feedback für langlaufende Operationen
Datum: 2026-08-11 Status: Design freigegeben, Implementierung offen Auslöser: Ein Merge über das Detail-Pane blockierte minutenlang ohne jede Anzeige.
Problem
Langlaufende Operationen geben kein Feedback. Der Nutzer klickt, nichts passiert sichtbar,
die App liest sich als abgestürzt. Der gemeldete Fall war ein Merge mit Verify-Gate, aber das
ist ein Symptom eines Musters: Feedback wird pro Fall ad-hoc gebaut
(PrepStarted/Line/Finished, RefineStarted/Finished, PlanningMerge*, MergeProgress),
und dabei geht regelmäßig eine Hälfte verloren oder eine Fläche wird vergessen.
Belegend: HubBroadcaster.MergeProgress existierte bereits, während WorkerClient noch
keinen Listener hatte — Sender ohne Empfänger. Das wurde parallel zu diesem Design in einer
anderen Session nachgezogen (siehe Abhängigkeit zur Parallelarbeit), deckt aber nur das
MergeModal ab, nicht den Approve-Pfad im Detail-Pane.
Vier Fehlerbilder
Die Analyse trennt vier Klassen, die verschiedene Lösungen brauchen. Sie zusammen als „fehlendes Feedback" zu behandeln war der Fehler der bisherigen Einzelfall-Fixes.
Bild 1 — Stiller Await im UI
Ein [RelayCommand] awaitet einen Call, ohne Busy-State, ohne Anzeige. Der Button bleibt
klickbar (Doppelklick-Risiko), es gibt keinen Hinweis, dass etwas läuft.
| Stelle | Befund |
|---|---|
src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:1206 ApproveReviewAsync |
Der gemeldete Fall. Kein Busy-Flag, kein Indikator; das Verify-Gate kann den Call Minuten halten |
DetailsIslandViewModel.cs:1244 SubmitForReviewAsync |
kein Busy-Flag (MissionControlViewModel hat für denselben Call ein IsSubmitPending, das Detail-Pane nicht) |
DetailsIslandViewModel.cs RejectReviewAsync, ParkReviewAsync |
kein Busy-Flag; Fehler werden verschluckt (catch { return; }) |
src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs:121 PreviewMergeAsync |
kein Indikator |
WorktreesOverviewModalViewModel (IsBusy vorhanden) |
IsBusy schaltet in WorktreesOverviewModalView.axaml:107-108 nur Buttons aus — kein Spinner, kein Text. Betrifft Cleanup, Reset, ForceRemove, Refresh, Batch-Merge |
Settings-Tabs: SessionSkillsSettingsTabViewModel, FilesSettingsTabViewModel, OnlineInboxSettingsViewModel |
dito — IsEnabled="{Binding !IsBusy}", keine Anzeige. Skill-Install ist ein git clone |
WeeklyReportModalViewModel, RepoImportModalViewModel |
IsBusy nur als CanExecute-Gate |
| Islands ↔ SQLite direkt | Die Islands sprechen direkt mit der DB (new TaskRepository(ctx) u.ä., ~15 Stellen allein in DetailsIslandViewModel). Meist schnell; ClearCompleted, Drag-Reorder über viele Zeilen und das Laden großer Listen nicht zwingend |
Bereits sauber gelöst und nicht anzufassen: MergeModalViewModel (Spinner + Phase +
Elapsed), ConflictResolverViewModel (IsBusy + Text), DiffViewerViewModel.IsLoadingCombined,
UsageMonitorModalViewModel (Spinner).
Bild 2 — UI-Thread-Freeze
Eine andere Fehlerklasse: hier hilft kein Indikator, weil der Dispatcher blockiert ist und der Spinner nicht gezeichnet werden könnte. Erst auslagern, dann anzeigen.
| Stelle | Befund |
|---|---|
src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs:182 und :260 |
UnifiedDiffParser.Parse(raw) läuft synchron auf dem UI-Thread |
src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs:102 |
DiffAlignment.Build(File?.Lines) ebenfalls |
Im gesamten ClaudeDo.Ui-Projekt gibt es zwei Task.Run-Offloads (RepoScanner,
RestartWorkerService). CPU-Arbeit auf dem UI-Thread ist damit die Norm, nicht die Ausnahme.
Bild 3 — Worker-Stille
Operationen ohne UI-Auslöser. Kein Client erfährt, dass sie laufen; die UI zeigt im Startfall nur „reconnecting".
| Stelle | Warum langsam |
|---|---|
6× src/ClaudeDo.Worker/Lifecycle/*Recovery.cs beim Worker-Start (OrphanRecovery, StaleTaskRecovery, PromptFileRecovery, AttachmentOrphanRecovery, PlanningLineageRecovery, LegacyWorktreeFolderRecovery) |
git + DB über alle Tasks/Worktrees |
src/ClaudeDo.Worker/Runner/WorktreeManager.cs — Worktree-Anlage beim Task-Start |
git worktree add + Branch; stille Lücke zwischen Queued und erster Ausgabe |
src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs |
Hintergrund-git über alle Worktrees |
TaskMergeService.RebaseOthersAfterMergeAsync |
läuft innerhalb des Merge-Calls, nach dem Phasen-Broadcast — vollständig unsichtbar |
PlanningAggregator, PlanningMergeOrchestrator |
ein Merge pro Subtask |
OnlineSyncService, UsageMonitorService, PrimeScheduler, QueueService |
periodisch, Netz/git |
Querverweis: docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md
führt WorktreeManager.cs:103 als DB-Write-ohne-Broadcast (Loch 3). C3 muss prüfen, ob das
inzwischen geschlossen ist, statt es doppelt zu beheben.
Bild 4 — MCP-Stille
Trifft nicht die UI, sondern Agenten-Runs. Nur drei Dateien reporten Progress:
ExternalMcpService (5 Tools), TaskWaitMcpTools, TaskMergeService. Ohne Progress laufen
u.a. die 8 batch_*-Tools, cleanup_task_worktree, get_task_diff, preview_merge_set.
Das ist die dokumentierte Ursache des Traps „MCP 300s idle abort leaves merges stuck": der Client bricht bei Stille ab, während der Worker weiterarbeitet, und der Task bleibt hängen.
Bewusst ausgeschlossen: der Installer
ClaudeDo.Installer hat eine vollständige IProgress<string>-Step-Pipeline mit Live-Anzeige
(Core/InstallerService.cs, Steps/*, Pages/InstallPage). Das ist die beste Progress-Fläche
im Repo und braucht nichts.
Design
1. UI-Primitive: OperationStatus
Eine ObservableObject-Klasse, die ein ViewModel als Property exponiert. Mehrere pro VM sind
erlaubt und erwünscht — WorktreesOverviewModalViewModel braucht getrennte für Refresh,
Cleanup und Merge, sonst blockiert ein laufender Refresh die Cleanup-Anzeige.
| Property | Verhalten |
|---|---|
IsRunning |
sofort true → treibt CanExecute, verhindert Doppelklick |
ShowIndicator |
erst nach 300 ms true → kein Flackern bei schnellen Calls |
Label |
lokalisierter Text, mid-flight überschreibbar (Report(label)) |
Elapsed |
mm:ss, lokal getickt |
IsStalled |
true, wenn 60 s ohne Aktualisierung vergangen sind → Hinweis „läuft weiter, Worker antwortet noch" |
IsStalled bemisst sich an der Zeit seit dem letzten Report, nicht an der Gesamtdauer.
Sonst würde ein regulär mehrminütiges Verify-Gate fälschlich als hängend gemeldet. Eine
Operation ohne jeden Report gilt nach 60 s als stalled — das ist der Fall, der heute wie ein
Absturz aussieht.
Benutzung:
using var op = Approve.Begin(Loc.T("ops.merge.merging"));
var result = await _worker.ApproveReviewAsync(...);
Dispose beendet die Operation auch im Exception-Fall.
Zeitquelle ist injizierbar (TimeProvider, in .NET 8 vorhanden), kein statischer
DispatcherTimer. Ein geteilter statischer Timer wäre genau die Sorte Shared State, die die
Ui.Tests reihenfolgen-abhängig flaky macht. Timer-Callbacks kommen vom Threadpool und müssen
auf den Dispatcher gepostet werden.
Fehlerbehandlung bleibt unverändert. Weiter über ShowErrorAsync / ErrorReported /
FlashFooterError. OperationStatus transportiert keine Fehler.
2. UI-Control: OperationIndicator
src/ClaudeDo.Ui/Views/Controls/OperationIndicator.axaml — Spinner (Ellipse.spinner aus
IslandStyles) + Label + Elapsed, gebunden an eine OperationStatus. Ohne das Control wird
die Spinner-StackPanel aus MergeModalView.axaml:28-30 acht Mal von Hand nachgebaut.
Alle Texte über Locale-Keys im Namespace ops.*; en/de-Parität erzwingen die
Localization.Tests.
3. Worker-Kanal: ein generisches Progress-Event
Der Worker sendet nur, was das UI nicht erraten kann — interne Phasen und Zählstände. Beim Klick weiß das UI selbst, was es angestoßen hat.
OperationProgress(string opKey, string phase, int current, int total)
opKey= TaskId bei task-gebundenen Operationen, sonst ein stabiler String ("worktree-cleanup","startup-recovery","planning-integration:<taskId>")- Kein Elapsed auf der Leitung — das tickt das UI
current/totalerlaubt echten Fortschritt („Worktree 3/12"), was das heutigeMergeProgressnicht kann
Dieses Event ersetzt MergeProgress. Begründung, dass sich die Verallgemeinerung lohnt:
es gibt drei Produzenten (Merge-Phasen, Worktree-Cleanup, Planning-Integration), nicht einen.
Aber IWorkerClient.MergeProgressEvent bleibt als dünner Forwarder bestehen. Sonst
zerstört C1 die Gruppen-Isolation: A1 abonniert dieses Event, und ein Rename würde C zwingen,
DetailsIslandViewModel zu ändern — eine Datei, die Gruppe A besitzt. Der Forwarder kostet
vier Zeilen und hält die vier Sessions unabhängig. Er darf später entfernt werden, wenn A und
C beide gemergt sind.
4. MCP: ProgressReporter als eigene Klasse
TaskMergeService.RunReportingProgressAsync (Zeile ~175) ist die einzige existierende
Progress-Schleife. Sie wird als eigenständige Klasse extrahiert, sodass jedes MCP-Tool sie
nutzen kann, statt die Schleife zu kopieren. Zusätzlich ein Overload für Element-Fortschritt
(i/n) für die batch_*-Tools.
Festgelegte Grenzen
Bewusst nicht Teil dieses Designs:
- Kein Cancel. Nur Sichtbarkeit. Ein Abbruch wäre bei einem Merge ohnehin irreführend: das
Verify-Gate läuft, wenn der Merge-Commit längst geschrieben ist — „abbrechen" könnte nur
aufhören zu warten bedeuten, nicht zurückrollen. Ohne Cancel entfällt der
opId-Handshake; die Korrelation läuft übertaskIdbzw. das offene Modal. - Keine Footer-Anzeige für laufende Operationen. Anzeige am auslösenden Control und an der
Task-Zeile / im Detail-Pane. Der Footer bleibt für Fehler (
FlashFooterError) und den Worker-Log. - Keine Prozent-Balken. Die meisten Operationen haben kein sinnvolles Total.
current/totalwird als Text gezeigt, nicht als Balken. - Keine EF-Migration. Damit entfällt der Trap paralleler Migrationen.
Messung
„Erst messen, dann instrumentieren" gilt für Bild 1 und 3 — aber an einer Stelle, nicht an zwanzig:
- Ein Timing-Hook in
WorkerClientloggt jeden Hub-Invoke mit Dauer. - Ein zweiter im DB-Pfad der Islands.
Ein Tag Nutzung liefert eine sortierte Liste echter Ausreißer. A5 und C entscheiden danach, statt zu raten. Bild 2 und 4 brauchen keine Messung — der Befund ist aus dem Code eindeutig.
Faustregel für den Zweifelsfall: instrumentiert wird, was git aufruft, Netz nutzt, einen Prozess startet, oder O(n) über unbegrenzt viele DB-Zeilen läuft. Einzelzeilen-Reads nicht.
Parallelisierung: vier Merge-Helper-Gruppen
Der Zuschnitt folgt dem Datei-Eigentum, nicht der Fachlichkeit — nur so können vier Sessions gleichzeitig laufen, ohne sich zu überschreiben.
P0 (Fundament, muss allein zuerst landen)
├── Gruppe A Bild 1: UI-Stille (5 Pakete)
└── Gruppe B Bild 2: UI-Freeze (3 Pakete)
Gruppe D Bild 4: MCP (4 Pakete) ← braucht P0 NICHT, kann sofort starten
Gruppe C Bild 3: Worker (5 Pakete) ← nach P0
| Gruppe | Exklusiv besessene Dateien |
|---|---|
| P0 | OperationStatus.cs, OperationIndicator.axaml, beide locales/*.json |
| A | Island-VMs, Modal-VMs und deren AXAML |
| B | DiffViewerViewModel.cs, DiffTextView.axaml.cs |
| C | HubBroadcaster, WorkerHub, WorkerClient, IWorkerClient, StubWorkerClient, Lifecycle/*Recovery, WorktreeManager |
| D | Worker/External/*McpTools, ExternalMcpService |
Der einzige echte Konfliktkandidat sind die Locale-Dateien. A und C brauchen Keys — D nicht: MCP-Progress-Meldungen gehen an Agenten, nicht an den Nutzer, und bleiben englische Klartext-Strings ohne Locale-Eintrag.
Regel: P0 legt die Keys für A und C vorab an, danach fasst keine Gruppe die JSONs mehr an. Braucht eine Gruppe doch einen nicht vorgesehenen Key, hängt sie ihn als letzte Änderung des Pakets an und behandelt ihn im Merge-Helper als bekannte Konfliktstelle.
Drei erzwungene Serialisierungen:
TaskMergeService— die Parallelsession arbeitet dort; C1 danach.WorkerClient.cs— P0-2 setzt dort den Timing-Hook, C ändert dieselbe Datei. P0 liegt ohnehin vor C, damit unkritisch — aber C darf nicht vor P0 starten.- Innerhalb jeder Gruppe
serializeOnFileOverlapauf der Liste setzen.
Ergänzung zum Datei-Eigentum: Services/UpdateCheckService.cs gehört zu A (Paket A3),
Services/WorkerClient.cs zu C. Beide liegen im selben Ordner, sind aber verschiedene
Dateien.
Abhängigkeit zur Parallelarbeit
Während dieses Designs hat eine andere Session im gemeinsamen main-Checkout das
Merge-Feedback für das MergeModal implementiert — inzwischen committet als
cad0582 fix(merge): conventional merge-commit default and live verify progress:
Phasen-Broadcast in TaskMergeService,
HubBroadcaster.MergeProgress, IWorkerClient.MergeProgressEvent,
MergeModalViewModel.ProgressMessage, Spinner in MergeModalView.axaml, plus Tests.
Konsequenzen:
- Der Merge-Fall im MergeModal ist Vorarbeit, nicht neu zu bauen.
- Zwei Nachbesserungen bleiben: A1 schließt den Approve-Pfad im Detail-Pane an, C1 stellt Elapsed auf UI-lokalen Tick um (die Implementierung lässt den Worker alle 30 s ticken — die ersten 30 Sekunden zeigt sie nur „merging").
- Der Blocker ist damit aufgelöst: C hängt nur noch an P0, nicht mehr an der Parallelsession.
Pakete
P0 — Fundament
| # | Inhalt |
|---|---|
| P0-1 | OperationStatus + OperationIndicator + ops.*-Keys für alle vier Gruppen vorab + Ui.Tests mit Fake-TimeProvider |
| P0-2 | Timing-Hook in WorkerClient + im DB-Pfad der Islands |
| P0-3 | Regel in src/ClaudeDo.Ui/CLAUDE.md und src/ClaudeDo.Worker/CLAUDE.md: jeder IWorkerClient-Call in einem [RelayCommand] läuft durch eine OperationStatus; MCP-Tools über ~5 s reporten Progress |
Gruppe A — Bild 1: UI-Stille
| # | Inhalt |
|---|---|
| A1 | Detail-Pane: Approve & Merge, Submit for review, Reject, Park, Preview-Merge. Abonniert das Merge-Phasen-Event zum Nachschärfen des Labels |
| A2 | WorktreesOverview: Refresh, Cleanup, Reset, ForceRemove, Batch-Merge (inkl. Zeilen-Status) |
| A3 | Settings-Tabs: SessionSkill install/update, Restore-Defaults, OnlineInbox-SignIn, RepoImport-Scan, Update-Check |
| A4 | Reports und Planning-UI: GenerateWeekReport, RunDailyPrepNow, BuildPlanningIntegrationBranch, GetPlanningAggregate, Finalize/QueuePlanningSubtasks |
| A5 | Island-DB-Pfade — nur die, die P0-2 als Ausreißer zeigt |
Gruppe B — Bild 2: UI-Freeze
| # | Inhalt |
|---|---|
| B1 | UnifiedDiffParser.Parse nach Task.Run + Indikator im DiffViewer |
| B2 | DiffAlignment.Build: Grenze definieren (ab N Zeilen auslagern oder inkrementell aufbauen) |
| B3 | Guard-Test: Parse/Build über ein großes Fixture blockiert den Dispatcher nicht |
Gruppe C — Bild 3: Worker-Stille
| # | Inhalt |
|---|---|
| C1 | OperationProgress(opKey, phase, current, total) ersetzt MergeProgress; Elapsed auf UI-lokalen Tick |
| C2 | Startup-Recovery sichtbar (6 Services) — Anzeige im Shell statt nur „reconnecting" |
| C3 | Worktree-Anlage beim Task-Start sichtbar an der Task-Zeile. Vorher prüfen, ob der Broadcast-Gap aus dem 08-07-Spec schon geschlossen ist |
| C4 | RebaseOthersAfterMergeAsync und WorktreeMaintenanceService in den Kanal |
| C5 | Periodische Dienste (Usage, OnlineSync, Prime, Queue): nur Aktivität und Fehler, kein Tick-Spam |
Gruppe D — Bild 4: MCP
| # | Inhalt |
|---|---|
| D1 | ProgressReporter aus TaskMergeService.RunReportingProgressAsync extrahieren, inkl. i/n-Overload |
| D2 | Die 8 batch_*-Tools mit Element-Fortschritt |
| D3 | Worktree- und Diff-Tools: cleanup_task_worktree, batch_cleanup_task_worktrees, get_task_diff, preview_merge_set |
| D4 | Restliche Long-Runner + Doku-Regel im Worker-CLAUDE.md |
Test-Strategie
OperationStatus: reine Unit-Tests mit Fake-TimeProvider— 300-ms-Grace, 60-s-Stall, Elapsed-Formatierung,Disposeim Exception-Fall,Reportüberschreibt Label.- VM-Tests (
Ui.Tests, headless): Command setztIsRunning,CanExecutesperrt während des Laufs, Zustand ist nach einer Exception zurückgesetzt. Fakes:StubWorkerClientmuss bei jedem neuen Event mitwachsen. - B3 ist der einzige Test, der Blockierung prüft: großes Diff-Fixture, Messung, dass der Dispatcher weiter Nachrichten verarbeitet.
- Worker/MCP (
Worker.Tests): Progress-Callbacks werden mit einem Fake-IProgressgezählt; keine echte Claude-CLI, keine echten Timeouts. - Visuell: jedes Paket in A und B hat eine offene visuelle Prüfung. Spinner, Grace-Periode und Layout kann kein Test bestätigen — das läuft über den Nutzer.