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).
|
// pick the flag up (see SyncInteractiveSessions).
|
||||||
private readonly HashSet<string> _interactiveSessionIds = new();
|
private readonly HashSet<string> _interactiveSessionIds = new();
|
||||||
private static readonly TaskListFilterRegistry _filters = 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? SelectionChanged;
|
||||||
public event EventHandler? FocusAddTaskRequested;
|
public event EventHandler? FocusAddTaskRequested;
|
||||||
@@ -179,9 +183,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var seq = ++_deltaCounter;
|
||||||
|
_deltaSeq[taskId] = seq;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ApplyDeltaAsync(taskId, list);
|
await ApplyDeltaAsync(taskId, list, seq);
|
||||||
}
|
}
|
||||||
catch (Exception first)
|
catch (Exception first)
|
||||||
{
|
{
|
||||||
@@ -189,7 +196,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
$"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying");
|
$"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ApplyDeltaAsync(taskId, list);
|
await ApplyDeltaAsync(taskId, list, seq);
|
||||||
}
|
}
|
||||||
catch (Exception second)
|
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();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
var entity = await db.Tasks
|
var entity = await db.Tasks
|
||||||
@@ -208,6 +215,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
.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
|
// 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
|
// 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
|
// 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);
|
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