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:
18
src/ClaudeDo.Data/Repositories/DiscardPlanningOutcome.cs
Normal file
18
src/ClaudeDo.Data/Repositories/DiscardPlanningOutcome.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public enum DiscardPlanningResult
|
||||
{
|
||||
/// <summary>Planning state cleared, children handled.</summary>
|
||||
Discarded,
|
||||
/// <summary>Parent not found or not in <c>PlanningPhase.Active</c>.</summary>
|
||||
NotInPlanning,
|
||||
/// <summary>At least one child is <c>Queued</c> and the caller did not opt in to auto-dequeue.</summary>
|
||||
BlockedByQueuedChildren,
|
||||
/// <summary>At least one child is <c>Running</c>; user must cancel it before discarding.</summary>
|
||||
BlockedByRunningChildren,
|
||||
}
|
||||
|
||||
public readonly record struct DiscardPlanningOutcome(
|
||||
DiscardPlanningResult Result,
|
||||
int QueuedChildrenCount,
|
||||
int RunningChildrenCount);
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user