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);