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,
|
string? commitType,
|
||||||
CancellationToken ct = default)
|
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)
|
if (parent is null)
|
||||||
throw new InvalidOperationException($"Parent task {parentId} not found.");
|
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
|
var maxSort = await _context.Tasks
|
||||||
.Where(t => t.ListId == parent.ListId)
|
.Where(t => t.ListId == parent.ListId)
|
||||||
@@ -401,8 +407,9 @@ public sealed class TaskRepository
|
|||||||
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> DiscardPlanningAsync(
|
public async Task<DiscardPlanningOutcome> DiscardPlanningAsync(
|
||||||
string parentId,
|
string parentId,
|
||||||
|
bool dequeueQueuedChildren,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||||||
@@ -413,10 +420,54 @@ public sealed class TaskRepository
|
|||||||
if (parent is null || parent.PlanningPhase != PlanningPhase.Active)
|
if (parent is null || parent.PlanningPhase != PlanningPhase.Active)
|
||||||
{
|
{
|
||||||
await tx.RollbackAsync(ct);
|
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
|
await _context.Tasks
|
||||||
.Where(t => t.ParentTaskId == parentId
|
.Where(t => t.ParentTaskId == parentId
|
||||||
&& t.Status == TaskStatus.Idle
|
&& t.Status == TaskStatus.Idle
|
||||||
@@ -433,7 +484,27 @@ public sealed class TaskRepository
|
|||||||
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
|
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
|
||||||
|
|
||||||
await tx.CommitAsync(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(
|
public async Task TryCompleteParentAsync(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Services;
|
namespace ClaudeDo.Ui.Services;
|
||||||
@@ -34,7 +35,7 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
||||||
Task ResumePlanningSessionAsync(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 FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
|
||||||
Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
|
Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
|
||||||
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
|
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Services;
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
@@ -64,6 +66,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
_hub = new HubConnectionBuilder()
|
_hub = new HubConnectionBuilder()
|
||||||
.WithUrl(signalRUrl)
|
.WithUrl(signalRUrl)
|
||||||
.WithAutomaticReconnect(new IndefiniteRetryPolicy())
|
.WithAutomaticReconnect(new IndefiniteRetryPolicy())
|
||||||
|
.AddJsonProtocol(options =>
|
||||||
|
{
|
||||||
|
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||||
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
_hub.Reconnected += async _ =>
|
_hub.Reconnected += async _ =>
|
||||||
@@ -436,8 +442,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public async Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default)
|
public async Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync("OpenInteractiveTerminalAsync", taskId, ct);
|
=> await _hub.InvokeAsync("OpenInteractiveTerminalAsync", taskId, ct);
|
||||||
|
|
||||||
public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
|
=> await _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
|
||||||
|
|
||||||
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
|
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
|
||||||
@@ -496,8 +502,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
=> await StartPlanningSessionAsync(taskId, ct);
|
=> await StartPlanningSessionAsync(taskId, ct);
|
||||||
async Task IWorkerClient.ResumePlanningSessionAsync(string taskId, CancellationToken ct)
|
async Task IWorkerClient.ResumePlanningSessionAsync(string taskId, CancellationToken ct)
|
||||||
=> await ResumePlanningSessionAsync(taskId, ct);
|
=> await ResumePlanningSessionAsync(taskId, ct);
|
||||||
async Task IWorkerClient.DiscardPlanningSessionAsync(string taskId, CancellationToken ct)
|
async Task<DiscardPlanningOutcome> IWorkerClient.DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren, CancellationToken ct)
|
||||||
=> await DiscardPlanningSessionAsync(taskId, ct);
|
=> await DiscardPlanningSessionAsync(taskId, dequeueQueuedChildren, ct);
|
||||||
async Task IWorkerClient.FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
async Task IWorkerClient.FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
||||||
=> await FinalizePlanningSessionAsync(taskId, queueAgentTasks, ct);
|
=> await FinalizePlanningSessionAsync(taskId, queueAgentTasks, ct);
|
||||||
async Task<int> IWorkerClient.GetPendingDraftCountAsync(string taskId, CancellationToken ct)
|
async Task<int> IWorkerClient.GetPendingDraftCountAsync(string taskId, CancellationToken ct)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Filtering;
|
using ClaudeDo.Data.Filtering;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -647,7 +648,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
await _worker.FinalizePlanningSessionAsync(row.Id);
|
await _worker.FinalizePlanningSessionAsync(row.Id);
|
||||||
break;
|
break;
|
||||||
case UnfinishedPlanningModalResult.Discard:
|
case UnfinishedPlanningModalResult.Discard:
|
||||||
await _worker.DiscardPlanningSessionAsync(row.Id);
|
await TryDiscardPlanningWithRetryAsync(row.Id);
|
||||||
break;
|
break;
|
||||||
case UnfinishedPlanningModalResult.Cancel:
|
case UnfinishedPlanningModalResult.Cancel:
|
||||||
default:
|
default:
|
||||||
@@ -660,11 +661,46 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
|
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null) return;
|
if (row is null || _worker is null) return;
|
||||||
try { await _worker!.DiscardPlanningSessionAsync(row.Id); }
|
await TryDiscardPlanningWithRetryAsync(row.Id);
|
||||||
catch { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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]
|
[RelayCommand]
|
||||||
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using Avalonia;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
@@ -33,10 +35,55 @@ public partial class TasksIslandView : UserControl
|
|||||||
await modal.ShowDialog(owner);
|
await modal.ShowDialog(owner);
|
||||||
// ShowDialog completes once the window is closed (CloseAction or OS close).
|
// 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)
|
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not TasksIslandViewModel vm) return;
|
if (DataContext is not TasksIslandViewModel vm) return;
|
||||||
|
|||||||
@@ -388,7 +388,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
}
|
}
|
||||||
catch (PlanningLaunchException)
|
catch (PlanningLaunchException)
|
||||||
{
|
{
|
||||||
await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
|
// Launch failed before any children could be created; force-cleanup is safe.
|
||||||
|
await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||||
@@ -408,10 +409,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
await _launcher.LaunchInteractiveAsync(ctx, Context.ConnectionAborted);
|
await _launcher.LaunchInteractiveAsync(ctx, Context.ConnectionAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DiscardPlanningSessionAsync(string taskId)
|
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
|
||||||
{
|
{
|
||||||
await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
|
var outcome = await _planning.DiscardAsync(taskId, dequeueQueuedChildren, Context.ConnectionAborted);
|
||||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
if (outcome.Result == DiscardPlanningResult.Discarded)
|
||||||
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||||
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true)
|
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true)
|
||||||
|
|||||||
38
src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs
Normal file
38
src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Lifecycle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Startup-only sweep: clears <c>ParentTaskId</c> on rows whose parent is missing or
|
||||||
|
/// no longer in a planning phase. These rows would otherwise be invisible in the UI
|
||||||
|
/// (the parent doesn't render as a planning header) and cannot reach a terminal state
|
||||||
|
/// through the chain coordinator. Promoting them to top-level restores both.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrphanRecovery : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly ILogger<OrphanRecovery> _logger;
|
||||||
|
|
||||||
|
public OrphanRecovery(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
ILogger<OrphanRecovery> logger)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var repo = new TaskRepository(ctx);
|
||||||
|
var repaired = await repo.RepairOrphanedChildrenAsync(cancellationToken);
|
||||||
|
if (repaired > 0)
|
||||||
|
_logger.LogWarning("Orphan recovery: promoted {Count} orphaned child task(s) to top-level", repaired);
|
||||||
|
else
|
||||||
|
_logger.LogInformation("Orphan recovery: no orphans found");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -236,12 +236,17 @@ public sealed class PlanningSessionManager
|
|||||||
return children.Count(c => c.Status == TaskStatus.Idle);
|
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();
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||||
await using var __ = ctx;
|
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);
|
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||||
|
|
||||||
@@ -251,8 +256,7 @@ public sealed class PlanningSessionManager
|
|||||||
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ok)
|
return outcome;
|
||||||
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
|||||||
|
|
||||||
builder.Services.AddSingleton(cfg);
|
builder.Services.AddSingleton(cfg);
|
||||||
builder.Services.AddHostedService<StaleTaskRecovery>();
|
builder.Services.AddHostedService<StaleTaskRecovery>();
|
||||||
|
builder.Services.AddHostedService<OrphanRecovery>();
|
||||||
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
||||||
{
|
{
|
||||||
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ public class ConflictResolutionViewModelTests
|
|||||||
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
||||||
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task<ClaudeDo.Data.Repositories.DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new ClaudeDo.Data.Repositories.DiscardPlanningOutcome(ClaudeDo.Data.Repositories.DiscardPlanningResult.Discarded, 0, 0));
|
||||||
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ public class DetailsIslandPlanningTests : IDisposable
|
|||||||
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
||||||
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task<ClaudeDo.Data.Repositories.DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new ClaudeDo.Data.Repositories.DiscardPlanningOutcome(ClaudeDo.Data.Repositories.DiscardPlanningResult.Discarded, 0, 0));
|
||||||
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult(MergeTargetsResult);
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult(MergeTargetsResult);
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ public class PlanningDiffViewModelTests
|
|||||||
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
||||||
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task<ClaudeDo.Data.Repositories.DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new ClaudeDo.Data.Repositories.DiscardPlanningOutcome(ClaudeDo.Data.Repositories.DiscardPlanningResult.Discarded, 0, 0));
|
||||||
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
|||||||
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
Assert.True(Directory.Exists(startCtx.Files.SessionDirectory));
|
Assert.True(Directory.Exists(startCtx.Files.SessionDirectory));
|
||||||
|
|
||||||
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
|
await _sut.DiscardAsync(parent.Id, dequeueQueuedChildren: false, CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(Directory.Exists(startCtx.Files.SessionDirectory));
|
Assert.False(Directory.Exists(startCtx.Files.SessionDirectory));
|
||||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
@@ -235,7 +235,7 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
|||||||
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||||
|
|
||||||
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
|
await _sut.DiscardAsync(parent.Id, dequeueQueuedChildren: false, CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(Directory.Exists(ctx.WorktreePath));
|
Assert.False(Directory.Exists(ctx.WorktreePath));
|
||||||
// branch deleted
|
// branch deleted
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the invariant that no task may have <c>ParentTaskId</c> pointing to a
|
||||||
|
/// parent without <c>PlanningPhase.Active|Finalized</c>. Tests the three guard
|
||||||
|
/// rails: <c>CreateChildAsync</c> validation, <c>DiscardPlanningAsync</c>
|
||||||
|
/// gating with the optional dequeue path, and the startup repair sweep.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TaskRepositoryOrphanGuardTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
|
||||||
|
public TaskRepositoryOrphanGuardTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateListAsync()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Idle, PlanningPhase phase = PlanningPhase.None) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "T",
|
||||||
|
Status = status,
|
||||||
|
PlanningPhase = phase,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<TaskEntity> SeedPlanningParentAsync(string listId)
|
||||||
|
{
|
||||||
|
var parent = MakeTask(listId, status: TaskStatus.Idle, phase: PlanningPhase.Active);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CreateChildAsync validation ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildAsync_Throws_When_Parent_Has_No_Planning_Phase()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, phase: PlanningPhase.None);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => _tasks.CreateChildAsync(parent.Id, "child", null, null, null));
|
||||||
|
Assert.Contains("not in a planning phase", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildAsync_Succeeds_When_Parent_Is_Active()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
|
||||||
|
var child = await _tasks.CreateChildAsync(parent.Id, "child", null, null, null);
|
||||||
|
Assert.Equal(parent.Id, child.ParentTaskId);
|
||||||
|
Assert.Equal(TaskStatus.Idle, child.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DiscardPlanningAsync gating ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanning_NotInPlanning_When_Parent_Phase_Is_None()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var stray = MakeTask(listId, phase: PlanningPhase.None);
|
||||||
|
await _tasks.AddAsync(stray);
|
||||||
|
|
||||||
|
var outcome = await _tasks.DiscardPlanningAsync(stray.Id, dequeueQueuedChildren: false);
|
||||||
|
Assert.Equal(DiscardPlanningResult.NotInPlanning, outcome.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanning_Succeeds_When_All_Children_Are_Idle()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "a", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "b", null, null, null);
|
||||||
|
|
||||||
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
|
||||||
|
|
||||||
|
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
|
||||||
|
Assert.Equal(0, _ctx.Tasks.AsNoTracking().Count(t => t.ParentTaskId == parent.Id));
|
||||||
|
var reloaded = _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id);
|
||||||
|
Assert.Equal(PlanningPhase.None, reloaded.PlanningPhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanning_Blocks_On_Queued_Children_Without_Optin()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
await SetChildStatusAsync(child.Id, TaskStatus.Queued);
|
||||||
|
|
||||||
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
|
||||||
|
|
||||||
|
Assert.Equal(DiscardPlanningResult.BlockedByQueuedChildren, outcome.Result);
|
||||||
|
Assert.Equal(1, outcome.QueuedChildrenCount);
|
||||||
|
// Parent and child are untouched.
|
||||||
|
Assert.Equal(PlanningPhase.Active, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase);
|
||||||
|
Assert.Equal(TaskStatus.Queued, _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanning_With_Dequeue_Succeeds_And_Drops_Idle_Children()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
await SetChildStatusAsync(child.Id, TaskStatus.Queued);
|
||||||
|
|
||||||
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: true);
|
||||||
|
|
||||||
|
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
|
||||||
|
// Child was dequeued to Idle and then deleted as part of the discard.
|
||||||
|
Assert.False(_ctx.Tasks.AsNoTracking().Any(t => t.Id == child.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanning_Blocks_On_Running_Children_Even_With_Dequeue_Optin()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
await SetChildStatusAsync(child.Id, TaskStatus.Running);
|
||||||
|
|
||||||
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: true);
|
||||||
|
|
||||||
|
Assert.Equal(DiscardPlanningResult.BlockedByRunningChildren, outcome.Result);
|
||||||
|
Assert.Equal(1, outcome.RunningChildrenCount);
|
||||||
|
Assert.Equal(PlanningPhase.Active, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanning_Promotes_Terminal_Children_To_Top_Level()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
var done = await _tasks.CreateChildAsync(parent.Id, "done", null, null, null);
|
||||||
|
var failed = await _tasks.CreateChildAsync(parent.Id, "failed", null, null, null);
|
||||||
|
await SetChildStatusAsync(done.Id, TaskStatus.Done);
|
||||||
|
await SetChildStatusAsync(failed.Id, TaskStatus.Failed);
|
||||||
|
|
||||||
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
|
||||||
|
|
||||||
|
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
|
||||||
|
Assert.Null(_ctx.Tasks.AsNoTracking().Single(t => t.Id == done.Id).ParentTaskId);
|
||||||
|
Assert.Null(_ctx.Tasks.AsNoTracking().Single(t => t.Id == failed.Id).ParentTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Repair sweep ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Repair_Clears_ParentTaskId_When_Parent_Is_Not_Planning()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
// Parent is plain (not planning), child attached -> orphan by definition.
|
||||||
|
var parent = MakeTask(listId, phase: PlanningPhase.None);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
var child = MakeTask(listId);
|
||||||
|
child.ParentTaskId = parent.Id;
|
||||||
|
await _tasks.AddAsync(child);
|
||||||
|
|
||||||
|
var repaired = await _tasks.RepairOrphanedChildrenAsync();
|
||||||
|
Assert.Equal(1, repaired);
|
||||||
|
Assert.Null(_ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id).ParentTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Repair_Leaves_Valid_Children_Untouched()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
|
||||||
|
var repaired = await _tasks.RepairOrphanedChildrenAsync();
|
||||||
|
Assert.Equal(0, repaired);
|
||||||
|
Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id).ParentTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetChildStatusAsync(string id, TaskStatus status)
|
||||||
|
{
|
||||||
|
var t = await _ctx.Tasks.FindAsync(id) ?? throw new InvalidOperationException();
|
||||||
|
t.Status = status;
|
||||||
|
await _ctx.SaveChangesAsync();
|
||||||
|
_ctx.Entry(t).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -205,9 +205,9 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
|||||||
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||||
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||||
|
|
||||||
var ok = await _tasks.DiscardPlanningAsync(parent.Id);
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
|
||||||
|
|
||||||
Assert.True(ok);
|
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
|
||||||
Assert.Null(await _tasks.GetByIdAsync(c1.Id));
|
Assert.Null(await _tasks.GetByIdAsync(c1.Id));
|
||||||
Assert.Null(await _tasks.GetByIdAsync(c2.Id));
|
Assert.Null(await _tasks.GetByIdAsync(c2.Id));
|
||||||
|
|
||||||
@@ -226,9 +226,9 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
|||||||
var task = MakeTask(listId);
|
var task = MakeTask(listId);
|
||||||
await _tasks.AddAsync(task);
|
await _tasks.AddAsync(task);
|
||||||
|
|
||||||
var ok = await _tasks.DiscardPlanningAsync(task.Id);
|
var outcome = await _tasks.DiscardPlanningAsync(task.Id, dequeueQueuedChildren: false);
|
||||||
|
|
||||||
Assert.False(ok);
|
Assert.Equal(DiscardPlanningResult.NotInPlanning, outcome.Result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
@@ -45,7 +46,11 @@ sealed class FakeWorkerClient : IWorkerClient
|
|||||||
public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }
|
||||||
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) { DiscardPlanningCalls++; return Task.CompletedTask; }
|
public Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
DiscardPlanningCalls++;
|
||||||
|
return Task.FromResult(new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, 0, 0));
|
||||||
|
}
|
||||||
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { FinalizePlanningCalls++; return Task.CompletedTask; }
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { FinalizePlanningCalls++; return Task.CompletedTask; }
|
||||||
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user