feat(planning): prevent orphaned subtasks via guards + startup repair

Three coordinated guards close the orphan-creation paths:

- CreateChildAsync refuses when the parent is not in a planning phase.
- DiscardPlanningAsync now returns a structured DiscardPlanningOutcome
  and refuses when children are queued or running; callers can opt into
  auto-dequeuing queued kids via dequeueQueuedChildren=true. Terminal
  children (Done/Failed/Cancelled) are promoted to top-level instead of
  becoming orphans when the parent's PlanningPhase is reset.
- OrphanRecovery hosted service clears ParentTaskId on any rows whose
  parent is missing or no longer in a planning phase on worker startup,
  mirroring the StaleTaskRecovery pattern.

UI surfaces the block reason: a confirm dialog offers to dequeue queued
children and retry; a running-children block is shown as a hard error
asking the user to cancel first.

WorkerClient now negotiates the JsonStringEnumConverter so the
DiscardPlanningResult enum round-trips correctly over SignalR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-05-18 16:02:15 +02:00
parent e68bb737e3
commit d094a21e09
17 changed files with 481 additions and 32 deletions

View File

@@ -258,9 +258,15 @@ public sealed class TaskRepository
string? commitType,
CancellationToken ct = default)
{
var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct);
// AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
// bypasses the change tracker; a tracked Find would return stale data.
var parent = await _context.Tasks.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
if (parent is null)
throw new InvalidOperationException($"Parent task {parentId} not found.");
if (parent.PlanningPhase == PlanningPhase.None)
throw new InvalidOperationException(
$"Parent task {parentId} is not in a planning phase; cannot attach children.");
var maxSort = await _context.Tasks
.Where(t => t.ListId == parent.ListId)
@@ -401,8 +407,9 @@ public sealed class TaskRepository
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
}
public async Task<bool> DiscardPlanningAsync(
public async Task<DiscardPlanningOutcome> DiscardPlanningAsync(
string parentId,
bool dequeueQueuedChildren,
CancellationToken ct = default)
{
using var tx = await _context.Database.BeginTransactionAsync(ct);
@@ -413,10 +420,54 @@ public sealed class TaskRepository
if (parent is null || parent.PlanningPhase != PlanningPhase.Active)
{
await tx.RollbackAsync(ct);
return false;
return new DiscardPlanningOutcome(DiscardPlanningResult.NotInPlanning, 0, 0);
}
// Children created during the planning session are Status=Idle, PlanningPhase=None.
var children = await _context.Tasks
.Where(t => t.ParentTaskId == parentId)
.Select(t => new { t.Id, t.Status })
.ToListAsync(ct);
var runningCount = children.Count(c => c.Status == TaskStatus.Running);
if (runningCount > 0)
{
await tx.RollbackAsync(ct);
return new DiscardPlanningOutcome(DiscardPlanningResult.BlockedByRunningChildren, 0, runningCount);
}
var queuedIds = children.Where(c => c.Status == TaskStatus.Queued).Select(c => c.Id).ToList();
if (queuedIds.Count > 0)
{
if (!dequeueQueuedChildren)
{
await tx.RollbackAsync(ct);
return new DiscardPlanningOutcome(DiscardPlanningResult.BlockedByQueuedChildren, queuedIds.Count, 0);
}
await _context.Tasks
.Where(t => queuedIds.Contains(t.Id))
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Idle)
.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
}
// Terminal children (Done/Failed/Cancelled) survive the discard but cannot remain
// attached: their parent's PlanningPhase is about to be reset to None, which would
// make them orphans. Promote them to top-level.
var terminalIds = children
.Where(c => c.Status == TaskStatus.Done
|| c.Status == TaskStatus.Failed
|| c.Status == TaskStatus.Cancelled)
.Select(c => c.Id)
.ToList();
if (terminalIds.Count > 0)
{
await _context.Tasks
.Where(t => terminalIds.Contains(t.Id))
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, (string?)null), ct);
}
// Idle children created during this planning session are dropped.
await _context.Tasks
.Where(t => t.ParentTaskId == parentId
&& t.Status == TaskStatus.Idle
@@ -433,7 +484,27 @@ public sealed class TaskRepository
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
await tx.CommitAsync(ct);
return true;
return new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, queuedIds.Count, 0);
}
/// <summary>
/// Clears <c>ParentTaskId</c> on rows whose parent is missing or no longer in a
/// planning phase. Returns the number of rows repaired. Idempotent.
/// </summary>
internal async Task<int> RepairOrphanedChildrenAsync(CancellationToken ct = default)
{
var orphanIds = await _context.Tasks
.Where(t => t.ParentTaskId != null)
.Where(t => !_context.Tasks.Any(p =>
p.Id == t.ParentTaskId && p.PlanningPhase != PlanningPhase.None))
.Select(t => t.Id)
.ToListAsync(ct);
if (orphanIds.Count == 0) return 0;
return await _context.Tasks
.Where(t => orphanIds.Contains(t.Id))
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, (string?)null), ct);
}
public async Task TryCompleteParentAsync(