fix(worker): advance planning chains and parents on stale-Running recovery

RecoverStaleRunningAsync bulk-flipped Running rows to Failed via raw
ExecuteUpdate, skipping the chain/parent side effects every other terminal
transition triggers. After a worker crash mid-run of a planning/improvement
child, the chain successor's BlockedByTaskId was never cleared and a
WaitingForChildren parent could wedge forever with no event left to
re-check it. Now each recovered task runs the same OnChildTerminalAsync
side effects (chain advance + parent advance) as FailAsync, best-effort.
This commit is contained in:
mika kuns
2026-08-06 13:16:58 +02:00
parent 0d1e3b9a6f
commit 58741c2bd6
2 changed files with 48 additions and 8 deletions
@@ -456,6 +456,25 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.StartsWith("[stale] ", t.Result);
}
[Fact]
public async Task RecoverStaleRunningAsync_ResolvesBlockedSuccessor_AndAdvancesParent()
{
var parent = await SeedTaskAsync(TaskStatus.WaitingForChildren, phase: PlanningPhase.Finalized);
var c0 = await SeedTaskAsync(TaskStatus.Running, parentId: parent, sortOrder: 0);
var c1 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 1, blockedBy: c0);
var count = await _sut.RecoverStaleRunningAsync("worker restart", default);
Assert.Equal(1, count);
Assert.Equal(TaskStatus.Failed, await GetStatusAsync(c0));
// c1 was blocked on the crashed task; the chain coordinator resolves it (cancels it,
// since the predecessor didn't finish successfully) instead of leaving it Queued with
// a dangling BlockedByTaskId that the picker would skip forever.
Assert.Equal(TaskStatus.Cancelled, await GetStatusAsync(c1));
// Both children are now terminal, so the WaitingForChildren parent advances.
Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(parent));
}
// ─── Child terminal → chain advance ───────────────────────────────────
[Fact]