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:
@@ -236,12 +236,17 @@ public sealed class PlanningSessionManager
|
||||
return children.Count(c => c.Status == TaskStatus.Idle);
|
||||
}
|
||||
|
||||
public async Task DiscardAsync(string taskId, CancellationToken ct)
|
||||
public async Task<DiscardPlanningOutcome> DiscardAsync(
|
||||
string taskId,
|
||||
bool dequeueQueuedChildren,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||
await using var __ = ctx;
|
||||
|
||||
var ok = await tasks.DiscardPlanningAsync(taskId, ct);
|
||||
var outcome = await tasks.DiscardPlanningAsync(taskId, dequeueQueuedChildren, ct);
|
||||
if (outcome.Result != DiscardPlanningResult.Discarded)
|
||||
return outcome;
|
||||
|
||||
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||
|
||||
@@ -251,8 +256,7 @@ public sealed class PlanningSessionManager
|
||||
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
if (!ok)
|
||||
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
|
||||
return outcome;
|
||||
}
|
||||
|
||||
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
||||
|
||||
Reference in New Issue
Block a user