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); } }