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);
|
.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal children (Done/Failed/Cancelled) survive the discard but cannot remain
|
// Terminal children (Done/Failed/Cancelled) stay attached to the parent even
|
||||||
// attached: their parent's PlanningPhase is about to be reset to None, which would
|
// though its PlanningPhase will be reset to None. The lineage is preserved as
|
||||||
// make them orphans. Promote them to top-level.
|
// historical context; the UI nests them under their parent regardless of phase.
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idle children created during this planning session are dropped.
|
// Idle children created during this planning session are dropped.
|
||||||
await _context.Tasks
|
await _context.Tasks
|
||||||
@@ -488,13 +476,16 @@ public sealed class TaskRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clears <c>ParentTaskId</c> on rows whose parent is missing or no longer in a
|
/// Dequeues child tasks whose parent is missing or no longer in a planning phase:
|
||||||
/// planning phase. Returns the number of rows repaired. Idempotent.
|
/// 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>
|
/// </summary>
|
||||||
internal async Task<int> RepairOrphanedChildrenAsync(CancellationToken ct = default)
|
internal async Task<int> DequeueOrphanedChildrenAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var orphanIds = await _context.Tasks
|
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 =>
|
.Where(t => !_context.Tasks.Any(p =>
|
||||||
p.Id == t.ParentTaskId && p.PlanningPhase != PlanningPhase.None))
|
p.Id == t.ParentTaskId && p.PlanningPhase != PlanningPhase.None))
|
||||||
.Select(t => t.Id)
|
.Select(t => t.Id)
|
||||||
@@ -504,7 +495,73 @@ public sealed class TaskRepository
|
|||||||
|
|
||||||
return await _context.Tasks
|
return await _context.Tasks
|
||||||
.Where(t => orphanIds.Contains(t.Id))
|
.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(
|
public async Task TryCompleteParentAsync(
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
event Action<string, string, DateTime>? TaskStartedEvent;
|
event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
event Action<string>? TaskUpdatedEvent;
|
event Action<string>? TaskUpdatedEvent;
|
||||||
|
/// <summary>Raised once when the SignalR connection is first established, and again on every reconnect.</summary>
|
||||||
|
event Action? ConnectionRestoredEvent;
|
||||||
event Action<string>? WorktreeUpdatedEvent;
|
event Action<string>? WorktreeUpdatedEvent;
|
||||||
event Action<string, string>? TaskMessageEvent;
|
event Action<string, string>? TaskMessageEvent;
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string>? RunNowRequestedEvent;
|
public event Action<string>? RunNowRequestedEvent;
|
||||||
public event Action<string>? ListUpdatedEvent;
|
public event Action<string>? ListUpdatedEvent;
|
||||||
@@ -76,6 +77,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
|
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
|
||||||
await SeedActiveTasksAsync();
|
await SeedActiveTasksAsync();
|
||||||
|
Dispatcher.UIThread.Post(() => ConnectionRestoredEvent?.Invoke());
|
||||||
};
|
};
|
||||||
|
|
||||||
_hub.Reconnecting += _ =>
|
_hub.Reconnecting += _ =>
|
||||||
@@ -200,6 +202,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
await _hub.StartAsync(ct);
|
await _hub.StartAsync(ct);
|
||||||
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
|
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
|
||||||
await SeedActiveTasksAsync();
|
await SeedActiveTasksAsync();
|
||||||
|
Dispatcher.UIThread.Post(() => ConnectionRestoredEvent?.Invoke());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
|
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
|
||||||
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
||||||
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||||
|
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
|
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
|
||||||
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
||||||
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
||||||
|
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
|
||||||
_ = RefreshAllTagsAsync();
|
_ = RefreshAllTagsAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace ClaudeDo.Worker.Lifecycle;
|
namespace ClaudeDo.Worker.Lifecycle;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Startup-only sweep: clears <c>ParentTaskId</c> on rows whose parent is missing or
|
/// Startup-only sweep: dequeues queued tasks whose parent is missing or no longer
|
||||||
/// no longer in a planning phase. These rows would otherwise be invisible in the UI
|
/// in a planning phase. The child stays attached (<c>ParentTaskId</c> intact) but
|
||||||
/// (the parent doesn't render as a planning header) and cannot reach a terminal state
|
/// drops out of the queue so it can't run against a dead chain. The user can
|
||||||
/// through the chain coordinator. Promoting them to top-level restores both.
|
/// re-queue or detach manually.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class OrphanRecovery : IHostedService
|
public sealed class OrphanRecovery : IHostedService
|
||||||
{
|
{
|
||||||
@@ -27,11 +27,11 @@ public sealed class OrphanRecovery : IHostedService
|
|||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||||
var repo = new TaskRepository(ctx);
|
var repo = new TaskRepository(ctx);
|
||||||
var repaired = await repo.RepairOrphanedChildrenAsync(cancellationToken);
|
var dequeued = await repo.DequeueOrphanedChildrenAsync(cancellationToken);
|
||||||
if (repaired > 0)
|
if (dequeued > 0)
|
||||||
_logger.LogWarning("Orphan recovery: promoted {Count} orphaned child task(s) to top-level", repaired);
|
_logger.LogWarning("Orphan recovery: dequeued {Count} stuck child task(s)", dequeued);
|
||||||
else
|
else
|
||||||
_logger.LogInformation("Orphan recovery: no orphans found");
|
_logger.LogInformation("Orphan recovery: no stuck child tasks found");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
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;
|
||||||
|
}
|
||||||
@@ -101,6 +101,10 @@ builder.Services.AddSingleton(sp =>
|
|||||||
sp.GetRequiredService<ITaskStateService>(),
|
sp.GetRequiredService<ITaskStateService>(),
|
||||||
sp.GetRequiredService<PlanningChainCoordinator>(),
|
sp.GetRequiredService<PlanningChainCoordinator>(),
|
||||||
planningSessionsDir));
|
planningSessionsDir));
|
||||||
|
builder.Services.AddHostedService(sp => new PlanningLineageRecovery(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
|
planningSessionsDir,
|
||||||
|
sp.GetRequiredService<ILogger<PlanningLineageRecovery>>()));
|
||||||
builder.Services.AddSingleton<IPlanningTerminalLauncher>(sp =>
|
builder.Services.AddSingleton<IPlanningTerminalLauncher>(sp =>
|
||||||
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
|
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class ConflictResolutionViewModelTests
|
|||||||
public event Action<string, string, DateTime>? TaskStartedEvent;
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public class DetailsIslandPlanningTests : IDisposable
|
|||||||
public event Action<string, string, DateTime>? TaskStartedEvent;
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class PlanningDiffViewModelTests
|
|||||||
public event Action<string, string, DateTime>? TaskStartedEvent;
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ public sealed class TaskRepositoryOrphanGuardTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscardPlanning_Promotes_Terminal_Children_To_Top_Level()
|
public async Task DiscardPlanning_Leaves_Terminal_Children_Attached()
|
||||||
{
|
{
|
||||||
var listId = await CreateListAsync();
|
var listId = await CreateListAsync();
|
||||||
var parent = await SeedPlanningParentAsync(listId);
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
@@ -172,40 +172,125 @@ public sealed class TaskRepositoryOrphanGuardTests : IDisposable
|
|||||||
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
|
||||||
|
|
||||||
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
|
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
|
||||||
Assert.Null(_ctx.Tasks.AsNoTracking().Single(t => t.Id == done.Id).ParentTaskId);
|
Assert.Equal(parent.Id, _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 == failed.Id).ParentTaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Repair sweep ---
|
// --- Dequeue sweep ---
|
||||||
|
|
||||||
[Fact]
|
[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();
|
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);
|
var parent = MakeTask(listId, phase: PlanningPhase.None);
|
||||||
await _tasks.AddAsync(parent);
|
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;
|
child.ParentTaskId = parent.Id;
|
||||||
await _tasks.AddAsync(child);
|
await _tasks.AddAsync(child);
|
||||||
|
|
||||||
var repaired = await _tasks.RepairOrphanedChildrenAsync();
|
var dequeued = await _tasks.DequeueOrphanedChildrenAsync();
|
||||||
Assert.Equal(1, repaired);
|
Assert.Equal(0, dequeued);
|
||||||
Assert.Null(_ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id).ParentTaskId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Repair_Leaves_Valid_Children_Untouched()
|
public async Task Dequeue_Leaves_Valid_Children_Untouched()
|
||||||
{
|
{
|
||||||
var listId = await CreateListAsync();
|
var listId = await CreateListAsync();
|
||||||
var parent = await SeedPlanningParentAsync(listId);
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
|
||||||
var repaired = await _tasks.RepairOrphanedChildrenAsync();
|
var dequeued = await _tasks.DequeueOrphanedChildrenAsync();
|
||||||
Assert.Equal(0, repaired);
|
Assert.Equal(0, dequeued);
|
||||||
Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id).ParentTaskId);
|
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)
|
private async Task SetChildStatusAsync(string id, TaskStatus status)
|
||||||
{
|
{
|
||||||
var t = await _ctx.Tasks.FindAsync(id) ?? throw new InvalidOperationException();
|
var t = await _ctx.Tasks.FindAsync(id) ?? throw new InvalidOperationException();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
|||||||
public event Action<string, string, DateTime>? TaskStartedEvent;
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
||||||
|
|||||||
Reference in New Issue
Block a user