diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 23bcf22..a05f1f1 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -451,21 +451,9 @@ public sealed class TaskRepository .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); - } + // Terminal children (Done/Failed/Cancelled) stay attached to the parent even + // though its PlanningPhase will be reset to None. The lineage is preserved as + // historical context; the UI nests them under their parent regardless of phase. // Idle children created during this planning session are dropped. await _context.Tasks @@ -488,13 +476,16 @@ public sealed class TaskRepository } /// - /// Clears ParentTaskId on rows whose parent is missing or no longer in a - /// planning phase. Returns the number of rows repaired. Idempotent. + /// Dequeues child tasks whose parent is missing or no longer in a planning phase: + /// sets Status from Queued to Idle and clears + /// BlockedByTaskId. ParentTaskId stays intact — the child remains + /// part of its (former) planning chain for historical context. Returns the + /// number of rows dequeued. Idempotent. /// - internal async Task RepairOrphanedChildrenAsync(CancellationToken ct = default) + internal async Task DequeueOrphanedChildrenAsync(CancellationToken ct = default) { var orphanIds = await _context.Tasks - .Where(t => t.ParentTaskId != null) + .Where(t => t.ParentTaskId != null && t.Status == TaskStatus.Queued) .Where(t => !_context.Tasks.Any(p => p.Id == t.ParentTaskId && p.PlanningPhase != PlanningPhase.None)) .Select(t => t.Id) @@ -504,7 +495,73 @@ public sealed class TaskRepository return await _context.Tasks .Where(t => orphanIds.Contains(t.Id)) - .ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, (string?)null), ct); + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Idle) + .SetProperty(t => t.BlockedByTaskId, (string?)null), ct); + } + + /// + /// Restores a planning-session lineage that lost its parent_task_id links. + /// Given a candidate parent task and a single unambiguous orphan chain in the + /// same list (linked via BlockedByTaskId), re-attaches the chain members + /// to the parent, marks the parent as Finalized, and dequeues queued + /// chain members. No-op if conditions are not met. Returns the number of + /// re-attached children (0 if skipped). + /// + internal async Task RestorePlanningLineageAsync(string parentId, CancellationToken ct = default) + { + var parent = await _context.Tasks.AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == parentId, ct); + if (parent is null) return 0; + if (parent.PlanningPhase != PlanningPhase.None) return 0; + if (parent.Status is TaskStatus.Done or TaskStatus.Failed or TaskStatus.Cancelled) return 0; + + // Candidates: unattached tasks in the same list, excluding the parent itself. + var candidates = await _context.Tasks.AsNoTracking() + .Where(t => t.ListId == parent.ListId && t.ParentTaskId == null && t.Id != parent.Id) + .ToListAsync(ct); + + // A chain is a maximal linear sequence linked via BlockedByTaskId. Find heads + // (BlockedByTaskId == null) that have at least one successor. + var bySource = candidates + .Where(c => c.BlockedByTaskId != null) + .ToLookup(c => c.BlockedByTaskId!); + + var heads = candidates + .Where(c => c.BlockedByTaskId == null && bySource[c.Id].Any()) + .ToList(); + + // Bail unless exactly one chain anchors a successor — anything else is + // ambiguous and we refuse to guess. + if (heads.Count != 1) return 0; + + var chain = new List { heads[0] }; + var current = heads[0]; + while (true) + { + var next = bySource[current.Id].FirstOrDefault(); + if (next is null) break; + chain.Add(next); + current = next; + } + + var chainIds = chain.Select(c => c.Id).ToList(); + + await _context.Tasks + .Where(t => t.Id == parentId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized), ct); + + await _context.Tasks + .Where(t => chainIds.Contains(t.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, (string?)parentId), ct); + + // Dequeue queued chain members; blocked_by stays intact so chain order is + // preserved for manual re-queueing. + await _context.Tasks + .Where(t => chainIds.Contains(t.Id) && t.Status == TaskStatus.Queued) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Idle), ct); + + return chainIds.Count; } public async Task TryCompleteParentAsync( diff --git a/src/ClaudeDo.Ui/Services/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/IWorkerClient.cs index f9522c6..19b44e9 100644 --- a/src/ClaudeDo.Ui/Services/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/IWorkerClient.cs @@ -12,6 +12,8 @@ public interface IWorkerClient : INotifyPropertyChanged event Action? TaskStartedEvent; event Action? TaskFinishedEvent; event Action? TaskUpdatedEvent; + /// Raised once when the SignalR connection is first established, and again on every reconnect. + event Action? ConnectionRestoredEvent; event Action? WorktreeUpdatedEvent; event Action? TaskMessageEvent; diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 6bd6d35..252151d 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -46,6 +46,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? TaskFinishedEvent; public event Action? TaskMessageEvent; public event Action? TaskUpdatedEvent; + public event Action? ConnectionRestoredEvent; public event Action? WorktreeUpdatedEvent; public event Action? RunNowRequestedEvent; public event Action? ListUpdatedEvent; @@ -76,6 +77,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC { Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; }); await SeedActiveTasksAsync(); + Dispatcher.UIThread.Post(() => ConnectionRestoredEvent?.Invoke()); }; _hub.Reconnecting += _ => @@ -200,6 +202,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await _hub.StartAsync(ct); Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; }); await SeedActiveTasksAsync(); + Dispatcher.UIThread.Post(() => ConnectionRestoredEvent?.Invoke()); return; } catch (OperationCanceledException) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 55b22fc..a678a80 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -78,6 +78,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase _worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync(); _worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync(); _worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync(); + _worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync(); } } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 8360657..5c99a1a 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -55,9 +55,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase _worker = worker; if (_worker is not null) { - _worker.TaskUpdatedEvent += OnWorkerTaskUpdated; - _worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated; - _worker.TaskMessageEvent += OnWorkerTaskMessage; + _worker.TaskUpdatedEvent += OnWorkerTaskUpdated; + _worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated; + _worker.TaskMessageEvent += OnWorkerTaskMessage; + _worker.ConnectionRestoredEvent += () => LoadForList(_currentList); _ = RefreshAllTagsAsync(); } } diff --git a/src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs index 068a5d8..fd67c02 100644 --- a/src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs +++ b/src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs @@ -5,10 +5,10 @@ using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Lifecycle; /// -/// Startup-only sweep: clears ParentTaskId 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. +/// Startup-only sweep: dequeues queued tasks whose parent is missing or no longer +/// in a planning phase. The child stays attached (ParentTaskId intact) but +/// drops out of the queue so it can't run against a dead chain. The user can +/// re-queue or detach manually. /// public sealed class OrphanRecovery : IHostedService { @@ -27,11 +27,11 @@ public sealed class OrphanRecovery : IHostedService { 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); + var dequeued = await repo.DequeueOrphanedChildrenAsync(cancellationToken); + if (dequeued > 0) + _logger.LogWarning("Orphan recovery: dequeued {Count} stuck child task(s)", dequeued); else - _logger.LogInformation("Orphan recovery: no orphans found"); + _logger.LogInformation("Orphan recovery: no stuck child tasks found"); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/ClaudeDo.Worker/Lifecycle/PlanningLineageRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/PlanningLineageRecovery.cs new file mode 100644 index 0000000..33d88c0 --- /dev/null +++ b/src/ClaudeDo.Worker/Lifecycle/PlanningLineageRecovery.cs @@ -0,0 +1,69 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Lifecycle; + +/// +/// Startup-only sweep that tries to re-attach blocked-by chains to their original +/// planning parent when the lineage was lost (parent_task_id cleared, parent +/// reverted to PlanningPhase.None) but the planning-sessions directory +/// still has the matching folder. Only restores lineage when the chain in the +/// parent's list is unambiguously the parent's — refuses to guess otherwise. +/// +public sealed class PlanningLineageRecovery : IHostedService +{ + private readonly IDbContextFactory _dbFactory; + private readonly string _sessionsRoot; + private readonly ILogger _logger; + + public PlanningLineageRecovery( + IDbContextFactory dbFactory, + string sessionsRoot, + ILogger logger) + { + _dbFactory = dbFactory; + _sessionsRoot = sessionsRoot; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + if (!Directory.Exists(_sessionsRoot)) + { + _logger.LogInformation("Planning lineage recovery: sessions directory missing, nothing to scan"); + return; + } + + var folders = Directory.GetDirectories(_sessionsRoot); + if (folders.Length == 0) + { + _logger.LogInformation("Planning lineage recovery: no session folders"); + return; + } + + await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); + var repo = new TaskRepository(ctx); + + var restored = 0; + foreach (var folder in folders) + { + var taskId = Path.GetFileName(folder); + if (string.IsNullOrEmpty(taskId)) continue; + + var count = await repo.RestorePlanningLineageAsync(taskId, cancellationToken); + if (count > 0) + { + _logger.LogWarning( + "Planning lineage recovery: re-attached {Count} child(ren) to parent {ParentId}", + count, taskId); + restored++; + } + } + + if (restored == 0) + _logger.LogInformation("Planning lineage recovery: no candidates"); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 39fa7e2..7dfde97 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -101,6 +101,10 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService(), sp.GetRequiredService(), planningSessionsDir)); +builder.Services.AddHostedService(sp => new PlanningLineageRecovery( + sp.GetRequiredService>(), + planningSessionsDir, + sp.GetRequiredService>())); builder.Services.AddSingleton(sp => new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin)); builder.Services.AddHttpContextAccessor(); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolutionViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolutionViewModelTests.cs index 59cf26d..784286a 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolutionViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolutionViewModelTests.cs @@ -21,6 +21,7 @@ public class ConflictResolutionViewModelTests public event Action? TaskStartedEvent; public event Action? TaskFinishedEvent; public event Action? TaskUpdatedEvent; + public event Action? ConnectionRestoredEvent; public event Action? WorktreeUpdatedEvent; public event Action? TaskMessageEvent; public event Action? PlanningMergeStartedEvent; diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs index 5b79337..8797902 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs @@ -48,6 +48,7 @@ public class DetailsIslandPlanningTests : IDisposable public event Action? TaskStartedEvent; public event Action? TaskFinishedEvent; public event Action? TaskUpdatedEvent; + public event Action? ConnectionRestoredEvent; public event Action? WorktreeUpdatedEvent; public event Action? TaskMessageEvent; public event Action? PlanningMergeStartedEvent; diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/PlanningDiffViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/PlanningDiffViewModelTests.cs index 0b054eb..0e8de55 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/PlanningDiffViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/PlanningDiffViewModelTests.cs @@ -13,6 +13,7 @@ public class PlanningDiffViewModelTests public event Action? TaskStartedEvent; public event Action? TaskFinishedEvent; public event Action? TaskUpdatedEvent; + public event Action? ConnectionRestoredEvent; public event Action? WorktreeUpdatedEvent; public event Action? TaskMessageEvent; public event Action? PlanningMergeStartedEvent; diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryOrphanGuardTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryOrphanGuardTests.cs index 9fe4d92..a604ecf 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryOrphanGuardTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryOrphanGuardTests.cs @@ -160,7 +160,7 @@ public sealed class TaskRepositoryOrphanGuardTests : IDisposable } [Fact] - public async Task DiscardPlanning_Promotes_Terminal_Children_To_Top_Level() + public async Task DiscardPlanning_Leaves_Terminal_Children_Attached() { var listId = await CreateListAsync(); var parent = await SeedPlanningParentAsync(listId); @@ -172,40 +172,125 @@ public sealed class TaskRepositoryOrphanGuardTests : IDisposable 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); + Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == done.Id).ParentTaskId); + Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == failed.Id).ParentTaskId); } - // --- Repair sweep --- + // --- Dequeue sweep --- [Fact] - public async Task Repair_Clears_ParentTaskId_When_Parent_Is_Not_Planning() + public async Task Dequeue_Dequeues_Queued_Child_When_Parent_Is_Not_Planning() { var listId = await CreateListAsync(); - // Parent is plain (not planning), child attached -> orphan by definition. + // Parent is plain (not planning), child attached -> stuck queued. var parent = MakeTask(listId, phase: PlanningPhase.None); await _tasks.AddAsync(parent); - var child = MakeTask(listId); + var predecessor = MakeTask(listId, status: TaskStatus.Idle); + await _tasks.AddAsync(predecessor); + var child = MakeTask(listId, status: TaskStatus.Queued); + child.ParentTaskId = parent.Id; + child.BlockedByTaskId = predecessor.Id; + await _tasks.AddAsync(child); + + var dequeued = await _tasks.DequeueOrphanedChildrenAsync(); + + Assert.Equal(1, dequeued); + var reloaded = _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id); + Assert.Equal(TaskStatus.Idle, reloaded.Status); + Assert.Null(reloaded.BlockedByTaskId); + Assert.Equal(parent.Id, reloaded.ParentTaskId); // lineage stays + } + + [Fact] + public async Task Dequeue_Leaves_Idle_Children_Of_NonPlanning_Parent_Alone() + { + var listId = await CreateListAsync(); + var parent = MakeTask(listId, phase: PlanningPhase.None); + await _tasks.AddAsync(parent); + var child = MakeTask(listId, status: TaskStatus.Idle); 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); + var dequeued = await _tasks.DequeueOrphanedChildrenAsync(); + Assert.Equal(0, dequeued); } [Fact] - public async Task Repair_Leaves_Valid_Children_Untouched() + public async Task Dequeue_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); + var dequeued = await _tasks.DequeueOrphanedChildrenAsync(); + Assert.Equal(0, dequeued); Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id).ParentTaskId); } + // --- Planning lineage restoration --- + + [Fact] + public async Task RestoreLineage_ReAttaches_Unambiguous_Chain_And_Dequeues_Queued_Members() + { + var listId = await CreateListAsync(); + // Parent that once had a planning session but lost the link. + var parent = MakeTask(listId, phase: PlanningPhase.None); + await _tasks.AddAsync(parent); + + // Chain: head (idle, no blocked_by, someone is blocked by it) + 2 queued successors. + var head = MakeTask(listId, status: TaskStatus.Idle); + head.BlockedByTaskId = null; + await _tasks.AddAsync(head); + + var mid = MakeTask(listId, status: TaskStatus.Queued); + mid.BlockedByTaskId = head.Id; + await _tasks.AddAsync(mid); + + var tail = MakeTask(listId, status: TaskStatus.Queued); + tail.BlockedByTaskId = mid.Id; + await _tasks.AddAsync(tail); + + var restored = await _tasks.RestorePlanningLineageAsync(parent.Id); + + Assert.Equal(3, restored); + Assert.Equal(PlanningPhase.Finalized, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase); + Assert.All(new[] { head.Id, mid.Id, tail.Id }, id => + Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == id).ParentTaskId)); + Assert.Equal(TaskStatus.Idle, _ctx.Tasks.AsNoTracking().Single(t => t.Id == mid.Id).Status); + Assert.Equal(TaskStatus.Idle, _ctx.Tasks.AsNoTracking().Single(t => t.Id == tail.Id).Status); + // blocked_by intact for chain order. + Assert.Equal(head.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == mid.Id).BlockedByTaskId); + Assert.Equal(mid.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == tail.Id).BlockedByTaskId); + } + + [Fact] + public async Task RestoreLineage_Skips_When_Multiple_Chains_Exist() + { + var listId = await CreateListAsync(); + var parent = MakeTask(listId, phase: PlanningPhase.None); + await _tasks.AddAsync(parent); + + // Two independent chains in the same list -> ambiguous. + var headA = MakeTask(listId, status: TaskStatus.Idle); await _tasks.AddAsync(headA); + var midA = MakeTask(listId, status: TaskStatus.Queued); midA.BlockedByTaskId = headA.Id; await _tasks.AddAsync(midA); + var headB = MakeTask(listId, status: TaskStatus.Idle); await _tasks.AddAsync(headB); + var midB = MakeTask(listId, status: TaskStatus.Queued); midB.BlockedByTaskId = headB.Id; await _tasks.AddAsync(midB); + + var restored = await _tasks.RestorePlanningLineageAsync(parent.Id); + Assert.Equal(0, restored); + Assert.Equal(PlanningPhase.None, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase); + } + + [Fact] + public async Task RestoreLineage_Skips_When_Parent_Already_Has_Planning_Phase() + { + var listId = await CreateListAsync(); + var parent = await SeedPlanningParentAsync(listId); + + var restored = await _tasks.RestorePlanningLineageAsync(parent.Id); + Assert.Equal(0, restored); + } + private async Task SetChildStatusAsync(string id, TaskStatus status) { var t = await _ctx.Tasks.FindAsync(id) ?? throw new InvalidOperationException(); diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 14b2e2c..07b7c75 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -25,6 +25,7 @@ sealed class FakeWorkerClient : IWorkerClient public event Action? TaskStartedEvent; public event Action? TaskFinishedEvent; public event Action? TaskUpdatedEvent; + public event Action? ConnectionRestoredEvent; public event Action? WorktreeUpdatedEvent; public event Action? TaskMessageEvent; public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);