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:
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears <c>ParentTaskId</c> 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 <c>Status</c> from <c>Queued</c> to <c>Idle</c> and clears
|
||||
/// <c>BlockedByTaskId</c>. <c>ParentTaskId</c> stays intact — the child remains
|
||||
/// part of its (former) planning chain for historical context. Returns the
|
||||
/// number of rows dequeued. Idempotent.
|
||||
/// </summary>
|
||||
internal async Task<int> RepairOrphanedChildrenAsync(CancellationToken ct = default)
|
||||
internal async Task<int> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores a planning-session lineage that lost its <c>parent_task_id</c> links.
|
||||
/// Given a candidate parent task and a single unambiguous orphan chain in the
|
||||
/// same list (linked via <c>BlockedByTaskId</c>), re-attaches the chain members
|
||||
/// to the parent, marks the parent as <c>Finalized</c>, and dequeues queued
|
||||
/// chain members. No-op if conditions are not met. Returns the number of
|
||||
/// re-attached children (0 if skipped).
|
||||
/// </summary>
|
||||
internal async Task<int> 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<TaskEntity> { 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(
|
||||
|
||||
Reference in New Issue
Block a user