diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 53e62fbd..6ccd42f9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -25,6 +25,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable // pick the flag up (see SyncInteractiveSessions). private readonly HashSet _interactiveSessionIds = new(); private static readonly TaskListFilterRegistry _filters = new(); + // 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 _deltaSeq = new(); + private long _deltaCounter; public event EventHandler? SelectionChanged; public event EventHandler? FocusAddTaskRequested; @@ -179,9 +183,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable return; } + var seq = ++_deltaCounter; + _deltaSeq[taskId] = seq; + try { - await ApplyDeltaAsync(taskId, list); + await ApplyDeltaAsync(taskId, list, seq); } catch (Exception first) { @@ -189,7 +196,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable $"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying"); try { - await ApplyDeltaAsync(taskId, list); + await ApplyDeltaAsync(taskId, list, seq); } catch (Exception second) { @@ -200,7 +207,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable } } - private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list) + private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list, long seq) { await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks @@ -208,6 +215,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable .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; + // 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 diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs index 1d7548bd..99267f15 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs @@ -122,4 +122,32 @@ public class TasksIslandDeltaResilienceTests : IDisposable Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status); } + + [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); + } }