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>
167 lines
7.2 KiB
C#
167 lines
7.2 KiB
C#
using System.Collections.Generic;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using ClaudeDo.Data.Models;
|
||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||
|
||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||
|
||
public sealed partial class TaskRowViewModel : ViewModelBase
|
||
{
|
||
public required string Id { get; init; }
|
||
[ObservableProperty] private string _title = "";
|
||
[ObservableProperty] private string _listName = "";
|
||
[ObservableProperty] private bool _done;
|
||
[ObservableProperty] private bool _isStarred;
|
||
[ObservableProperty] private bool _isMyDay;
|
||
[ObservableProperty] private bool _isSelected;
|
||
[ObservableProperty] private TaskStatus _status;
|
||
[ObservableProperty] private string? _branch;
|
||
[ObservableProperty] private string? _diffStat;
|
||
[ObservableProperty] private string? _liveTail;
|
||
[ObservableProperty] private DateTime? _scheduledFor;
|
||
[ObservableProperty] private int _diffAdditions;
|
||
[ObservableProperty] private int _diffDeletions;
|
||
[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;
|
||
|
||
public DateTime CreatedAt { get; init; }
|
||
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
||
|
||
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
||
public int StepsCount { get; init; }
|
||
public int StepsCompleted { get; init; }
|
||
|
||
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||
public bool IsPlanningParent => Status == TaskStatus.Planning
|
||
|| Status == TaskStatus.Planned
|
||
|| HasPlanningChildren;
|
||
public bool IsDraft => Status == TaskStatus.Draft;
|
||
|
||
public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
|
||
public bool CanResumeOrDiscardPlanning => Status == TaskStatus.Planning;
|
||
|
||
public string? PlanningBadge => Status switch
|
||
{
|
||
TaskStatus.Planning => "PLANNING",
|
||
TaskStatus.Planned => "PLANNED",
|
||
_ => null,
|
||
};
|
||
|
||
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
|
||
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
|
||
public bool HasTags => Tags.Count > 0;
|
||
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 && 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);
|
||
|
||
public string DiffAdditionsText => $"+{DiffAdditions}";
|
||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||
public string StepsText => $"{StepsCompleted}/{StepsCount} steps";
|
||
|
||
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
|
||
{
|
||
(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)
|
||
{
|
||
OnPropertyChanged(nameof(StatusChipClass));
|
||
OnPropertyChanged(nameof(IsRunning));
|
||
OnPropertyChanged(nameof(IsQueued));
|
||
OnPropertyChanged(nameof(IsWaiting));
|
||
OnPropertyChanged(nameof(HasLiveTail));
|
||
OnPropertyChanged(nameof(IsPlanningParent));
|
||
OnPropertyChanged(nameof(PlanningBadge));
|
||
OnPropertyChanged(nameof(IsDraft));
|
||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||
}
|
||
|
||
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));
|
||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||
}
|
||
|
||
partial void OnHasPlanningChildrenChanged(bool value)
|
||
=> OnPropertyChanged(nameof(IsPlanningParent));
|
||
|
||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
|
||
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
|
||
partial void OnScheduledForChanged(DateTime? value)
|
||
{
|
||
OnPropertyChanged(nameof(IsOverdue));
|
||
OnPropertyChanged(nameof(HasSchedule));
|
||
}
|
||
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
|
||
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
|
||
|
||
public static TaskRowViewModel FromEntity(TaskEntity t)
|
||
{
|
||
var row = new TaskRowViewModel { Id = t.Id, CreatedAt = t.CreatedAt };
|
||
row.UpdateFromEntity(t);
|
||
return row;
|
||
}
|
||
|
||
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;
|
||
BlockedByTaskId = t.BlockedByTaskId;
|
||
}
|
||
|
||
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
|
||
private static (int add, int del) ParseDiffStat(string? s)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(s)) return (0, 0);
|
||
int add = 0, del = 0;
|
||
var parts = s.Split(new[] { ' ', ',', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||
foreach (var p in parts)
|
||
{
|
||
if (p.Length > 1 && p[0] == '+' && int.TryParse(p.AsSpan(1), out var a)) add = a;
|
||
else if (p.Length > 1 && (p[0] == '-' || p[0] == '\u2212') && int.TryParse(p.AsSpan(1), out var d)) del = d;
|
||
}
|
||
return (add, del);
|
||
}
|
||
}
|