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:
@@ -5,10 +5,10 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace ClaudeDo.Worker.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Startup-only sweep: clears <c>ParentTaskId</c> 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 (<c>ParentTaskId</c> intact) but
|
||||
/// drops out of the queue so it can't run against a dead chain. The user can
|
||||
/// re-queue or detach manually.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
69
src/ClaudeDo.Worker/Lifecycle/PlanningLineageRecovery.cs
Normal file
69
src/ClaudeDo.Worker/Lifecycle/PlanningLineageRecovery.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>PlanningPhase.None</c>) 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.
|
||||
/// </summary>
|
||||
public sealed class PlanningLineageRecovery : IHostedService
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly string _sessionsRoot;
|
||||
private readonly ILogger<PlanningLineageRecovery> _logger;
|
||||
|
||||
public PlanningLineageRecovery(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
string sessionsRoot,
|
||||
ILogger<PlanningLineageRecovery> 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;
|
||||
}
|
||||
Reference in New Issue
Block a user