fix(ui): drop stale delta refreshes so the newest task state wins

This commit is contained in:
mika kuns
2026-08-07 09:18:52 +02:00
parent cba7d01c31
commit ac099dd1a8
2 changed files with 41 additions and 3 deletions
@@ -25,6 +25,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// pick the flag up (see SyncInteractiveSessions).
private readonly HashSet<string> _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<string, long> _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
@@ -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);
}
}