feat(planning): prevent orphaned subtasks via guards + startup repair
Three coordinated guards close the orphan-creation paths: - CreateChildAsync refuses when the parent is not in a planning phase. - DiscardPlanningAsync now returns a structured DiscardPlanningOutcome and refuses when children are queued or running; callers can opt into auto-dequeuing queued kids via dequeueQueuedChildren=true. Terminal children (Done/Failed/Cancelled) are promoted to top-level instead of becoming orphans when the parent's PlanningPhase is reset. - OrphanRecovery hosted service clears ParentTaskId on any rows whose parent is missing or no longer in a planning phase on worker startup, mirroring the StaleTaskRecovery pattern. UI surfaces the block reason: a confirm dialog offers to dequeue queued children and retry; a running-children block is shown as a hard error asking the user to cancel first. WorkerClient now negotiates the JsonStringEnumConverter so the DiscardPlanningResult enum round-trips correctly over SignalR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
38
src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs
Normal file
38
src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
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.
|
||||
/// </summary>
|
||||
public sealed class OrphanRecovery : IHostedService
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly ILogger<OrphanRecovery> _logger;
|
||||
|
||||
public OrphanRecovery(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
ILogger<OrphanRecovery> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
else
|
||||
_logger.LogInformation("Orphan recovery: no orphans found");
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
Reference in New Issue
Block a user