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:
@@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Filtering;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -647,7 +648,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
await _worker.FinalizePlanningSessionAsync(row.Id);
|
||||
break;
|
||||
case UnfinishedPlanningModalResult.Discard:
|
||||
await _worker.DiscardPlanningSessionAsync(row.Id);
|
||||
await TryDiscardPlanningWithRetryAsync(row.Id);
|
||||
break;
|
||||
case UnfinishedPlanningModalResult.Cancel:
|
||||
default:
|
||||
@@ -660,11 +661,46 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
[RelayCommand]
|
||||
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
try { await _worker!.DiscardPlanningSessionAsync(row.Id); }
|
||||
catch { }
|
||||
if (row is null || _worker is null) return;
|
||||
await TryDiscardPlanningWithRetryAsync(row.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls discard, and if it is blocked because children are queued, prompts the
|
||||
/// user to dequeue them and retries. Running children are surfaced as a hard
|
||||
/// block — the user must cancel them first.
|
||||
/// </summary>
|
||||
private async Task TryDiscardPlanningWithRetryAsync(string taskId)
|
||||
{
|
||||
if (_worker is null) return;
|
||||
DiscardPlanningOutcome outcome;
|
||||
try { outcome = await _worker.DiscardPlanningSessionAsync(taskId); }
|
||||
catch { return; }
|
||||
|
||||
if (outcome.Result == DiscardPlanningResult.BlockedByQueuedChildren)
|
||||
{
|
||||
if (ConfirmAsync is null) return;
|
||||
var ok = await ConfirmAsync(
|
||||
$"{outcome.QueuedChildrenCount} child task(s) are queued.\n" +
|
||||
"Dequeue them and discard the planning session?");
|
||||
if (!ok) return;
|
||||
try { await _worker.DiscardPlanningSessionAsync(taskId, dequeueQueuedChildren: true); }
|
||||
catch { }
|
||||
}
|
||||
else if (outcome.Result == DiscardPlanningResult.BlockedByRunningChildren)
|
||||
{
|
||||
if (ConfirmAsync is null) return;
|
||||
await ConfirmAsync(
|
||||
$"{outcome.RunningChildrenCount} child task(s) are still running.\n" +
|
||||
"Cancel them first, then try again.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wired by the view via <see cref="ShowConfirmAsync"/>. Returns true when the user confirms.
|
||||
/// </summary>
|
||||
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user