From ce79a2d0fede7b8624d24f11f858b60c61599e64 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 29 May 2026 14:41:48 +0200 Subject: [PATCH] feat(planning): gate subtask queueing behind plan finalization Planning subtasks are now "Draft" until their parent plan is finalized, then "Planned" (queueable). Finalizing a plan no longer auto-queues the child chain; the user sends the plan to the queue explicitly. - TaskStateService rejects a child entering Queued/Running unless its parent is Finalized; this single invariant covers UI, queue, RunNow and MCP paths - WorkerHub.SetTaskStatus routes Queued through the gated EnqueueAsync - Finalize call sites pass queueAgentTasks: false - PlanningChainCoordinator.QueuePlanAsync guards the chain build on Finalized - TaskRowViewModel derives Draft/Planned from ParentFinalized; gates CanSendToQueue / CanQueuePlan; view shows a PLANNED badge Co-Authored-By: Claude Opus 4.7 --- .../Islands/DetailsIslandViewModel.cs | 3 +- .../ViewModels/Islands/TaskRowViewModel.cs | 29 +++++++++-- .../Islands/TasksIslandViewModel.cs | 14 ++++- .../Views/Islands/TaskRowView.axaml | 5 +- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 8 ++- .../Planning/PlanningChainCoordinator.cs | 18 +++++++ src/ClaudeDo.Worker/State/TaskStateService.cs | 23 +++++++++ .../Planning/PlanningChainCoordinatorTests.cs | 33 ++++++++++++ .../State/TaskStateServiceTests.cs | 48 +++++++++++++++++ .../UiVm/TaskRowViewModelPlanningTests.cs | 51 +++++++++++++++++++ 10 files changed, 223 insertions(+), 9 deletions(-) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index d45a04f..d2e59ef 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -853,7 +853,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase } private bool CanEnqueue() => - Task != null && _worker.IsConnected && IsIdle; + Task != null && _worker.IsConnected && IsIdle + && (!Task.IsChild || Task.ParentFinalized); [RelayCommand(CanExecute = nameof(CanDequeue))] private async System.Threading.Tasks.Task DequeueAsync() diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index a4b8ce0..c4e9bb2 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -29,6 +29,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase [ObservableProperty] private bool _hasPlanningChildren; [ObservableProperty] private bool _hasQueuedSubtasks; [ObservableProperty] private bool _showListChip = true; + [ObservableProperty] private bool _parentFinalized; public DateTime CreatedAt { get; init; } public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}"; @@ -39,7 +40,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase public bool IsChild => !string.IsNullOrEmpty(ParentTaskId); public bool IsPlanningParent => PlanningPhase != PlanningPhase.None || HasPlanningChildren; - public bool IsDraft => IsChild && Status == TaskStatus.Idle; + // A subtask is Draft until its planning parent is finalized, then Planned (queueable). + public bool IsDraft => IsChild && Status == TaskStatus.Idle && !ParentFinalized; + public bool IsPlanned => IsChild && Status == TaskStatus.Idle && ParentFinalized; public bool CanOpenPlanningSession => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None @@ -61,7 +64,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId); public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId); public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks; - public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks; + public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks + && (!IsChild || ParentFinalized); + // Parent-level "send plan to queue" — only once the plan is finalized (children Planned). + public bool CanQueuePlan => !IsChild && HasPlanningChildren + && PlanningPhase == PlanningPhase.Finalized + && !HasQueuedSubtasks; public bool HasSchedule => ScheduledFor.HasValue; public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail); @@ -87,6 +95,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(IsWaiting)); OnPropertyChanged(nameof(HasLiveTail)); OnPropertyChanged(nameof(IsDraft)); + OnPropertyChanged(nameof(IsPlanned)); OnPropertyChanged(nameof(CanOpenPlanningSession)); OnPropertyChanged(nameof(CanRemoveFromQueue)); OnPropertyChanged(nameof(CanSendToQueue)); @@ -96,21 +105,32 @@ public sealed partial class TaskRowViewModel : ViewModelBase { OnPropertyChanged(nameof(IsChild)); OnPropertyChanged(nameof(IsDraft)); + OnPropertyChanged(nameof(IsPlanned)); + OnPropertyChanged(nameof(CanSendToQueue)); OnPropertyChanged(nameof(CanOpenPlanningSession)); } + partial void OnParentFinalizedChanged(bool value) + { + OnPropertyChanged(nameof(IsDraft)); + OnPropertyChanged(nameof(IsPlanned)); + OnPropertyChanged(nameof(CanSendToQueue)); + } + partial void OnPlanningPhaseChanged(PlanningPhase value) { OnPropertyChanged(nameof(IsPlanningParent)); OnPropertyChanged(nameof(PlanningBadge)); OnPropertyChanged(nameof(CanOpenPlanningSession)); OnPropertyChanged(nameof(CanResumeOrDiscardPlanning)); + OnPropertyChanged(nameof(CanQueuePlan)); } partial void OnHasQueuedSubtasksChanged(bool value) { OnPropertyChanged(nameof(CanRemoveFromQueue)); OnPropertyChanged(nameof(CanSendToQueue)); + OnPropertyChanged(nameof(CanQueuePlan)); } partial void OnBlockedByTaskIdChanged(string? value) @@ -121,7 +141,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase } partial void OnHasPlanningChildrenChanged(bool value) - => OnPropertyChanged(nameof(IsPlanningParent)); + { + OnPropertyChanged(nameof(IsPlanningParent)); + OnPropertyChanged(nameof(CanQueuePlan)); + } partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch)); partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail)); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 0108fae..3f7cde2 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -244,6 +244,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase foreach (var r in Items) r.HasQueuedSubtasks = parentsWithQueuedKids.Contains(r.Id); + // A subtask is "Planned" (queueable) once its planning parent is finalized; + // until then it is a "Draft". + var finalizedParents = Items + .Where(r => r.PlanningPhase == PlanningPhase.Finalized) + .Select(r => r.Id) + .ToHashSet(); + foreach (var r in Items) + r.ParentFinalized = !string.IsNullOrEmpty(r.ParentTaskId) + && finalizedParents.Contains(r.ParentTaskId!); + Regroup(); UpdateSubtitle(); } @@ -645,7 +655,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase await _worker.ResumePlanningSessionAsync(row.Id); break; case UnfinishedPlanningModalResult.FinalizeNow: - await _worker.FinalizePlanningSessionAsync(row.Id); + await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); break; case UnfinishedPlanningModalResult.Discard: await TryDiscardPlanningWithRetryAsync(row.Id); @@ -713,7 +723,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row) { if (row is null) return; - try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); } + try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); } catch { } } diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index 3a07fdb..53a3119 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -60,7 +60,7 @@ IsVisible="{Binding CanResumeOrDiscardPlanning}"/> + IsVisible="{Binding CanQueuePlan}"/> + + + diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 410e057..d03bd01 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -115,7 +115,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub { try { - await _planningChain.SetupChainAsync(parentTaskId, Context.ConnectionAborted); + await _planningChain.QueuePlanAsync(parentTaskId, Context.ConnectionAborted); } catch (InvalidOperationException ex) { @@ -380,7 +380,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub { if (!Enum.TryParse(status, ignoreCase: true, out var parsed)) throw new HubException($"unknown status: {status}"); - var result = await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted); + // Queueing goes through the gated transition so draft subtasks can't be queued; + // other statuses keep the unconditional "set status freely" affordance. + var result = parsed == TaskStatus.Queued + ? await _state.EnqueueAsync(taskId, Context.ConnectionAborted) + : await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted); if (!result.Ok) throw new HubException(result.Reason ?? "set status failed"); } diff --git a/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs b/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs index 519e882..fe8f102 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs @@ -66,6 +66,24 @@ public sealed class PlanningChainCoordinator return sequenceable.Count; } + // User-triggered "send plan to queue". Only valid once the plan is finalized + // (children are "Planned"); otherwise the children are still drafts. + public async Task QueuePlanAsync(string parentTaskId, CancellationToken ct = default) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var phase = await ctx.Tasks.AsNoTracking() + .Where(t => t.Id == parentTaskId) + .Select(t => (PlanningPhase?)t.PlanningPhase) + .FirstOrDefaultAsync(ct); + + if (phase is null) + throw new InvalidOperationException($"Task {parentTaskId} not found."); + if (phase != PlanningPhase.Finalized) + throw new InvalidOperationException("Plan must be finalized before it can be queued."); + + return await SetupChainAsync(parentTaskId, ct); + } + public async Task OnChildFinishedAsync( string childTaskId, TaskStatus finalStatus, CancellationToken ct = default) { diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index 4b3f768..ea1b0fc 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -34,6 +34,10 @@ public sealed class TaskStateService : ITaskStateService public async Task EnqueueAsync(string taskId, CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + + if (await IsDraftChildAsync(ctx, taskId, ct)) + return new TransitionResult(false, "Draft subtask: finalize the plan before queuing it."); + var affected = await ctx.Tasks .Where(t => t.Id == taskId && t.Status != TaskStatus.Running) .ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Queued), ct); @@ -49,6 +53,10 @@ public sealed class TaskStateService : ITaskStateService public async Task StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + + if (await IsDraftChildAsync(ctx, taskId, ct)) + return new TransitionResult(false, "Draft subtask: finalize the plan before running it."); + var affected = await ctx.Tasks .Where(t => t.Id == taskId && t.Status != TaskStatus.Running) .ExecuteUpdateAsync(s => s @@ -234,6 +242,21 @@ public sealed class TaskStateService : ITaskStateService .SetProperty(t => t.Result, resultText), ct); } + // A subtask is "draft" until its planning parent is finalized. Draft subtasks must not be + // queued or run by any path (UI, queue, RunNow, MCP). Standalone tasks are never draft. + private static async Task IsDraftChildAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct) + { + var parentId = await ctx.Tasks.AsNoTracking() + .Where(t => t.Id == taskId) + .Select(t => t.ParentTaskId) + .FirstOrDefaultAsync(ct); + if (parentId is null) return false; + + var parentFinalized = await ctx.Tasks.AsNoTracking() + .AnyAsync(p => p.Id == parentId && p.PlanningPhase == PlanningPhase.Finalized, ct); + return !parentFinalized; + } + private async Task OnChildTerminalAsync(string taskId, TaskStatus finalStatus) { // Terminal child writes are best-effort and use CancellationToken.None so the diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs index df25190..1e7b9d5 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs @@ -239,4 +239,37 @@ public sealed class PlanningChainCoordinatorTests : IDisposable Assert.Equal(TaskStatus.Queued, kids[3].Status); Assert.Equal("P-c2", kids[3].BlockedByTaskId); } + + [Fact] + public async Task QueuePlan_WhenParentFinalized_BuildsChain() + { + await SeedPlanningFamilyAsync("P", 2); + + var count = await _sut.QueuePlanAsync("P", default); + + Assert.Equal(2, count); + var kids = await GetChildrenAsync("P"); + Assert.Equal(TaskStatus.Queued, kids[0].Status); + Assert.Null(kids[0].BlockedByTaskId); + Assert.Equal(TaskStatus.Queued, kids[1].Status); + Assert.Equal("P-c0", kids[1].BlockedByTaskId); + } + + [Fact] + public async Task QueuePlan_WhenParentNotFinalized_Throws_AndLeavesChildrenIdle() + { + await SeedPlanningFamilyAsync("P", 2); + await using (var ctx = _factory.CreateDbContext()) + { + var parent = await ctx.Tasks.FirstAsync(t => t.Id == "P"); + parent.PlanningPhase = PlanningPhase.Active; + await ctx.SaveChangesAsync(); + } + + await Assert.ThrowsAsync( + () => _sut.QueuePlanAsync("P", default)); + + var kids = await GetChildrenAsync("P"); + Assert.All(kids, k => Assert.Equal(TaskStatus.Idle, k.Status)); + } } diff --git a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs index 4a32769..7ec4953 100644 --- a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs @@ -100,6 +100,30 @@ public sealed class TaskStateServiceTests : IDisposable Assert.Equal(TaskStatus.Running, await GetStatusAsync(id)); } + [Fact] + public async Task EnqueueAsync_DraftChild_Rejected_WhenParentNotFinalized() + { + var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Active); + var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent); + + var result = await _sut.EnqueueAsync(child, default); + + Assert.False(result.Ok); + Assert.Equal(TaskStatus.Idle, await GetStatusAsync(child)); + } + + [Fact] + public async Task EnqueueAsync_PlannedChild_Succeeds_WhenParentFinalized() + { + var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Finalized); + var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent); + + var result = await _sut.EnqueueAsync(child, default); + + Assert.True(result.Ok); + Assert.Equal(TaskStatus.Queued, await GetStatusAsync(child)); + } + // ─── StartRunningAsync ──────────────────────────────────────────────── [Fact] @@ -142,6 +166,30 @@ public sealed class TaskStateServiceTests : IDisposable Assert.Equal(TaskStatus.Running, await GetStatusAsync(id)); } + [Fact] + public async Task StartRunningAsync_DraftChild_Rejected_WhenParentNotFinalized() + { + var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Active); + var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent); + + var result = await _sut.StartRunningAsync(child, DateTime.UtcNow, default); + + Assert.False(result.Ok); + Assert.Equal(TaskStatus.Idle, await GetStatusAsync(child)); + } + + [Fact] + public async Task StartRunningAsync_PlannedChild_Succeeds_WhenParentFinalized() + { + var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Finalized); + var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent); + + var result = await _sut.StartRunningAsync(child, DateTime.UtcNow, default); + + Assert.True(result.Ok); + Assert.Equal(TaskStatus.Running, await GetStatusAsync(child)); + } + // ─── CompleteAsync ──────────────────────────────────────────────────── [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs index 7092c7a..87db7d4 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs @@ -46,4 +46,55 @@ public class TaskRowViewModelPlanningTests Assert.False(vm.IsPlanningParent); Assert.Null(vm.PlanningBadge); } + + [Fact] + public void DraftChild_CannotSendToQueue() + { + var vm = MakeRow(TaskStatus.Idle, parentTaskId: "parent-id"); + vm.ParentFinalized = false; + Assert.True(vm.IsDraft); + Assert.False(vm.IsPlanned); + Assert.False(vm.CanSendToQueue); + } + + [Fact] + public void PlannedChild_CanSendToQueue() + { + var vm = MakeRow(TaskStatus.Idle, parentTaskId: "parent-id"); + vm.ParentFinalized = true; + Assert.False(vm.IsDraft); + Assert.True(vm.IsPlanned); + Assert.True(vm.CanSendToQueue); + } + + [Fact] + public void StandaloneIdle_CanSendToQueue() + { + var vm = MakeRow(TaskStatus.Idle); + Assert.False(vm.IsChild); + Assert.True(vm.CanSendToQueue); + } + + [Fact] + public void FinalizedParentWithChildren_CanQueuePlan() + { + var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized); + vm.HasPlanningChildren = true; + Assert.True(vm.CanQueuePlan); + } + + [Fact] + public void ActiveParentWithChildren_CannotQueuePlan() + { + var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active); + vm.HasPlanningChildren = true; + Assert.False(vm.CanQueuePlan); + } + + [Fact] + public void FinalizedParentWithoutChildren_CannotQueuePlan() + { + var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized); + Assert.False(vm.CanQueuePlan); + } }