fix(ui): drop stale delta refreshes so the newest task state wins
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user