Three behavioral changes around stuck planning subtasks: - OrphanRecovery no longer clears ParentTaskId. Queued children of a parent that is not in a planning phase are dequeued (Status: Queued -> Idle, BlockedByTaskId cleared) but stay attached to the parent so the historical lineage is preserved. - DiscardPlanningAsync stops promoting terminal (Done/Failed/Cancelled) children to top-level for the same reason - they remain ChildTasks of the (now non-planning) parent. - New PlanningLineageRecovery hosted service scans ~/.todo-app/planning-sessions/ and re-attaches a single, unambiguous blocked-by chain to its original planning parent when the parent_task_id links were lost. Refuses to guess when multiple candidate chains exist. UI now exposes ConnectionRestoredEvent on IWorkerClient, fired on first connect and every reconnect. ListsIslandViewModel refreshes counters and TasksIslandViewModel reloads the current list - so stale counts no longer survive a worker restart. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
8.4 KiB
C#
178 lines
8.4 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.Islands;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Xunit;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.UiVm;
|
|
|
|
// ── Fake worker client ────────────────────────────────────────────────────────
|
|
|
|
sealed class FakeWorkerClient : IWorkerClient
|
|
{
|
|
public int StartPlanningCalls { get; private set; }
|
|
public int ResumePlanningCalls { get; private set; }
|
|
public int DiscardPlanningCalls { get; private set; }
|
|
public int FinalizePlanningCalls { get; private set; }
|
|
public int WakeQueueCalls { get; private set; }
|
|
|
|
public bool IsConnected => false;
|
|
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
|
public event Action<string>? TaskUpdatedEvent;
|
|
public event Action? ConnectionRestoredEvent;
|
|
public event Action<string>? WorktreeUpdatedEvent;
|
|
public event Action<string, string>? TaskMessageEvent;
|
|
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
|
public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId);
|
|
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
|
|
|
|
public Task RunNowAsync(string taskId) => Task.CompletedTask;
|
|
public Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask;
|
|
public Task ResetTaskAsync(string taskId) => Task.CompletedTask;
|
|
public Task CancelTaskAsync(string taskId) => Task.CompletedTask;
|
|
public Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
|
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
|
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
|
public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
|
|
public Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames) => Task.CompletedTask;
|
|
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
|
public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; }
|
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; }
|
|
public Task OpenInteractiveTerminalAsync(string taskId, 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<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<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
|
|
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
|
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
|
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
|
public event Action<string>? PlanningMergeAbortedEvent;
|
|
public event Action<string>? PlanningCompletedEvent;
|
|
|
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
|
public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) => Task.FromResult<IReadOnlyList<SubtaskDiffDto>>(Array.Empty<SubtaskDiffDto>());
|
|
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) => Task.FromResult<CombinedDiffResultDto?>(null);
|
|
public Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) => Task.CompletedTask;
|
|
public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
|
public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
|
}
|
|
|
|
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
|
|
|
|
file static class VmFactory
|
|
{
|
|
// Minimal SQLite :memory: factory — never actually called in these tests
|
|
// (we seed Items directly), but required by the VM constructor.
|
|
private static IDbContextFactory<ClaudeDoDbContext> NullDbFactory()
|
|
{
|
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
|
.UseSqlite("DataSource=:memory:")
|
|
.Options;
|
|
return new NullDbContextFactory(opts);
|
|
}
|
|
|
|
private sealed class NullDbContextFactory(DbContextOptions<ClaudeDoDbContext> opts)
|
|
: IDbContextFactory<ClaudeDoDbContext>
|
|
{
|
|
public ClaudeDoDbContext CreateDbContext() => new(opts);
|
|
}
|
|
|
|
public static (TasksIslandViewModel vm, FakeWorkerClient worker) Create(
|
|
IEnumerable<TaskRowViewModel> rows)
|
|
{
|
|
var worker = new FakeWorkerClient();
|
|
var vm = new TasksIslandViewModel(NullDbFactory(), worker);
|
|
foreach (var r in rows)
|
|
vm.Items.Add(r);
|
|
vm.Regroup();
|
|
return (vm, worker);
|
|
}
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
public class TasksIslandViewModelPlanningTests
|
|
{
|
|
private static TaskRowViewModel MakeRow(
|
|
string id,
|
|
TaskStatus status,
|
|
string? parentId = null,
|
|
int sortOrder = 0,
|
|
PlanningPhase phase = PlanningPhase.None)
|
|
=> new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId, PlanningPhase = phase };
|
|
|
|
[Fact]
|
|
public void ToggleExpand_CollapsesChildrenOfPlanningParent()
|
|
{
|
|
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Active);
|
|
var child1 = MakeRow("c1", TaskStatus.Idle, "p1");
|
|
var child2 = MakeRow("c2", TaskStatus.Idle, "p1");
|
|
|
|
var (vm, _) = VmFactory.Create([parent, child1, child2]);
|
|
|
|
// Initially expanded — children visible in OpenItems
|
|
Assert.Contains(child1, vm.OpenItems);
|
|
Assert.Contains(child2, vm.OpenItems);
|
|
|
|
// Collapse the parent
|
|
vm.ToggleExpandCommand.Execute(parent);
|
|
|
|
// Children should no longer appear
|
|
Assert.DoesNotContain(child1, vm.OpenItems);
|
|
Assert.DoesNotContain(child2, vm.OpenItems);
|
|
// Parent still present
|
|
Assert.Contains(parent, vm.OpenItems);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenPlanningSession_IgnoresNonIdleRow()
|
|
{
|
|
var row = MakeRow("t1", TaskStatus.Queued);
|
|
var (vm, worker) = VmFactory.Create([row]);
|
|
|
|
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
|
|
|
|
Assert.Equal(0, worker.StartPlanningCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenPlanningSession_CallsWorkerForIdleRow()
|
|
{
|
|
var row = MakeRow("t1", TaskStatus.Idle);
|
|
var (vm, worker) = VmFactory.Create([row]);
|
|
|
|
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
|
|
|
|
Assert.Equal(1, worker.StartPlanningCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToggleExpand_ExpandsCollapsedParentAgain()
|
|
{
|
|
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
|
var child = MakeRow("c1", TaskStatus.Idle, "p1");
|
|
|
|
var (vm, _) = VmFactory.Create([parent, child]);
|
|
|
|
// Collapse
|
|
vm.ToggleExpandCommand.Execute(parent);
|
|
Assert.DoesNotContain(child, vm.OpenItems);
|
|
|
|
// Re-expand
|
|
vm.ToggleExpandCommand.Execute(parent);
|
|
Assert.Contains(child, vm.OpenItems);
|
|
}
|
|
}
|