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

@@ -1,5 +1,6 @@
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Services;
@@ -34,7 +35,7 @@ public interface IWorkerClient : INotifyPropertyChanged
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default);
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);

View File

@@ -1,9 +1,11 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Ui.Services;
@@ -64,6 +66,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
_hub = new HubConnectionBuilder()
.WithUrl(signalRUrl)
.WithAutomaticReconnect(new IndefiniteRetryPolicy())
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
})
.Build();
_hub.Reconnected += async _ =>
@@ -436,8 +442,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("OpenInteractiveTerminalAsync", taskId, ct);
public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
=> await _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
@@ -496,8 +502,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
=> await StartPlanningSessionAsync(taskId, ct);
async Task IWorkerClient.ResumePlanningSessionAsync(string taskId, CancellationToken ct)
=> await ResumePlanningSessionAsync(taskId, ct);
async Task IWorkerClient.DiscardPlanningSessionAsync(string taskId, CancellationToken ct)
=> await DiscardPlanningSessionAsync(taskId, ct);
async Task<DiscardPlanningOutcome> IWorkerClient.DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren, CancellationToken ct)
=> await DiscardPlanningSessionAsync(taskId, dequeueQueuedChildren, ct);
async Task IWorkerClient.FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
=> await FinalizePlanningSessionAsync(taskId, queueAgentTasks, ct);
async Task<int> IWorkerClient.GetPendingDraftCountAsync(string taskId, CancellationToken ct)

View File

@@ -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)
{

View File

@@ -2,6 +2,8 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
@@ -33,10 +35,55 @@ public partial class TasksIslandView : UserControl
await modal.ShowDialog(owner);
// ShowDialog completes once the window is closed (CloseAction or OS close).
};
vm.ConfirmAsync = ShowConfirmAsync;
}
};
}
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { cancel, confirm },
},
},
},
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (DataContext is not TasksIslandViewModel vm) return;