From 4ab906ff0b5db9bdeb499864c36cfbcbf9c12fe0 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 27 Apr 2026 14:16:12 +0200 Subject: [PATCH] feat(planning): consolidate finalize+chain via TaskStateService, fix queue pickup Slice 4 of the worker state consolidation refactor. Eliminates the "queue never picks up planning tasks" bug structurally by routing both the manager and MCP finalize paths through TaskStateService and PlanningChainCoordinator.SetupChainAsync, where the auto-wake on enqueue guarantees the queue picker claims the first child immediately. - Delete TaskRepository.FinalizePlanningAsync; PlanningSessionManager now orchestrates via _state.FinalizePlanningAsync + _chain.SetupChainAsync. - Rename QueueSubtasksSequentiallyAsync to SetupChainAsync (internal); layout is now Status=Queued + BlockedByTaskId, with auto-attached agent tag. - OnChildFinishedAsync looks up the successor by BlockedByTaskId, drops the legacy Waiting status lookup. - PlanningMcpService.Finalize routes through state+chain; EditableStatuses drops Waiting and adds Idle; gate uses PlanningPhase==Active. - TaskStateService.FinalizePlanningAsync clears the planning session token. - UI: TaskRowViewModel adds BlockedByTaskId; IsQueued/IsWaiting reflect the new layout; TasksIslandViewModel.RemoveFromQueueAsync clears BlockedByTaskId on dequeue. - New regression test PlanningEndToEndTests.FinalizeAsync_FirstChildIs ClaimedByPicker_WithinDeadline asserts the picker claims the first child within 200ms with no manual WakeQueue. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Repositories/TaskRepository.cs | 45 +--------- .../ViewModels/Islands/TaskRowViewModel.cs | 53 ++++++----- .../Islands/TasksIslandViewModel.cs | 15 +++- src/ClaudeDo.Worker/ClaudeDo.Worker.csproj | 4 + src/ClaudeDo.Worker/Hub/WorkerHub.cs | 2 +- .../Planning/PlanningChainCoordinator.cs | 61 ++++++++----- .../Planning/PlanningMcpService.cs | 37 +++++--- .../Planning/PlanningSessionManager.cs | 23 ++++- src/ClaudeDo.Worker/Program.cs | 1 + src/ClaudeDo.Worker/State/TaskStateService.cs | 3 +- .../Hub/PlanningHubTests.cs | 4 +- .../Planning/PlanningChainCoordinatorTests.cs | 77 ++++++++++++---- .../Planning/PlanningEndToEndTests.cs | 88 +++++++++++++++++-- .../Planning/PlanningMcpServiceTests.cs | 13 ++- .../Planning/PlanningSessionManagerTests.cs | 11 ++- .../TaskRepositoryPlanningTests.cs | 59 ------------- .../State/TaskStateServiceTests.cs | 25 ++++-- 17 files changed, 315 insertions(+), 206 deletions(-) diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index d383d2c..31038db 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -358,6 +358,7 @@ public sealed class TaskRepository .Where(t => t.Id == taskId && t.Status == TaskStatus.Manual) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Planning) + .SetProperty(t => t.PlanningPhase, PlanningPhase.Active) .SetProperty(t => t.PlanningSessionToken, sessionToken), ct); if (affected == 0) return null; @@ -396,49 +397,6 @@ public sealed class TaskRepository .FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct); } - public async Task FinalizePlanningAsync( - string parentId, - bool queueAgentTasks, - CancellationToken ct = default) - { - using var tx = await _context.Database.BeginTransactionAsync(ct); - - var parent = await _context.Tasks - .AsNoTracking() - .Include(t => t.List).ThenInclude(l => l.Tags) - .FirstOrDefaultAsync(t => t.Id == parentId, ct); - if (parent is null || parent.Status != TaskStatus.Planning) - throw new InvalidOperationException($"Task {parentId} is not in Planning state."); - - var listHasAgentTag = parent.List.Tags.Any(t => t.Name == "agent"); - - var drafts = await _context.Tasks - .Include(t => t.Tags) - .Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft) - .ToListAsync(ct); - - int count = 0; - foreach (var draft in drafts) - { - var childHasAgentTag = draft.Tags.Any(t => t.Name == "agent"); - var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag); - draft.Status = shouldQueue ? TaskStatus.Queued : TaskStatus.Manual; - count++; - } - - var finalizedAt = DateTime.UtcNow; - await _context.Tasks - .Where(t => t.Id == parentId) - .ExecuteUpdateAsync(s => s - .SetProperty(t => t.Status, TaskStatus.Planned) - .SetProperty(t => t.PlanningFinalizedAt, finalizedAt) - .SetProperty(t => t.PlanningSessionToken, (string?)null), ct); - - await _context.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - return count; - } - public async Task DiscardPlanningAsync( string parentId, CancellationToken ct = default) @@ -462,6 +420,7 @@ public sealed class TaskRepository .Where(t => t.Id == parentId) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Manual) + .SetProperty(t => t.PlanningPhase, PlanningPhase.None) .SetProperty(t => t.PlanningSessionId, (string?)null) .SetProperty(t => t.PlanningSessionToken, (string?)null) .SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 6090e92..7049988 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -24,6 +24,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase [ObservableProperty] private bool _dropHintAbove; [ObservableProperty] private bool _dropHintBelow; [ObservableProperty] private string? _parentTaskId; + [ObservableProperty] private string? _blockedByTaskId; [ObservableProperty] private bool _isExpanded = true; [ObservableProperty] private bool _hasPlanningChildren; [ObservableProperty] private bool _hasQueuedSubtasks; @@ -57,8 +58,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase public bool HasSteps => StepsCount > 0; public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done; public bool IsRunning => Status == TaskStatus.Running; - public bool IsQueued => Status == TaskStatus.Queued; - public bool IsWaiting => Status == TaskStatus.Waiting; + public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId); + public bool IsWaiting => (Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId)) + || Status == TaskStatus.Waiting; public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks; public bool HasSchedule => ScheduledFor.HasValue; public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail); @@ -67,14 +69,15 @@ public sealed partial class TaskRowViewModel : ViewModelBase public string DiffDeletionsText => $"−{DiffDeletions}"; public string StepsText => $"{StepsCompleted}/{StepsCount} steps"; - public string StatusChipClass => Status switch + public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch { - TaskStatus.Running => "running", - TaskStatus.Failed => "error", - TaskStatus.Done => "review", - TaskStatus.Queued => "queued", - TaskStatus.Waiting => "waiting", - _ => "idle", + (TaskStatus.Running, _) => "running", + (TaskStatus.Failed, _) => "error", + (TaskStatus.Done, _) => "review", + (TaskStatus.Queued, true) => "waiting", + (TaskStatus.Queued, false) => "queued", + (TaskStatus.Waiting, _) => "waiting", + _ => "idle", }; partial void OnStatusChanged(TaskStatus value) @@ -95,6 +98,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase partial void OnHasQueuedSubtasksChanged(bool value) => OnPropertyChanged(nameof(CanRemoveFromQueue)); + partial void OnBlockedByTaskIdChanged(string? value) + { + OnPropertyChanged(nameof(IsQueued)); + OnPropertyChanged(nameof(IsWaiting)); + OnPropertyChanged(nameof(StatusChipClass)); + } + partial void OnParentTaskIdChanged(string? value) { OnPropertyChanged(nameof(IsChild)); @@ -125,18 +135,19 @@ public sealed partial class TaskRowViewModel : ViewModelBase public void UpdateFromEntity(TaskEntity t) { var (add, del) = ParseDiffStat(t.Worktree?.DiffStat); - Title = t.Title; - ListName = t.List?.Name ?? ""; - Done = t.Status == TaskStatus.Done; - IsStarred = t.IsStarred; - IsMyDay = t.IsMyDay; - Status = t.Status; - Branch = t.Worktree?.BranchName; - DiffStat = t.Worktree?.DiffStat; - ScheduledFor = t.ScheduledFor; - DiffAdditions = add; - DiffDeletions = del; - ParentTaskId = t.ParentTaskId; + Title = t.Title; + ListName = t.List?.Name ?? ""; + Done = t.Status == TaskStatus.Done; + IsStarred = t.IsStarred; + IsMyDay = t.IsMyDay; + Status = t.Status; + Branch = t.Worktree?.BranchName; + DiffStat = t.Worktree?.DiffStat; + ScheduledFor = t.ScheduledFor; + DiffAdditions = add; + DiffDeletions = del; + ParentTaskId = t.ParentTaskId; + BlockedByTaskId = t.BlockedByTaskId; } // Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions". diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index bd26485..eb4ad2b 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -495,18 +495,27 @@ public sealed partial class TasksIslandViewModel : ViewModelBase // For a planning parent the dequeue button targets queued/waiting children, // not the parent itself (whose Status is Planning/Planned). - if (entity.Status == TaskStatus.Planning || entity.Status == TaskStatus.Planned) + if (entity.Status == TaskStatus.Planning || entity.Status == TaskStatus.Planned + || entity.PlanningPhase != PlanningPhase.None) { var children = await db.Tasks .Where(t => t.ParentTaskId == row.Id && (t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting)) .ToListAsync(); - foreach (var c in children) c.Status = TaskStatus.Manual; + foreach (var c in children) + { + c.Status = TaskStatus.Manual; + c.BlockedByTaskId = null; + } await db.SaveChangesAsync(); foreach (var c in children) { var childRow = Items.FirstOrDefault(r => r.Id == c.Id); - if (childRow is not null) childRow.Status = TaskStatus.Manual; + if (childRow is not null) + { + childRow.Status = TaskStatus.Manual; + childRow.BlockedByTaskId = null; + } } row.HasQueuedSubtasks = false; } diff --git a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj index 5f937f6..3ff1ba0 100644 --- a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +++ b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj @@ -23,4 +23,8 @@ enable + + + + diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 4e11591..c96d9d6 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -88,7 +88,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub { try { - await _planningChain.QueueSubtasksSequentiallyAsync(parentTaskId, Context.ConnectionAborted); + await _planningChain.SetupChainAsync(parentTaskId, Context.ConnectionAborted); } catch (InvalidOperationException ex) { diff --git a/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs b/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs index 873d93c..707fc0c 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs @@ -19,7 +19,13 @@ public sealed class PlanningChainCoordinator _state = state; } - public async Task QueueSubtasksSequentiallyAsync(string parentTaskId, CancellationToken ct = default) + // Sets up a sequential queue chain over a planning parent's children. + // - First child gets Status=Queued (auto-wakes the queue picker). + // - Each subsequent child gets Status=Queued + BlockedByTaskId=, + // so the picker skips them until the predecessor finishes. + // The "agent" tag is auto-attached to every child so the picker can claim them. + // Returns the number of children placed in the chain. + internal async Task SetupChainAsync(string parentTaskId, CancellationToken ct = default) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var parent = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == parentTaskId, ct) @@ -33,22 +39,38 @@ public sealed class PlanningChainCoordinator if (children.Count == 0) throw new InvalidOperationException("Parent has no subtasks."); + // Eligibility: new layout uses Status=Idle. Tolerate legacy Manual/Planned/Draft + // values during this slice — they will be migrated away in slice 6. var bad = children.FirstOrDefault(c => - c.Status != TaskStatus.Manual && c.Status != TaskStatus.Planned); + c.Status != TaskStatus.Idle && + c.Status != TaskStatus.Manual && + c.Status != TaskStatus.Planned && + c.Status != TaskStatus.Draft); if (bad is not null) throw new InvalidOperationException( - $"Child {bad.Id} is in status {bad.Status}; expected Manual or Planned."); + $"Child {bad.Id} is in status {bad.Status}; expected Idle (or legacy Manual/Planned/Draft)."); // Worker queue picker requires the "agent" tag — attach it so children are pickable. var agentTag = await ctx.Tags.FirstOrDefaultAsync(t => t.Name == "agent", ct); - for (int i = 0; i < children.Count; i++) + if (agentTag is not null) { - children[i].Status = i == 0 ? TaskStatus.Queued : TaskStatus.Waiting; - if (agentTag is not null && !children[i].Tags.Any(t => t.Id == agentTag.Id)) - children[i].Tags.Add(agentTag); + foreach (var c in children) + { + if (!c.Tags.Any(t => t.Id == agentTag.Id)) + c.Tags.Add(agentTag); + } + await ctx.SaveChangesAsync(ct); } - await ctx.SaveChangesAsync(ct); + var state = _state(); + for (int i = 0; i < children.Count; i++) + { + await state.EnqueueAsync(children[i].Id, ct); + if (i > 0) + await state.BlockOnAsync(children[i].Id, children[i - 1].Id, ct); + } + + return children.Count; } public async Task OnChildFinishedAsync( @@ -57,21 +79,18 @@ public sealed class PlanningChainCoordinator if (finalStatus != TaskStatus.Done) return null; await using var ctx = await _dbFactory.CreateDbContextAsync(ct); - var child = await ctx.Tasks + // The successor is whichever sibling explicitly blocks on this child. + // No status check — UnblockAsync flips legacy Waiting to Queued and is a no-op + // for already-Queued rows in the new layout. + var nextId = await ctx.Tasks .AsNoTracking() - .FirstOrDefaultAsync(t => t.Id == childTaskId, ct); - if (child?.ParentTaskId is null) return null; - - var next = await ctx.Tasks - .AsNoTracking() - .Where(t => t.ParentTaskId == child.ParentTaskId - && t.SortOrder > child.SortOrder - && t.Status == TaskStatus.Waiting) - .OrderBy(t => t.SortOrder) + .Where(t => t.BlockedByTaskId == childTaskId) + .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) + .Select(t => t.Id) .FirstOrDefaultAsync(ct); - if (next is null) return null; + if (nextId is null) return null; - await _state().UnblockAsync(next.Id, ct); - return next.Id; + await _state().UnblockAsync(nextId, ct); + return nextId; } } diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs index 231b9be..c9bd9bb 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.State; using ModelContextProtocol.Server; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -16,15 +17,21 @@ public sealed class PlanningMcpService private readonly TaskRepository _tasks; private readonly PlanningMcpContextAccessor _contextAccessor; private readonly HubBroadcaster _broadcaster; + private readonly ITaskStateService _state; + private readonly PlanningChainCoordinator _chain; public PlanningMcpService( TaskRepository tasks, PlanningMcpContextAccessor contextAccessor, - HubBroadcaster broadcaster) + HubBroadcaster broadcaster, + ITaskStateService state, + PlanningChainCoordinator chain) { _tasks = tasks; _contextAccessor = contextAccessor; _broadcaster = broadcaster; + _state = state; + _chain = chain; } private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct) @@ -61,9 +68,9 @@ public sealed class PlanningMcpService } private static readonly TaskStatus[] EditableStatuses = - { TaskStatus.Draft, TaskStatus.Manual, TaskStatus.Queued, TaskStatus.Waiting }; + { TaskStatus.Draft, TaskStatus.Idle, TaskStatus.Manual, TaskStatus.Queued }; - [McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Draft, Manual, Queued, Waiting.")] + [McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Draft, Idle, Manual, Queued.")] public async Task UpdateChildTask( string taskId, string? title, @@ -76,7 +83,7 @@ public sealed class PlanningMcpService var ctx = _contextAccessor.Current; var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken) ?? throw new InvalidOperationException("Planning parent task not found."); - if (parent.Status != TaskStatus.Planning) + if (parent.PlanningPhase != PlanningPhase.Active) throw new InvalidOperationException("Cannot modify tasks outside an active planning session."); var child = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -90,7 +97,7 @@ public sealed class PlanningMcpService if (!Enum.TryParse(status, ignoreCase: true, out var parsed)) throw new InvalidOperationException($"Unknown status '{status}'."); if (!EditableStatuses.Contains(parsed)) - throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Draft, Manual, Queued, Waiting."); + throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Draft, Idle, Manual, Queued."); newStatus = parsed; } @@ -111,7 +118,7 @@ public sealed class PlanningMcpService var ctx = _contextAccessor.Current; var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken) ?? throw new InvalidOperationException("Planning parent task not found."); - if (parent.Status != TaskStatus.Planning) + if (parent.PlanningPhase != PlanningPhase.Active) throw new InvalidOperationException("Cannot delete tasks outside an active planning session."); var child = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -141,11 +148,19 @@ public sealed class PlanningMcpService CancellationToken cancellationToken) { var ctx = _contextAccessor.Current; - var childIds = (await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken)) - .Select(c => c.Id).ToList(); - var count = await _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken); - foreach (var id in childIds) - await BroadcastTaskUpdatedAsync(id, cancellationToken); + + var finalizeResult = await _state.FinalizePlanningAsync(ctx.ParentTaskId, cancellationToken); + if (!finalizeResult.Ok) + throw new InvalidOperationException( + finalizeResult.Reason ?? $"Could not finalize planning for task {ctx.ParentTaskId}."); + + var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken); + int count = children.Count; + if (queueAgentTasks && children.Count > 0) + count = await _chain.SetupChainAsync(ctx.ParentTaskId, cancellationToken); + + foreach (var c in children) + await BroadcastTaskUpdatedAsync(c.Id, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); return count; } diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index 875dcb4..ae5a1d5 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -24,6 +24,7 @@ public sealed class PlanningSessionManager private readonly WorkerConfig _cfg; private readonly string _rootDirectory; private readonly ITaskStateService? _state; + private readonly PlanningChainCoordinator? _chain; // DI constructor. public PlanningSessionManager( @@ -31,12 +32,14 @@ public sealed class PlanningSessionManager GitService git, WorkerConfig cfg, ITaskStateService state, + PlanningChainCoordinator chain, string rootDirectory) { _factory = factory; _git = git; _cfg = cfg; _state = state; + _chain = chain; _rootDirectory = rootDirectory; } @@ -48,7 +51,8 @@ public sealed class PlanningSessionManager GitService git, WorkerConfig cfg, string rootDirectory, - ITaskStateService? state = null) + ITaskStateService? state = null, + PlanningChainCoordinator? chain = null) { _tasksOverride = tasks; _listsOverride = lists; @@ -56,6 +60,7 @@ public sealed class PlanningSessionManager _git = git; _cfg = cfg; _state = state; + _chain = chain; _rootDirectory = rootDirectory; } @@ -194,7 +199,21 @@ public sealed class PlanningSessionManager var (tasks, lists, settings, ctx) = CreateRepos(); await using var __ = ctx; - var count = await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct); + if (_state is null || _chain is null) + throw new InvalidOperationException( + "PlanningSessionManager.FinalizeAsync requires ITaskStateService and PlanningChainCoordinator."); + + var finalizeResult = await _state.FinalizePlanningAsync(taskId, ct); + if (!finalizeResult.Ok) + throw new InvalidOperationException( + finalizeResult.Reason ?? $"Could not finalize planning for task {taskId}."); + + int count = 0; + var children = await tasks.GetChildrenAsync(taskId, ct); + if (queueAgentTasks && children.Count > 0) + count = await _chain.SetupChainAsync(taskId, ct); + else + count = children.Count; // Best-effort cleanup — don't block finalization on git state. await TryCleanupWorktreeAsync(taskId, lists, settings, ct); diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 60e2e25..25315b3 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -83,6 +83,7 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService(), cfg, sp.GetRequiredService(), + sp.GetRequiredService(), planningSessionsDir)); builder.Services.AddSingleton(sp => new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin)); diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index dc6ca66..4f9dc7d 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -164,7 +164,8 @@ public sealed class TaskStateService : ITaskStateService .Where(t => t.Id == parentId && t.PlanningPhase == PlanningPhase.Active) .ExecuteUpdateAsync(s => s .SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized) - .SetProperty(t => t.PlanningFinalizedAt, DateTime.UtcNow), ct); + .SetProperty(t => t.PlanningFinalizedAt, DateTime.UtcNow) + .SetProperty(t => t.PlanningSessionToken, (string?)null), ct); if (affected == 0) return new TransitionResult(false, "No active planning session."); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs index d112b7a..cd9e4db 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs @@ -34,7 +34,9 @@ public sealed class PlanningHubTests : IDisposable var cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") }; var settingsRepo = new AppSettingsRepository(_ctx); settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult(); - _planning = new PlanningSessionManager(_tasks, _lists, settingsRepo, git, cfg, _rootDir); + var built = TaskStateServiceBuilder.Build(_db.CreateFactory()); + _planning = new PlanningSessionManager( + _tasks, _lists, settingsRepo, git, cfg, _rootDir, built.State, built.Chain); _launcher = new FakePlanningLauncher(); _proxy = new RecordingClientProxy(); } diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs index dc7af96..7476cc5 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs @@ -32,7 +32,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable public void Dispose() => _db.Dispose(); - private async Task SeedPlanningFamilyAsync(string parentId, int childCount) + private async Task SeedPlanningFamilyAsync(string parentId, int childCount, TaskStatus childStatus = TaskStatus.Manual) { await using var ctx = _factory.CreateDbContext(); ctx.Tasks.Add(new TaskEntity @@ -51,7 +51,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable ListId = _listId, Title = $"Child {i}", CreatedAt = DateTime.UtcNow, - Status = TaskStatus.Manual, + Status = childStatus, ParentTaskId = parentId, SortOrder = i, }); @@ -64,31 +64,67 @@ public sealed class PlanningChainCoordinatorTests : IDisposable await using var ctx = _factory.CreateDbContext(); return await ctx.Tasks .AsNoTracking() + .Include(t => t.Tags) .Where(t => t.ParentTaskId == parentId) .OrderBy(t => t.SortOrder) .ToListAsync(); } [Fact] - public async Task QueueSubtasksSequentially_SetsFirstQueued_RestWaiting() + public async Task SetupChain_FirstChildQueuedUnblocked_RestQueuedBlockedByPredecessor() { await SeedPlanningFamilyAsync("P", 3); - await _sut.QueueSubtasksSequentiallyAsync("P", default); + var count = await _sut.SetupChainAsync("P", default); + Assert.Equal(3, count); var kids = await GetChildrenAsync("P"); Assert.Equal(TaskStatus.Queued, kids[0].Status); - Assert.Equal(TaskStatus.Waiting, kids[1].Status); - Assert.Equal(TaskStatus.Waiting, kids[2].Status); + Assert.Null(kids[0].BlockedByTaskId); + Assert.Equal(TaskStatus.Queued, kids[1].Status); + Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId); + Assert.Equal(TaskStatus.Queued, kids[2].Status); + Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId); } [Fact] - public async Task OnChildDone_FlipsNextWaitingToQueued() + public async Task SetupChain_AttachesAgentTagToAllChildren() + { + await SeedPlanningFamilyAsync("P", 2); + + await _sut.SetupChainAsync("P", default); + + var kids = await GetChildrenAsync("P"); + Assert.All(kids, k => Assert.Contains(k.Tags, t => t.Name == "agent")); + } + + [Fact] + public async Task SetupChain_AcceptsIdleChildren() + { + await SeedPlanningFamilyAsync("P", 2, childStatus: TaskStatus.Idle); + + var count = await _sut.SetupChainAsync("P", default); + + Assert.Equal(2, count); + } + + [Fact] + public async Task SetupChain_AcceptsDraftChildren() + { + await SeedPlanningFamilyAsync("P", 2, childStatus: TaskStatus.Draft); + + var count = await _sut.SetupChainAsync("P", default); + + Assert.Equal(2, count); + } + + [Fact] + public async Task OnChildDone_UnblocksTheSuccessor() { await SeedPlanningFamilyAsync("P", 3); - await _sut.QueueSubtasksSequentiallyAsync("P", default); + await _sut.SetupChainAsync("P", default); - // Simulate first child finishing Done. + // Mark the head child Done before announcing. await using (var ctx = _factory.CreateDbContext()) { var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0"); @@ -101,15 +137,19 @@ public sealed class PlanningChainCoordinatorTests : IDisposable Assert.Equal("P-c1", advanced); var kids = await GetChildrenAsync("P"); Assert.Equal(TaskStatus.Done, kids[0].Status); + // c1 was Queued+BlockedBy=c0; UnblockAsync clears the block. Assert.Equal(TaskStatus.Queued, kids[1].Status); - Assert.Equal(TaskStatus.Waiting, kids[2].Status); + Assert.Null(kids[1].BlockedByTaskId); + // c2 still blocked on c1. + Assert.Equal(TaskStatus.Queued, kids[2].Status); + Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId); } [Fact] public async Task OnChildFailed_DoesNotAdvanceChain() { await SeedPlanningFamilyAsync("P", 3); - await _sut.QueueSubtasksSequentiallyAsync("P", default); + await _sut.SetupChainAsync("P", default); await using (var ctx = _factory.CreateDbContext()) { @@ -123,17 +163,17 @@ public sealed class PlanningChainCoordinatorTests : IDisposable Assert.Null(advanced); var kids = await GetChildrenAsync("P"); Assert.Equal(TaskStatus.Failed, kids[0].Status); - Assert.Equal(TaskStatus.Waiting, kids[1].Status); - Assert.Equal(TaskStatus.Waiting, kids[2].Status); + // Successors remain blocked on the failed predecessor. + Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId); + Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId); } [Fact] public async Task OnChildDone_LastChild_ReturnsNull() { await SeedPlanningFamilyAsync("P", 2); - await _sut.QueueSubtasksSequentiallyAsync("P", default); + await _sut.SetupChainAsync("P", default); - // Mark both done, simulating chain reaching the end. await using (var ctx = _factory.CreateDbContext()) { foreach (var t in ctx.Tasks.Where(t => t.ParentTaskId == "P")) @@ -147,18 +187,17 @@ public sealed class PlanningChainCoordinatorTests : IDisposable } [Fact] - public async Task QueueSubtasksSequentially_RejectsNonManualChildren() + public async Task SetupChain_RejectsRunningChildren() { await SeedPlanningFamilyAsync("P", 2); - // Corrupt one child to be already Queued. await using (var ctx = _factory.CreateDbContext()) { var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0"); - first.Status = TaskStatus.Queued; + first.Status = TaskStatus.Running; await ctx.SaveChangesAsync(); } await Assert.ThrowsAsync( - () => _sut.QueueSubtasksSequentiallyAsync("P", default)); + () => _sut.SetupChainAsync("P", default)); } } diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs index 843608f..51c5071 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; @@ -5,6 +6,7 @@ using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.SignalR; @@ -50,6 +52,11 @@ public sealed class PlanningEndToEndTests : IDisposable private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; + private readonly AppSettingsRepository _settingsRepo; + private readonly GitService _git; + private readonly WorkerConfig _cfg; + private readonly string _root; + private readonly TaskStateServiceBuilder.Built _built; private readonly PlanningSessionManager _manager; private readonly DefaultHttpContext _httpContext; private readonly PlanningMcpContextAccessor _accessor; @@ -61,17 +68,20 @@ public sealed class PlanningEndToEndTests : IDisposable _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); - var root = Path.Combine(Path.GetTempPath(), $"cd_e2e_{Guid.NewGuid():N}"); - var git = new GitService(); - var cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(root, "central") }; - var settingsRepo = new AppSettingsRepository(_ctx); - settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult(); - _manager = new PlanningSessionManager(_tasks, _lists, settingsRepo, git, cfg, root); + _root = Path.Combine(Path.GetTempPath(), $"cd_e2e_{Guid.NewGuid():N}"); + _git = new GitService(); + _cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_root, "central") }; + _settingsRepo = new AppSettingsRepository(_ctx); + _settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult(); + + _built = TaskStateServiceBuilder.Build(_db.CreateFactory()); + _manager = new PlanningSessionManager( + _tasks, _lists, _settingsRepo, _git, _cfg, _root, _built.State, _built.Chain); _httpContext = new DefaultHttpContext(); _accessor = new PlanningMcpContextAccessor(new E2EFakeHttpContextAccessor { HttpContext = _httpContext }); var broadcaster = new HubBroadcaster(new E2EFakeHubContext()); - _svc = new PlanningMcpService(_tasks, _accessor, broadcaster); + _svc = new PlanningMcpService(_tasks, _accessor, broadcaster, _built.State, _built.Chain); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } @@ -108,9 +118,69 @@ public sealed class PlanningEndToEndTests : IDisposable Assert.Equal(2, count); var reload = await _tasks.GetByIdAsync(parent.Id); - Assert.Equal(TaskStatus.Planned, reload!.Status); + Assert.Equal(PlanningPhase.Finalized, reload!.PlanningPhase); var kids = await _tasks.GetChildrenAsync(parent.Id); - Assert.All(kids, k => Assert.Equal(TaskStatus.Manual, k.Status)); + // SetupChainAsync auto-attaches agent tag and queues all children; + // the first one is unblocked, the rest are BlockedBy their predecessor. + Assert.Equal(TaskStatus.Queued, kids[0].Status); + Assert.Null(kids[0].BlockedByTaskId); + Assert.Equal(TaskStatus.Queued, kids[1].Status); + Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId); + } + + // Regression: original bug was "queue never picks up planning tasks". After Finalize + // with queueAgentTasks=true, the first child must be claimable by the queue picker + // automatically — without anyone calling WakeQueue() manually. + [Fact] + public async Task FinalizeAsync_FirstChildIsClaimedByPicker_WithinDeadline() + { + var listId = Guid.NewGuid().ToString(); + var wd = Path.Combine(Path.GetTempPath(), $"cd_e2e_wd_{Guid.NewGuid():N}"); + GitRepoFixture.InitRepoWithInitialCommit(wd); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow }); + + var parent = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "Parent", + Status = TaskStatus.Manual, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(parent); + + await _manager.StartAsync(parent.Id, CancellationToken.None); + _httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id }; + + await _svc.CreateChildTask("c1", null, null, null, CancellationToken.None); + await _svc.CreateChildTask("c2", null, null, null, CancellationToken.None); + await _svc.CreateChildTask("c3", null, null, null, CancellationToken.None); + + var kidsBefore = await _tasks.GetChildrenAsync(parent.Id); + var firstChildId = kidsBefore[0].Id; + var wakesBefore = _built.WakeCount(); + + await _manager.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None); + + // The picker should pick the first child immediately. Auto-wake fires inside + // _state.EnqueueAsync; we don't need a manual WakeQueue() for the bug to be fixed. + var picker = new QueuePicker(_db.CreateFactory()); + + TaskEntity? claimed = null; + var sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < 200) + { + claimed = await picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); + if (claimed is not null) break; + await Task.Delay(10); + } + + Assert.NotNull(claimed); + Assert.Equal(firstChildId, claimed!.Id); + Assert.Equal(TaskStatus.Running, claimed.Status); + Assert.True(_built.WakeCount() > wakesBefore, + "TaskStateService.EnqueueAsync should auto-wake the queue."); } } diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs index 78a8fe8..f9e860a 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs @@ -64,6 +64,7 @@ public sealed class PlanningMcpServiceTests : IDisposable public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private List<(string Method, object?[] Args)> _hubCalls = new(); + private TaskStateServiceBuilder.Built? _built; private PlanningMcpService BuildSut(string parentTaskId) { @@ -73,7 +74,8 @@ public sealed class PlanningMcpServiceTests : IDisposable var hub = new FakeHubContext(); _hubCalls = hub.RecordingClients.Proxy.Calls; var broadcaster = new HubBroadcaster(hub); - return new PlanningMcpService(_tasks, accessor, broadcaster); + _built = TaskStateServiceBuilder.Build(_db.CreateFactory()); + return new PlanningMcpService(_tasks, accessor, broadcaster, _built.State, _built.Chain); } private IReadOnlyList TaskUpdatedIds() => @@ -146,9 +148,12 @@ public sealed class PlanningMcpServiceTests : IDisposable { var parent = await SeedPlanningParentAsync(); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); - await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false); - + // Simulate post-finalize state directly: parent.PlanningPhase=Finalized + // is the gate the MCP service checks. var sut = BuildSut(parent.Id); + var result = await _built!.State.FinalizePlanningAsync(parent.Id, CancellationToken.None); + Assert.True(result.Ok, result.Reason); + await Assert.ThrowsAsync(() => sut.UpdateChildTask(c.Id, "new", null, null, null, null, CancellationToken.None)); } @@ -258,7 +263,7 @@ public sealed class PlanningMcpServiceTests : IDisposable Assert.Equal(2, count); var loaded = await _tasks.GetByIdAsync(parent.Id); - Assert.Equal(TaskStatus.Planned, loaded!.Status); + Assert.Equal(PlanningPhase.Finalized, loaded!.PlanningPhase); Assert.Null(loaded.PlanningSessionToken); } diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs index 5275c07..79875e1 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs @@ -20,6 +20,7 @@ public sealed class PlanningSessionManagerTests : IDisposable private readonly WorkerConfig _cfg; private readonly AppSettingsRepository _settingsRepo; private readonly PlanningSessionManager _sut; + private readonly TaskStateServiceBuilder.Built _built; public PlanningSessionManagerTests() { @@ -31,7 +32,9 @@ public sealed class PlanningSessionManagerTests : IDisposable _cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") }; _settingsRepo = new AppSettingsRepository(_ctx); _settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult(); - _sut = new PlanningSessionManager(_tasks, _lists, _settingsRepo, _git, _cfg, _rootDir); + _built = TaskStateServiceBuilder.Build(_db.CreateFactory()); + _sut = new PlanningSessionManager( + _tasks, _lists, _settingsRepo, _git, _cfg, _rootDir, _built.State, _built.Chain); } public void Dispose() @@ -173,7 +176,7 @@ public sealed class PlanningSessionManagerTests : IDisposable } [Fact] - public async Task FinalizeAsync_PromotesDraftsAndMarksPlanned() + public async Task FinalizeAsync_PromotesDraftsAndMarksPlanningFinalized() { var (listId, _) = await SeedListAsync(); var parent = await SeedManualTaskAsync(listId); @@ -185,7 +188,9 @@ public sealed class PlanningSessionManagerTests : IDisposable Assert.Equal(2, count); var loaded = await _tasks.GetByIdAsync(parent.Id); - Assert.Equal(TaskStatus.Planned, loaded!.Status); + Assert.Equal(PlanningPhase.Finalized, loaded!.PlanningPhase); + Assert.NotNull(loaded.PlanningFinalizedAt); + Assert.Null(loaded.PlanningSessionToken); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs index c1a6b9a..04e787d 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -188,65 +188,6 @@ public sealed class TaskRepositoryPlanningTests : IDisposable Assert.Null(found); } - [Fact] - public async Task FinalizePlanningAsync_TransitionsDraftsAndParent() - { - var listId = await CreateListAsync(); - var parent = MakeTask(listId, TaskStatus.Manual); - await _tasks.AddAsync(parent); - await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); - - var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, tagNames: new[] { "agent" }, commitType: null); - var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, tagNames: null, commitType: null); - - var count = await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true); - - Assert.Equal(2, count); - - var c1Loaded = await _tasks.GetByIdAsync(c1.Id); - var c2Loaded = await _tasks.GetByIdAsync(c2.Id); - var parentLoaded = await _tasks.GetByIdAsync(parent.Id); - - Assert.Equal(TaskStatus.Queued, c1Loaded!.Status); - Assert.Equal(TaskStatus.Manual, c2Loaded!.Status); - Assert.Equal(TaskStatus.Planned, parentLoaded!.Status); - Assert.NotNull(parentLoaded.PlanningFinalizedAt); - Assert.Null(parentLoaded.PlanningSessionToken); - } - - [Fact] - public async Task FinalizePlanningAsync_QueueAgentTasksFalse_AllToManual() - { - var listId = await CreateListAsync(); - var parent = MakeTask(listId, TaskStatus.Manual); - await _tasks.AddAsync(parent); - await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); - var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: new[] { "agent" }, commitType: null); - - await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false); - - var cLoaded = await _tasks.GetByIdAsync(c.Id); - Assert.Equal(TaskStatus.Manual, cLoaded!.Status); - } - - [Fact] - public async Task FinalizePlanningAsync_ParentWithAgentListTag_ChildIsQueued() - { - var listId = await CreateListAsync(); - var agentTagId = await _tags.GetOrCreateAsync("agent"); - await _lists.AddTagAsync(listId, agentTagId); - - var parent = MakeTask(listId, TaskStatus.Manual); - await _tasks.AddAsync(parent); - await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); - var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: null, commitType: null); - - await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true); - - var cLoaded = await _tasks.GetByIdAsync(c.Id); - Assert.Equal(TaskStatus.Queued, cLoaded!.Status); - } - [Fact] public async Task DiscardPlanningAsync_DeletesDraftsAndResetsParent() { diff --git a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs index 9028cad..e5a1594 100644 --- a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs @@ -36,7 +36,11 @@ public sealed class TaskStateServiceTests : IDisposable public void Dispose() => _db.Dispose(); - private async Task SeedTaskAsync(TaskStatus status, string? parentId = null, int sortOrder = 0) + private async Task SeedTaskAsync( + TaskStatus status, + string? parentId = null, + int sortOrder = 0, + string? blockedBy = null) { var id = Guid.NewGuid().ToString(); await using var ctx = _factory.CreateDbContext(); @@ -49,6 +53,7 @@ public sealed class TaskStateServiceTests : IDisposable CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, SortOrder = sortOrder, + BlockedByTaskId = blockedBy, }); await ctx.SaveChangesAsync(); return id; @@ -364,20 +369,24 @@ public sealed class TaskStateServiceTests : IDisposable // ─── Child terminal → chain advance ─────────────────────────────────── [Fact] - public async Task CompleteAsync_OnChild_AdvancesNextWaitingSibling() + public async Task CompleteAsync_OnChild_AdvancesNextBlockedSibling() { var parent = await SeedTaskAsync(TaskStatus.Planned); var c0 = await SeedTaskAsync(TaskStatus.Running, parentId: parent, sortOrder: 0); - var c1 = await SeedTaskAsync(TaskStatus.Waiting, parentId: parent, sortOrder: 1); - var c2 = await SeedTaskAsync(TaskStatus.Waiting, parentId: parent, sortOrder: 2); + var c1 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 1, blockedBy: c0); + var c2 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 2, blockedBy: c1); var result = await _sut.CompleteAsync(c0, DateTime.UtcNow, "ok", default); Assert.True(result.Ok); Assert.Equal(TaskStatus.Done, await GetStatusAsync(c0)); - // Next sibling was Waiting → chain coordinator unblocks → Queued. - Assert.Equal(TaskStatus.Queued, await GetStatusAsync(c1)); - // Subsequent sibling untouched. - Assert.Equal(TaskStatus.Waiting, await GetStatusAsync(c2)); + // c1 was BlockedBy=c0 → chain coordinator unblocks → BlockedByTaskId cleared, still Queued. + var t1 = await GetTaskAsync(c1); + Assert.Equal(TaskStatus.Queued, t1.Status); + Assert.Null(t1.BlockedByTaskId); + // c2 still blocked on c1. + var t2 = await GetTaskAsync(c2); + Assert.Equal(TaskStatus.Queued, t2.Status); + Assert.Equal(c1, t2.BlockedByTaskId); } }