fix(worker): route done-toggle and dequeue through guarded TaskStateService transitions

The task-list done toggle (both islands) and RemoveFromQueue wrote TaskEntity.Status
directly via EF, bypassing TaskStateService: no TaskUpdated broadcast, no guard against
a concurrent picker claim (lost update), and no status-based filter. Added guarded
MarkDoneAsync/UnmarkDoneAsync/DequeueToIdleAsync transitions plus matching hub methods
(SetTaskDone/UnsetTaskDone/DequeueTask) and IWorkerClient wrappers; the three UI call
sites now route through the hub with optimistic-then-revert row updates and
ErrorReported on failure. RemoveFromQueueAsync dequeues each queued child individually
through the same guarded path instead of cascading via a raw EF update.

Also closes two hub guard gaps: UpdateListConfig's delete branch now preserves a list's
SerializeOnFileOverlap flag instead of dropping it, and SubmitTaskForReview's Idle/Failed
status gate now runs before either mutation branch so a Done/Cancelled task can't get
committed or stamped and then rejected.
This commit is contained in:
mika kuns
2026-08-21 11:47:24 +02:00
parent ce98f65ca5
commit 7f337b36c7
20 changed files with 806 additions and 49 deletions
@@ -477,6 +477,84 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
// ─── MarkDoneAsync / UnmarkDoneAsync ─────────────────────────────────
[Fact]
public async Task MarkDoneAsync_FromIdle_TransitionsToDone_AndStampsFinishedAt()
{
var id = await SeedTaskAsync(TaskStatus.Idle);
var now = DateTime.UtcNow;
var result = await _sut.MarkDoneAsync(id, now, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.Done, t.Status);
Assert.Equal(now, t.FinishedAt);
}
[Fact]
public async Task MarkDoneAsync_FromRunning_Rejects_AndLeavesStatusUnchanged()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.MarkDoneAsync(id, DateTime.UtcNow, default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
[Fact]
public async Task UnmarkDoneAsync_FromDone_TransitionsToIdle()
{
var id = await SeedTaskAsync(TaskStatus.Done);
var result = await _sut.UnmarkDoneAsync(id, default);
Assert.True(result.Ok);
Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id));
}
[Fact]
public async Task UnmarkDoneAsync_FromRunning_Rejects_AndLeavesStatusUnchanged()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.UnmarkDoneAsync(id, default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
// ─── DequeueToIdleAsync ───────────────────────────────────────────────
[Fact]
public async Task DequeueToIdleAsync_FromQueued_TransitionsToIdle_AndClearsBlockedByTaskId()
{
var pred = await SeedTaskAsync(TaskStatus.Queued);
var id = await SeedTaskAsync(TaskStatus.Queued, blockedBy: pred);
var result = await _sut.DequeueToIdleAsync(id, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.Idle, t.Status);
Assert.Null(t.BlockedByTaskId);
}
[Fact]
public async Task DequeueToIdleAsync_FromRunning_Rejects_AndLeavesStatusUnchanged()
{
// Simulates the picker having already claimed the task between the UI reading
// its row and the dequeue call landing.
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.DequeueToIdleAsync(id, default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
// ─── StartPlanningAsync ───────────────────────────────────────────────
[Fact]