fix(worker): keep planning-chain cascade moving past an Idle middle link

OnChildFinishedAsync ignored CancelAsync's result. If a chain successor
sat in a non-cancellable state (e.g. parked to Idle out of band) when
its predecessor failed/was cancelled, CancelAsync was a silent no-op and
the cascade stopped there, leaving the rest of the chain Queued+blocked
forever. Now it walks past any link CancelAsync can't touch and keeps
cancelling downstream.
This commit is contained in:
mika kuns
2026-07-23 17:09:55 +02:00
parent 85c7e650c9
commit 816f247d90
2 changed files with 60 additions and 13 deletions
@@ -92,14 +92,7 @@ public sealed class PlanningChainCoordinator
public async Task<string?> OnChildFinishedAsync(
string childTaskId, TaskStatus finalStatus, CancellationToken ct = default)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
// The successor is whichever sibling explicitly blocks on this child.
var nextId = await ctx.Tasks
.AsNoTracking()
.Where(t => t.BlockedByTaskId == childTaskId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => t.Id)
.FirstOrDefaultAsync(ct);
var nextId = await FindSuccessorAsync(childTaskId, ct);
if (nextId is null) return null;
if (finalStatus == TaskStatus.Done)
@@ -108,10 +101,33 @@ public sealed class PlanningChainCoordinator
return nextId;
}
// Child failed or was cancelled: cancel the immediate successor so the chain
// is not left wedged. CancelAsync triggers OnChildTerminalAsync → OnChildFinishedAsync
// for that successor, cascading cancellation through the rest of the chain.
await _state().CancelAsync(nextId, DateTime.UtcNow, ct);
return null;
// Child failed or was cancelled: cancel the immediate successor so the chain is
// not left wedged. If it's cancellable, CancelAsync's own OnChildTerminalAsync
// callback recurses into this method for its successor, cascading cancellation
// through the rest of the chain. If it's not (e.g. it was parked back to Idle
// out of band), CancelAsync is a no-op and nothing will ever call back for it —
// keep walking the chain ourselves so the tail isn't left wedged forever.
var predecessorId = nextId;
while (true)
{
var result = await _state().CancelAsync(predecessorId, DateTime.UtcNow, ct);
if (result.Ok) return null;
var following = await FindSuccessorAsync(predecessorId, ct);
if (following is null) return null;
predecessorId = following;
}
}
// The successor is whichever sibling explicitly blocks on this task.
private async Task<string?> FindSuccessorAsync(string taskId, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
return await ctx.Tasks
.AsNoTracking()
.Where(t => t.BlockedByTaskId == taskId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => t.Id)
.FirstOrDefaultAsync(ct);
}
}