Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryOrphanGuardTests.cs
mika kuns 0d55002e5e 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>
2026-05-18 16:28:57 +02:00

302 lines
12 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Repositories;
/// <summary>
/// Covers the invariant that no task may have <c>ParentTaskId</c> pointing to a
/// parent without <c>PlanningPhase.Active|Finalized</c>. Tests the three guard
/// rails: <c>CreateChildAsync</c> validation, <c>DiscardPlanningAsync</c>
/// gating with the optional dequeue path, and the startup repair sweep.
/// </summary>
public sealed class TaskRepositoryOrphanGuardTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public TaskRepositoryOrphanGuardTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private async Task<string> CreateListAsync()
{
var id = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
return id;
}
private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Idle, PlanningPhase phase = PlanningPhase.None) => new()
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "T",
Status = status,
PlanningPhase = phase,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
private async Task<TaskEntity> SeedPlanningParentAsync(string listId)
{
var parent = MakeTask(listId, status: TaskStatus.Idle, phase: PlanningPhase.Active);
await _tasks.AddAsync(parent);
return parent;
}
// --- CreateChildAsync validation ---
[Fact]
public async Task CreateChildAsync_Throws_When_Parent_Has_No_Planning_Phase()
{
var listId = await CreateListAsync();
var parent = MakeTask(listId, phase: PlanningPhase.None);
await _tasks.AddAsync(parent);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _tasks.CreateChildAsync(parent.Id, "child", null, null, null));
Assert.Contains("not in a planning phase", ex.Message);
}
[Fact]
public async Task CreateChildAsync_Succeeds_When_Parent_Is_Active()
{
var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId);
var child = await _tasks.CreateChildAsync(parent.Id, "child", null, null, null);
Assert.Equal(parent.Id, child.ParentTaskId);
Assert.Equal(TaskStatus.Idle, child.Status);
}
// --- DiscardPlanningAsync gating ---
[Fact]
public async Task DiscardPlanning_NotInPlanning_When_Parent_Phase_Is_None()
{
var listId = await CreateListAsync();
var stray = MakeTask(listId, phase: PlanningPhase.None);
await _tasks.AddAsync(stray);
var outcome = await _tasks.DiscardPlanningAsync(stray.Id, dequeueQueuedChildren: false);
Assert.Equal(DiscardPlanningResult.NotInPlanning, outcome.Result);
}
[Fact]
public async Task DiscardPlanning_Succeeds_When_All_Children_Are_Idle()
{
var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId);
await _tasks.CreateChildAsync(parent.Id, "a", null, null, null);
await _tasks.CreateChildAsync(parent.Id, "b", null, null, null);
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
Assert.Equal(0, _ctx.Tasks.AsNoTracking().Count(t => t.ParentTaskId == parent.Id));
var reloaded = _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id);
Assert.Equal(PlanningPhase.None, reloaded.PlanningPhase);
}
[Fact]
public async Task DiscardPlanning_Blocks_On_Queued_Children_Without_Optin()
{
var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId);
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
await SetChildStatusAsync(child.Id, TaskStatus.Queued);
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
Assert.Equal(DiscardPlanningResult.BlockedByQueuedChildren, outcome.Result);
Assert.Equal(1, outcome.QueuedChildrenCount);
// Parent and child are untouched.
Assert.Equal(PlanningPhase.Active, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase);
Assert.Equal(TaskStatus.Queued, _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id).Status);
}
[Fact]
public async Task DiscardPlanning_With_Dequeue_Succeeds_And_Drops_Idle_Children()
{
var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId);
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
await SetChildStatusAsync(child.Id, TaskStatus.Queued);
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: true);
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
// Child was dequeued to Idle and then deleted as part of the discard.
Assert.False(_ctx.Tasks.AsNoTracking().Any(t => t.Id == child.Id));
}
[Fact]
public async Task DiscardPlanning_Blocks_On_Running_Children_Even_With_Dequeue_Optin()
{
var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId);
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
await SetChildStatusAsync(child.Id, TaskStatus.Running);
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: true);
Assert.Equal(DiscardPlanningResult.BlockedByRunningChildren, outcome.Result);
Assert.Equal(1, outcome.RunningChildrenCount);
Assert.Equal(PlanningPhase.Active, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase);
}
[Fact]
public async Task DiscardPlanning_Leaves_Terminal_Children_Attached()
{
var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId);
var done = await _tasks.CreateChildAsync(parent.Id, "done", null, null, null);
var failed = await _tasks.CreateChildAsync(parent.Id, "failed", null, null, null);
await SetChildStatusAsync(done.Id, TaskStatus.Done);
await SetChildStatusAsync(failed.Id, TaskStatus.Failed);
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
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);
}
// --- Dequeue sweep ---
[Fact]
public async Task Dequeue_Dequeues_Queued_Child_When_Parent_Is_Not_Planning()
{
var listId = await CreateListAsync();
// Parent is plain (not planning), child attached -> stuck queued.
var parent = MakeTask(listId, phase: PlanningPhase.None);
await _tasks.AddAsync(parent);
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 dequeued = await _tasks.DequeueOrphanedChildrenAsync();
Assert.Equal(0, dequeued);
}
[Fact]
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 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();
t.Status = status;
await _ctx.SaveChangesAsync();
_ctx.Entry(t).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
}
}