feat(planning): consolidate finalize+chain via TaskStateService, fix queue pickup

Slice 4 of the worker state consolidation refactor. Eliminates the
"queue never picks up planning tasks" bug structurally by routing both
the manager and MCP finalize paths through TaskStateService and
PlanningChainCoordinator.SetupChainAsync, where the auto-wake on enqueue
guarantees the queue picker claims the first child immediately.

- Delete TaskRepository.FinalizePlanningAsync; PlanningSessionManager
  now orchestrates via _state.FinalizePlanningAsync + _chain.SetupChainAsync.
- Rename QueueSubtasksSequentiallyAsync to SetupChainAsync (internal);
  layout is now Status=Queued + BlockedByTaskId, with auto-attached agent tag.
- OnChildFinishedAsync looks up the successor by BlockedByTaskId, drops
  the legacy Waiting status lookup.
- PlanningMcpService.Finalize routes through state+chain; EditableStatuses
  drops Waiting and adds Idle; gate uses PlanningPhase==Active.
- TaskStateService.FinalizePlanningAsync clears the planning session token.
- UI: TaskRowViewModel adds BlockedByTaskId; IsQueued/IsWaiting reflect
  the new layout; TasksIslandViewModel.RemoveFromQueueAsync clears
  BlockedByTaskId on dequeue.
- New regression test PlanningEndToEndTests.FinalizeAsync_FirstChildIs
  ClaimedByPicker_WithinDeadline asserts the picker claims the first
  child within 200ms with no manual WakeQueue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-04-27 14:16:12 +02:00
parent 064a903076
commit 4ab906ff0b
17 changed files with 315 additions and 206 deletions

View File

@@ -24,6 +24,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow;
[ObservableProperty] private string? _parentTaskId;
[ObservableProperty] private string? _blockedByTaskId;
[ObservableProperty] private bool _isExpanded = true;
[ObservableProperty] private bool _hasPlanningChildren;
[ObservableProperty] private bool _hasQueuedSubtasks;
@@ -57,8 +58,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool HasSteps => StepsCount > 0;
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
public bool IsRunning => Status == TaskStatus.Running;
public bool IsQueued => Status == TaskStatus.Queued;
public bool IsWaiting => Status == TaskStatus.Waiting;
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
public bool IsWaiting => (Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId))
|| Status == TaskStatus.Waiting;
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
public bool HasSchedule => ScheduledFor.HasValue;
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
@@ -67,14 +69,15 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public string DiffDeletionsText => $"{DiffDeletions}";
public string StepsText => $"{StepsCompleted}/{StepsCount} steps";
public string StatusChipClass => Status switch
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
{
TaskStatus.Running => "running",
TaskStatus.Failed => "error",
TaskStatus.Done => "review",
TaskStatus.Queued => "queued",
TaskStatus.Waiting => "waiting",
_ => "idle",
(TaskStatus.Running, _) => "running",
(TaskStatus.Failed, _) => "error",
(TaskStatus.Done, _) => "review",
(TaskStatus.Queued, true) => "waiting",
(TaskStatus.Queued, false) => "queued",
(TaskStatus.Waiting, _) => "waiting",
_ => "idle",
};
partial void OnStatusChanged(TaskStatus value)
@@ -95,6 +98,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
partial void OnHasQueuedSubtasksChanged(bool value)
=> OnPropertyChanged(nameof(CanRemoveFromQueue));
partial void OnBlockedByTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsWaiting));
OnPropertyChanged(nameof(StatusChipClass));
}
partial void OnParentTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsChild));
@@ -125,18 +135,19 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public void UpdateFromEntity(TaskEntity t)
{
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
Title = t.Title;
ListName = t.List?.Name ?? "";
Done = t.Status == TaskStatus.Done;
IsStarred = t.IsStarred;
IsMyDay = t.IsMyDay;
Status = t.Status;
Branch = t.Worktree?.BranchName;
DiffStat = t.Worktree?.DiffStat;
ScheduledFor = t.ScheduledFor;
DiffAdditions = add;
DiffDeletions = del;
ParentTaskId = t.ParentTaskId;
Title = t.Title;
ListName = t.List?.Name ?? "";
Done = t.Status == TaskStatus.Done;
IsStarred = t.IsStarred;
IsMyDay = t.IsMyDay;
Status = t.Status;
Branch = t.Worktree?.BranchName;
DiffStat = t.Worktree?.DiffStat;
ScheduledFor = t.ScheduledFor;
DiffAdditions = add;
DiffDeletions = del;
ParentTaskId = t.ParentTaskId;
BlockedByTaskId = t.BlockedByTaskId;
}
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".

View File

@@ -495,18 +495,27 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
// For a planning parent the dequeue button targets queued/waiting children,
// not the parent itself (whose Status is Planning/Planned).
if (entity.Status == TaskStatus.Planning || entity.Status == TaskStatus.Planned)
if (entity.Status == TaskStatus.Planning || entity.Status == TaskStatus.Planned
|| entity.PlanningPhase != PlanningPhase.None)
{
var children = await db.Tasks
.Where(t => t.ParentTaskId == row.Id
&& (t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting))
.ToListAsync();
foreach (var c in children) c.Status = TaskStatus.Manual;
foreach (var c in children)
{
c.Status = TaskStatus.Manual;
c.BlockedByTaskId = null;
}
await db.SaveChangesAsync();
foreach (var c in children)
{
var childRow = Items.FirstOrDefault(r => r.Id == c.Id);
if (childRow is not null) childRow.Status = TaskStatus.Manual;
if (childRow is not null)
{
childRow.Status = TaskStatus.Manual;
childRow.BlockedByTaskId = null;
}
}
row.HasQueuedSubtasks = false;
}