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:
mika kuns
2026-05-18 16:28:57 +02:00
parent d094a21e09
commit 0d55002e5e
13 changed files with 270 additions and 44 deletions

View File

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

View File

@@ -25,6 +25,7 @@ sealed class FakeWorkerClient : IWorkerClient
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);