From 1cad264967e9838caa62e7045c1052442d24007f Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 10 Aug 2026 15:56:05 +0200 Subject: [PATCH] feat(ui): add reconcile tick to self-heal lost worker broadcasts TasksIslandViewModel now runs a periodic tick that diffs the flat Items collection against SQLite and patches properties in place (capped at 500 rows, sharing the Phase 1 delta sequence guard so it never overtakes a fresher broadcast). Never rebuilds rows or triggers Regroup/LoadForList, so it stays decoupled from the parallel Phase 2b virtualization work. The same cadence now refreshes the long-lived Worktrees Overview, Log Visualizer, and Merge Helper selection overlays; short-lived modals are untouched. --- .../Islands/TasksIslandViewModel.cs | 84 +++++++ .../Modals/LogVisualizerViewModel.cs | 16 +- .../MergeHelperSelectionModalViewModel.cs | 36 ++- .../Modals/WorktreesOverviewModalViewModel.cs | 19 +- src/ClaudeDo.Ui/Views/MainWindow.axaml.cs | 6 + src/ClaudeDo.Ui/Views/WindowDialogService.cs | 6 +- .../TasksIslandReconcileTickTests.cs | 218 ++++++++++++++++++ 7 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandReconcileTickTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index e446fac2..198bd032 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.Globalization; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; @@ -29,6 +30,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable // one task can be in flight at once. Only the newest may write to the row. private readonly Dictionary _deltaSeq = new(); private long _deltaCounter; + // Phase 3 reconcile tick — see the block at the end of this class. + private readonly System.Timers.Timer _reconcileTimer = new(4_000); public event EventHandler? SelectionChanged; public event EventHandler? FocusAddTaskRequested; @@ -127,11 +130,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable } _langChangedHandler = (_, _) => RefreshLocalizedText(); Loc.LanguageChanged += _langChangedHandler; + _reconcileTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync()); + _reconcileTimer.Start(); } public void Dispose() { Loc.LanguageChanged -= _langChangedHandler; + _reconcileTimer.Stop(); + _reconcileTimer.Dispose(); } private void RefreshLocalizedText() @@ -1225,4 +1232,81 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value); SelectionChanged?.Invoke(this, EventArgs.Empty); } + + // ── Phase 3: reconcile tick ────────────────────────────────────────────── + // Self-healing safety net for a lost broadcast: every few seconds, diff the flat `Items` + // master collection against SQLite and patch properties in place. Never rebuilds a row, + // never calls Regroup() (Phase 2b owns OverdueItems/OpenItems/CompletedItems/Regroup and is + // rewriting them in parallel), and never falls back to LoadForList — the delta path already + // owns that escalation. + + // Above this many rows, only the first N (in Items order) are reconciled per tick, so the + // query cost stays bounded instead of growing with an ever-larger list. + internal const int ReconcileRowCap = 500; + + // Set by the shell from the main window's WindowState; the tick no-ops while minimized. + public bool IsWindowVisible { get; set; } = true; + + // Test seam: awaited right after the reconcile query returns and before its results are + // applied, so a test can land a fresher, higher-sequence update in that gap and exercise the + // stale-sequence guard deterministically. Always null outside tests. + internal Func? ReconcileTickTestBarrier { get; set; } + + // Awaitable so tests can drive it deterministically (mirrors RefreshTaskFromWorkerAsync). + internal async Task ReconcileTickAsync(CancellationToken ct = default) + { + var list = _currentList; + if (!IsWindowVisible || list is null) return; + + // Mirrors the full-reload guard in RefreshTaskFromWorkerAsync: matching virtual:queued / + // virtual:running depends on a Planning parent's children, not a single entity. + if (list.Kind == ListKind.Virtual && + (list.Id == "virtual:queued" || list.Id == "virtual:running")) + return; + + var ids = Items.Select(r => r.Id).Take(ReconcileRowCap).ToList(); + if (ids.Count == 0) return; + + // Same monotonic per-task sequence as the Phase 1 delta path, so whichever of the two + // started most recently for a given task id wins, regardless of completion order. + var seqByTaskId = new Dictionary(ids.Count); + foreach (var id in ids) + { + var seq = ++_deltaCounter; + seqByTaskId[id] = seq; + _deltaSeq[id] = seq; + } + + List entities; + try + { + var idSet = ids.ToHashSet(); + await using var db = await _dbFactory.CreateDbContextAsync(ct); + entities = await db.Tasks + .Include(t => t.List) + .Include(t => t.Worktree) + .Where(t => idSet.Contains(t.Id)) + .ToListAsync(ct); + } + catch (OperationCanceledException) { return; } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"TasksIsland: reconcile tick failed ({ex.Message})"); + return; + } + + if (ReconcileTickTestBarrier is { } barrier) await barrier(); + + var byId = entities.ToDictionary(e => e.Id); + foreach (var id in ids) + { + // Superseded by a fresher delta refresh or a later tick that landed while this one + // was reading — its result is stale, discard it. + if (!_deltaSeq.TryGetValue(id, out var current) || current != seqByTaskId[id]) continue; + if (!byId.TryGetValue(id, out var entity)) continue; // deleted; the delta path removes rows, not the tick + + var row = Items.FirstOrDefault(r => r.Id == id); + row?.UpdateFromEntity(entity); + } + } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs index c78fd945..9b4a8a27 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs @@ -41,8 +41,16 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase _worker = worker; _copyToClipboard = copyToClipboard; Rows.CollectionChanged += (_, _) => CopyLastCommand.NotifyCanExecuteChanged(); + // Phase 3 reconcile tick: this overlay is long-lived (stays open while the user reads + // through the log), so refresh it on the same cadence as TasksIslandViewModel's tick + // instead of leaving it frozen at the moment it was opened. + _reconcileTimer.Elapsed += (_, _) => + Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = RefreshAsync()); + _reconcileTimer.Start(); } + private readonly System.Timers.Timer _reconcileTimer = new(4_000); + [RelayCommand] public async Task RefreshAsync() { @@ -65,7 +73,13 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase : Loc.T("modals.logVisualizer.count", Rows.Count); } - [RelayCommand] private void Close() => CloseAction?.Invoke(); + [RelayCommand] + private void Close() + { + _reconcileTimer.Stop(); + _reconcileTimer.Dispose(); + CloseAction?.Invoke(); + } private bool CanCopyLast() => Rows.Count > 0; diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs index 6ab423aa..3885b5b0 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs @@ -51,8 +51,18 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase public TaskCompletionSource?> Result { get; } = new(); public Action? CloseAction { get; set; } + private readonly System.Timers.Timer _reconcileTimer = new(4_000); + public MergeHelperSelectionModalViewModel(IDbContextFactory dbFactory) - => _dbFactory = dbFactory; + { + _dbFactory = dbFactory; + // Phase 3 reconcile tick: this overlay is long-lived (stays open while the user ticks + // through candidates), so refresh it on the same cadence as TasksIslandViewModel's tick + // instead of leaving it frozen at the moment it was opened. + _reconcileTimer.Elapsed += (_, _) => + Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync()); + _reconcileTimer.Start(); + } public void Configure(string listId, string listName) { @@ -60,6 +70,19 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName); } + // LoadAsync rebuilds every row from scratch, which would wipe the user's ticks out from + // under them on every tick — capture and restore selection by id around the reload. + internal async Task ReconcileTickAsync() + { + if (string.IsNullOrEmpty(_listId)) return; + var selected = Tasks.Where(t => t.IsSelected).Select(t => t.Id).ToHashSet(); + await LoadAsync(); + foreach (var t in Tasks) + if (selected.Contains(t.Id)) + t.IsSelected = true; + OnPropertyChanged(nameof(CanConfirm)); + } + public async Task LoadAsync(CancellationToken ct = default) { foreach (var row in Tasks) row.PropertyChanged -= OnRowChanged; @@ -163,6 +186,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase [RelayCommand] private void Confirm() { + StopReconcileTick(); Result.TrySetResult(Tasks.Where(t => t.IsSelected).Select(t => t.Id).ToList()); CloseAction?.Invoke(); } @@ -170,7 +194,17 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase [RelayCommand] private void Cancel() { + StopReconcileTick(); Result.TrySetResult(null); CloseAction?.Invoke(); } + + // Also called from the dialog's native-close fallback (WindowDialogService), which resolves + // Result without going through Confirm/Cancel. Idempotent — Timer.Stop()/Dispose() tolerate + // repeated calls. + internal void StopReconcileTick() + { + _reconcileTimer.Stop(); + _reconcileTimer.Dispose(); + } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs index ef419616..8bce9961 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs @@ -100,8 +100,20 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase _worker = worker; _diffVmFactory = diffVmFactory; _merge = merge; + // Phase 3 reconcile tick: this overlay is long-lived (stays open while the user reviews + // worktrees), so refresh it on the same cadence as TasksIslandViewModel's tick instead of + // leaving it frozen at the moment it was opened. Skipped while a load or batch merge is + // already in flight — reloading mid-merge would replace the row instances the merge loop + // is still updating. + _reconcileTimer.Elapsed += (_, _) => + Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync()); + _reconcileTimer.Start(); } + private readonly System.Timers.Timer _reconcileTimer = new(4_000); + + internal Task ReconcileTickAsync() => IsBusy || IsMerging ? Task.CompletedTask : LoadAsync(); + public void SelectRow(WorktreeOverviewRowViewModel row) { if (SelectedRow is not null) SelectedRow.IsSelected = false; @@ -179,7 +191,12 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase } [RelayCommand] - private void Close() => CloseAction?.Invoke(); + private void Close() + { + _reconcileTimer.Stop(); + _reconcileTimer.Dispose(); + CloseAction?.Invoke(); + } [RelayCommand] private void ShowDiff(WorktreeOverviewRowViewModel? row) diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs index 6cf23803..d506f287 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs @@ -25,7 +25,11 @@ public partial class MainWindow : Window { base.OnPropertyChanged(change); if (change.Property == WindowStateProperty) + { UpdateMaxIcon(); + if (DataContext is IslandsShellViewModel vm && vm.Tasks is not null) + vm.Tasks.IsWindowVisible = WindowState != WindowState.Minimized; + } if (change.Property == OffScreenMarginProperty) RootGrid.Margin = OffScreenMargin; } @@ -48,6 +52,8 @@ public partial class MainWindow : Window WindowState = WindowState.Normal; Activate(); }; + if (vm.Tasks is not null) + vm.Tasks.IsWindowVisible = WindowState != WindowState.Minimized; } } diff --git a/src/ClaudeDo.Ui/Views/WindowDialogService.cs b/src/ClaudeDo.Ui/Views/WindowDialogService.cs index fdf7744d..9ccad7e3 100644 --- a/src/ClaudeDo.Ui/Views/WindowDialogService.cs +++ b/src/ClaudeDo.Ui/Views/WindowDialogService.cs @@ -94,7 +94,11 @@ public sealed class WindowDialogService : IDialogService { var dlg = new MergeHelperSelectionModal { DataContext = vm }; vm.CloseAction = () => dlg.Close(); - dlg.Closed += (_, _) => vm.Result.TrySetResult(null); // native close counts as cancel + dlg.Closed += (_, _) => + { + vm.StopReconcileTick(); + vm.Result.TrySetResult(null); // native close counts as cancel + }; await dlg.ShowDialog(_owner); return await vm.Result.Task; } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandReconcileTickTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandReconcileTickTests.cs new file mode 100644 index 00000000..c47ef405 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandReconcileTickTests.cs @@ -0,0 +1,218 @@ +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; + +// Phase 3 reconcile tick: a periodic, awaitable-for-tests diff of the flat `Items` master +// collection against SQLite that patches properties in place. It must never rebuild a row, +// never call Regroup(), and never fall back to LoadForList — see +// docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md. +public class TasksIslandReconcileTickTests : IDisposable +{ + private readonly string _dbPath; + + public TasksIslandReconcileTickTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_reconcile_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class CountingDbFactory : IDbContextFactory + { + private readonly Func _create; + public int CreateCalls { get; private set; } + public CountingDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() + { + CreateCalls++; + return _create(); + } + } + + private static ListNavItemViewModel UserList(string listEntityId, string name) => + new() { Id = $"user:{listEntityId}", Kind = ListKind.User, Name = name }; + + private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list, int expectedCount = 1) + { + vm.LoadForList(list); + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + await Task.Delay(25); + if (vm.Items.Count >= expectedCount) break; + } + await Task.Delay(50); + } + + private async Task SeedListAsync() + { + await using var db = NewContext(); + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + + private async Task SeedTaskAsync(string id, string title, TaskStatus status, int sortOrder) + { + await using var db = NewContext(); + db.Tasks.Add(new TaskEntity + { + Id = id, ListId = "L1", Title = title, + Status = status, CreatedAt = DateTime.UtcNow, SortOrder = sortOrder, + }); + await db.SaveChangesAsync(); + } + + [Fact] + public async Task Tick_patches_a_diverging_property() + { + await SeedListAsync(); + await SeedTaskAsync("T1", "Original title", TaskStatus.Idle, 0); + + var vm = new TasksIslandViewModel(new CountingDbFactory(NewContext), worker: null); + await LoadAndWaitAsync(vm, UserList("L1", "Work")); + Assert.Equal("Original title", vm.Items.Single(r => r.Id == "T1").Title); + + await using (var db = NewContext()) + { + var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); + t.Title = "Renamed elsewhere"; + await db.SaveChangesAsync(); + } + + await vm.ReconcileTickAsync(); + + Assert.Equal("Renamed elsewhere", vm.Items.Single(r => r.Id == "T1").Title); + } + + [Fact] + public async Task Tick_never_creates_a_new_row_instance() + { + await SeedListAsync(); + await SeedTaskAsync("T1", "Task one", TaskStatus.Idle, 0); + + var vm = new TasksIslandViewModel(new CountingDbFactory(NewContext), worker: null); + await LoadAndWaitAsync(vm, UserList("L1", "Work")); + var rowBefore = vm.Items.Single(r => r.Id == "T1"); + + await using (var db = NewContext()) + { + var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); + t.Status = TaskStatus.Running; + await db.SaveChangesAsync(); + } + + await vm.ReconcileTickAsync(); + + var rowAfter = vm.Items.Single(r => r.Id == "T1"); + Assert.True(ReferenceEquals(rowBefore, rowAfter)); + Assert.Equal(TaskStatus.Running, rowAfter.Status); + } + + [Fact] + public async Task Tick_with_no_changes_raises_no_property_changed() + { + await SeedListAsync(); + await SeedTaskAsync("T1", "Task one", TaskStatus.Idle, 0); + + var vm = new TasksIslandViewModel(new CountingDbFactory(NewContext), worker: null); + await LoadAndWaitAsync(vm, UserList("L1", "Work")); + var row = vm.Items.Single(r => r.Id == "T1"); + + var changedProperties = new List(); + row.PropertyChanged += (_, e) => changedProperties.Add(e.PropertyName); + + await vm.ReconcileTickAsync(); + + Assert.Empty(changedProperties); + } + + [Fact] + public async Task Tick_discards_a_stale_result_when_a_newer_refresh_wins_the_race() + { + await SeedListAsync(); + await SeedTaskAsync("T1", "Task one", TaskStatus.Queued, 0); + + var vm = new TasksIslandViewModel(new CountingDbFactory(NewContext), worker: null); + await LoadAndWaitAsync(vm, UserList("L1", "Work")); + Assert.Equal(TaskStatus.Queued, vm.Items.Single(r => r.Id == "T1").Status); + + var barrierReached = new TaskCompletionSource(); + var proceed = new TaskCompletionSource(); + vm.ReconcileTickTestBarrier = async () => + { + barrierReached.TrySetResult(); + await proceed.Task; + }; + + // Tick reads "Queued" (nothing has changed yet), then parks at the barrier before + // applying its (about to become stale) result. + var tickTask = vm.ReconcileTickAsync(); + await barrierReached.Task; + + // A fresher broadcast lands and wins: the DB flips to Running and the delta path applies + // it with a newer sequence number while the tick is still parked. + await using (var db = NewContext()) + { + var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); + t.Status = TaskStatus.Running; + await db.SaveChangesAsync(); + } + await vm.RefreshTaskFromWorkerAsync("T1"); + Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status); + + // Let the tick's stale "Queued" apply attempt run — the sequence guard must discard it. + proceed.TrySetResult(); + await tickTask; + + Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status); + } + + [Fact] + public async Task Tick_caps_reconciliation_at_the_row_limit() + { + await SeedListAsync(); + var total = TasksIslandViewModel.ReconcileRowCap + 1; + for (var i = 0; i < total; i++) + await SeedTaskAsync($"T{i}", $"Task {i}", TaskStatus.Queued, i); + + var factory = new CountingDbFactory(NewContext); + var vm = new TasksIslandViewModel(factory, worker: null); + await LoadAndWaitAsync(vm, UserList("L1", "Work"), expectedCount: total); + Assert.Equal(total, vm.Items.Count); + + await using (var db = NewContext()) + { + await db.Tasks.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Running)); + } + + var createCallsBefore = factory.CreateCalls; + await vm.ReconcileTickAsync(); + Assert.Equal(createCallsBefore + 1, factory.CreateCalls); // exactly one DB query for the whole tick + + var reconciled = vm.Items.Count(r => r.Status == TaskStatus.Running); + Assert.Equal(TasksIslandViewModel.ReconcileRowCap, reconciled); + + // The row beyond the cap (last one loaded, per SortOrder) must not have been touched. + var lastRow = vm.Items.Single(r => r.Id == $"T{total - 1}"); + Assert.Equal(TaskStatus.Queued, lastRow.Status); + } +}