refactor(planning): dequeue orphans instead of promoting, restore lost lineage
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>
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user