diff --git a/src/ClaudeDo.Data/ClaudeDoDbContext.cs b/src/ClaudeDo.Data/ClaudeDoDbContext.cs index 57c61c9..591f15c 100644 --- a/src/ClaudeDo.Data/ClaudeDoDbContext.cs +++ b/src/ClaudeDo.Data/ClaudeDoDbContext.cs @@ -45,6 +45,13 @@ public class ClaudeDoDbContext : DbContext walCmd.ExecuteNonQuery(); } + // Enable FK enforcement — SQLite defaults to OFF per connection. + using (var fkCmd = conn.CreateCommand()) + { + fkCmd.CommandText = "PRAGMA foreign_keys=ON;"; + fkCmd.ExecuteNonQuery(); + } + // If the 'lists' table exists but __EFMigrationsHistory does not, // this is a pre-EF database. Baseline the InitialCreate migration. using (var cmd = conn.CreateCommand()) diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index 0c1be42..26aa747 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -14,6 +14,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration : v == TaskStatus.Running ? "running" : v == TaskStatus.Done ? "done" : v == TaskStatus.Failed ? "failed" + : v == TaskStatus.Planning ? "planning" + : v == TaskStatus.Planned ? "planned" + : v == TaskStatus.Draft ? "draft" : throw new ArgumentOutOfRangeException(nameof(v)); private static TaskStatus StatusFromString(string v) @@ -22,6 +25,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration : v == "running" ? TaskStatus.Running : v == "done" ? TaskStatus.Done : v == "failed" ? TaskStatus.Failed + : v == "planning" ? TaskStatus.Planning + : v == "planned" ? TaskStatus.Planned + : v == "draft" ? TaskStatus.Draft : throw new ArgumentOutOfRangeException(nameof(v)); private static readonly ValueConverter StatusConverter = @@ -53,6 +59,16 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.Property(t => t.Notes).HasColumnName("notes"); builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); + builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id"); + builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id"); + builder.Property(t => t.PlanningSessionToken).HasColumnName("planning_session_token"); + builder.Property(t => t.PlanningFinalizedAt).HasColumnName("planning_finalized_at"); + + builder.HasOne(t => t.Parent) + .WithMany(t => t.Children) + .HasForeignKey(t => t.ParentTaskId) + .OnDelete(DeleteBehavior.Restrict); + builder.HasOne(t => t.List) .WithMany(l => l.Tasks) .HasForeignKey(t => t.ListId) @@ -76,5 +92,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id"); builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status"); builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort"); + builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id"); } } diff --git a/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs b/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs new file mode 100644 index 0000000..ef24f78 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs @@ -0,0 +1,80 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddPlanningSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "parent_task_id", + table: "tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "planning_finalized_at", + table: "tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "planning_session_id", + table: "tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "planning_session_token", + table: "tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "idx_tasks_parent_task_id", + table: "tasks", + column: "parent_task_id"); + + migrationBuilder.AddForeignKey( + name: "FK_tasks_tasks_parent_task_id", + table: "tasks", + column: "parent_task_id", + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_tasks_tasks_parent_task_id", + table: "tasks"); + + migrationBuilder.DropIndex( + name: "idx_tasks_parent_task_id", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "parent_task_id", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "planning_finalized_at", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "planning_session_id", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "planning_session_token", + table: "tasks"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index eee421b..08926d8 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -273,6 +273,22 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("notes"); + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + b.Property("Result") .HasColumnType("TEXT") .HasColumnName("result"); @@ -310,6 +326,9 @@ namespace ClaudeDo.Data.Migrations b.HasIndex("ListId") .HasDatabaseName("idx_tasks_list_id"); + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + b.HasIndex("Status") .HasDatabaseName("idx_tasks_status"); @@ -502,7 +521,14 @@ namespace ClaudeDo.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + b.Navigation("List"); + + b.Navigation("Parent"); }); modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => @@ -566,6 +592,8 @@ namespace ClaudeDo.Data.Migrations modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => { + b.Navigation("Children"); + b.Navigation("Runs"); b.Navigation("Subtasks"); diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index 3ddb378..9b81aa6 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -7,6 +7,9 @@ public enum TaskStatus Running, Done, Failed, + Planning, + Planned, + Draft, } public sealed class TaskEntity @@ -31,10 +34,18 @@ public sealed class TaskEntity public string? Notes { get; set; } public int SortOrder { get; set; } + public string? ParentTaskId { get; set; } + public string? PlanningSessionId { get; set; } + public string? PlanningSessionToken { get; set; } + public DateTime? PlanningFinalizedAt { get; set; } + // Navigation properties public ListEntity List { get; set; } = null!; public WorktreeEntity? Worktree { get; set; } public ICollection Tags { get; set; } = new List(); public ICollection Runs { get; set; } = new List(); public ICollection Subtasks { get; set; } = new List(); + + public TaskEntity? Parent { get; set; } + public ICollection Children { get; set; } = new List(); } diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index b15ef15..d746589 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -206,6 +206,206 @@ public sealed class TaskRepository #endregion + #region Planning + + public async Task> GetChildrenAsync(string parentId, CancellationToken ct = default) + { + return await _context.Tasks + .AsNoTracking() + .Where(t => t.ParentTaskId == parentId) + .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) + .ToListAsync(ct); + } + + public async Task CreateChildAsync( + string parentId, + string title, + string? description, + IReadOnlyList? tagNames, + string? commitType, + CancellationToken ct = default) + { + var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct); + if (parent is null) + throw new InvalidOperationException($"Parent task {parentId} not found."); + + var maxSort = await _context.Tasks + .Where(t => t.ListId == parent.ListId) + .Select(t => (int?)t.SortOrder) + .MaxAsync(ct); + + var child = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = parent.ListId, + Title = title, + Description = description, + Status = TaskStatus.Draft, + CreatedAt = DateTime.UtcNow, + CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType, + ParentTaskId = parentId, + SortOrder = (maxSort ?? -1) + 1, + }; + _context.Tasks.Add(child); + + if (tagNames is not null && tagNames.Count > 0) + { + foreach (var tagName in tagNames.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == tagName, ct); + if (tag is null) + { + tag = new TagEntity { Name = tagName }; + _context.Tags.Add(tag); + await _context.SaveChangesAsync(ct); + } + child.Tags.Add(tag); + } + } + + await _context.SaveChangesAsync(ct); + return child; + } + + public async Task SetPlanningStartedAsync( + string taskId, + string sessionToken, + CancellationToken ct = default) + { + var affected = await _context.Tasks + .Where(t => t.Id == taskId && t.Status == TaskStatus.Manual) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Planning) + .SetProperty(t => t.PlanningSessionToken, sessionToken), ct); + + if (affected == 0) return null; + return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct); + } + + public async Task UpdatePlanningSessionIdAsync( + string parentId, + string sessionId, + CancellationToken ct = default) + { + await _context.Tasks + .Where(t => t.Id == parentId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.PlanningSessionId, sessionId), ct); + } + + public async Task FindByPlanningTokenAsync( + string token, + CancellationToken ct = default) + { + if (string.IsNullOrEmpty(token)) return null; + return await _context.Tasks + .AsNoTracking() + .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) + { + using var tx = await _context.Database.BeginTransactionAsync(ct); + + var parent = await _context.Tasks + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == parentId, ct); + if (parent is null || parent.Status != TaskStatus.Planning) + { + await tx.RollbackAsync(ct); + return false; + } + + await _context.Tasks + .Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft) + .ExecuteDeleteAsync(ct); + + await _context.Tasks + .Where(t => t.Id == parentId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Manual) + .SetProperty(t => t.PlanningSessionId, (string?)null) + .SetProperty(t => t.PlanningSessionToken, (string?)null) + .SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct); + + await tx.CommitAsync(ct); + return true; + } + + public async Task TryCompleteParentAsync( + string parentId, + CancellationToken ct = default) + { + var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct); + if (parent is null || parent.Status != TaskStatus.Planned) return; + + var children = await _context.Tasks + .Where(t => t.ParentTaskId == parentId) + .Select(t => t.Status) + .ToListAsync(ct); + + if (children.Count == 0) return; + + bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed); + if (!allTerminal) return; + + bool anyFailed = children.Any(s => s == TaskStatus.Failed); + var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done; + var finishedAt = DateTime.UtcNow; + await _context.Tasks + .Where(t => t.Id == parentId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, finalStatus) + .SetProperty(t => t.FinishedAt, finishedAt), ct); + } + + #endregion + #region Queue selection public async Task GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 0be91e4..240aa32 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -331,6 +331,8 @@ public sealed class TaskRunner { var taskRepo = new TaskRepository(context); await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None); + if (task.ParentTaskId is not null) + await taskRepo.TryCompleteParentAsync(task.ParentTaskId, CancellationToken.None); } await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow); await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt); @@ -346,6 +348,9 @@ public sealed class TaskRunner using var context = _dbFactory.CreateDbContext(); var taskRepo = new TaskRepository(context); await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None); + var justFailed = await taskRepo.GetByIdAsync(taskId, CancellationToken.None); + if (justFailed?.ParentTaskId is not null) + await taskRepo.TryCompleteParentAsync(justFailed.ParentTaskId, CancellationToken.None); await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow); await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt); _logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown); @@ -360,6 +365,9 @@ public sealed class TaskRunner using var context = _dbFactory.CreateDbContext(); var taskRepo = new TaskRepository(context); await taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None); + var justFailed = await taskRepo.GetByIdAsync(taskId, CancellationToken.None); + if (justFailed?.ParentTaskId is not null) + await taskRepo.TryCompleteParentAsync(justFailed.ParentTaskId, CancellationToken.None); await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow); await _broadcaster.TaskFinished(slot, taskId, "failed", now); await _broadcaster.TaskUpdated(taskId); diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs new file mode 100644 index 0000000..1c2e563 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs @@ -0,0 +1,136 @@ +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; + +public sealed class TaskRepositoryParentCompletionTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + + public TaskRepositoryParentCompletionTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private async Task ListAsync() + { + var id = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow }); + return id; + } + + private async Task PlannedParentAsync(string listId) + { + var parent = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "p", + Status = TaskStatus.Planned, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(parent); + return parent; + } + + private async Task ChildAsync(string listId, string parentId, TaskStatus status) + { + var child = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "c", + Status = status, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + ParentTaskId = parentId, + }; + await _tasks.AddAsync(child); + return child; + } + + [Fact] + public async Task TryCompleteParentAsync_AllChildrenDone_ParentBecomesDone() + { + var listId = await ListAsync(); + var parent = await PlannedParentAsync(listId); + await ChildAsync(listId, parent.Id, TaskStatus.Done); + await ChildAsync(listId, parent.Id, TaskStatus.Done); + + await _tasks.TryCompleteParentAsync(parent.Id); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Done, loaded!.Status); + Assert.NotNull(loaded.FinishedAt); + } + + [Fact] + public async Task TryCompleteParentAsync_OneFailedRestDone_ParentBecomesFailed() + { + var listId = await ListAsync(); + var parent = await PlannedParentAsync(listId); + await ChildAsync(listId, parent.Id, TaskStatus.Done); + await ChildAsync(listId, parent.Id, TaskStatus.Failed); + + await _tasks.TryCompleteParentAsync(parent.Id); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Failed, loaded!.Status); + Assert.NotNull(loaded.FinishedAt); + } + + [Fact] + public async Task TryCompleteParentAsync_OneStillRunning_ParentStaysPlanned() + { + var listId = await ListAsync(); + var parent = await PlannedParentAsync(listId); + await ChildAsync(listId, parent.Id, TaskStatus.Done); + await ChildAsync(listId, parent.Id, TaskStatus.Running); + + await _tasks.TryCompleteParentAsync(parent.Id); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planned, loaded!.Status); + Assert.Null(loaded.FinishedAt); + } + + [Fact] + public async Task TryCompleteParentAsync_ChildStillDraft_ParentStaysPlanned() + { + var listId = await ListAsync(); + var parent = await PlannedParentAsync(listId); + await ChildAsync(listId, parent.Id, TaskStatus.Done); + await ChildAsync(listId, parent.Id, TaskStatus.Draft); + + await _tasks.TryCompleteParentAsync(parent.Id); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planned, loaded!.Status); + } + + [Fact] + public async Task TryCompleteParentAsync_ParentIsNotPlanned_NoChange() + { + var listId = await ListAsync(); + var parent = await PlannedParentAsync(listId); + await _ctx.Database.ExecuteSqlRawAsync("UPDATE tasks SET status = 'planning' WHERE id = {0}", parent.Id); + await ChildAsync(listId, parent.Id, TaskStatus.Done); + + await _tasks.TryCompleteParentAsync(parent.Id); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planning, loaded!.Status); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs new file mode 100644 index 0000000..94a4e9c --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -0,0 +1,329 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Repositories; + +public sealed class TaskRepositoryPlanningTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly TagRepository _tags; + + public TaskRepositoryPlanningTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _tags = new TagRepository(_ctx); + } + + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + } + + private async Task CreateListAsync(string? id = null) + { + var listId = id ?? Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity + { + Id = listId, + Name = "Test List", + CreatedAt = DateTime.UtcNow, + }); + return listId; + } + + private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Manual, string? parentId = null) => new() + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "t", + Status = status, + CreatedAt = DateTime.UtcNow, + CommitType = "feat", + ParentTaskId = parentId, + }; + + [Fact] + public async Task GetChildrenAsync_ReturnsOnlyDirectChildren_Sorted() + { + var listId = await CreateListAsync(); + var parent = MakeTask(listId, TaskStatus.Planning); + parent.Title = "parent"; + await _tasks.AddAsync(parent); + + var childA = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id); + childA.Title = "a"; + await _tasks.AddAsync(childA); + childA.SortOrder = 1; + await _tasks.UpdateAsync(childA); + + var childB = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id); + childB.Title = "b"; + await _tasks.AddAsync(childB); + childB.SortOrder = 0; + await _tasks.UpdateAsync(childB); + + var unrelated = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(unrelated); + + var children = await _tasks.GetChildrenAsync(parent.Id); + + Assert.Equal(2, children.Count); + Assert.Equal("b", children[0].Title); + Assert.Equal("a", children[1].Title); + } + + [Fact] + public async Task CreateChildAsync_CreatesDraftUnderParent() + { + var listId = await CreateListAsync(); + var parent = MakeTask(listId, TaskStatus.Planning); + await _tasks.AddAsync(parent); + + var child = await _tasks.CreateChildAsync( + parent.Id, + title: "child title", + description: "child desc", + tagNames: new[] { "agent" }, + commitType: "feat"); + + Assert.Equal(TaskStatus.Draft, child.Status); + Assert.Equal(parent.Id, child.ParentTaskId); + Assert.Equal(listId, child.ListId); + Assert.Equal("child title", child.Title); + Assert.Equal("child desc", child.Description); + Assert.Equal("feat", child.CommitType); + + var loaded = await _tasks.GetByIdAsync(child.Id); + Assert.NotNull(loaded); + Assert.Equal(TaskStatus.Draft, loaded!.Status); + + var tags = await _tasks.GetTagsAsync(child.Id); + Assert.Contains(tags, t => t.Name == "agent"); + } + + [Fact] + public async Task CreateChildAsync_ThrowsIfParentNotFound() + { + var listId = await CreateListAsync(); + _ = listId; // just to create the DB + + await Assert.ThrowsAsync(() => + _tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null, null)); + } + + [Fact] + public async Task SetPlanningStartedAsync_ManualTask_TransitionsToPlanning() + { + var listId = await CreateListAsync(); + var task = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(task); + + var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-abc"); + + Assert.NotNull(result); + Assert.Equal(TaskStatus.Planning, result!.Status); + Assert.Equal("tok-abc", result.PlanningSessionToken); + + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Planning, loaded!.Status); + Assert.Equal("tok-abc", loaded.PlanningSessionToken); + } + + [Fact] + public async Task SetPlanningStartedAsync_NonManualTask_ReturnsNull() + { + var listId = await CreateListAsync(); + var task = MakeTask(listId, TaskStatus.Queued); + await _tasks.AddAsync(task); + + var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-xyz"); + + Assert.Null(result); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Queued, loaded!.Status); + Assert.Null(loaded.PlanningSessionToken); + } + + [Fact] + public async Task UpdatePlanningSessionIdAsync_StoresClaudeSessionId() + { + var listId = await CreateListAsync(); + var task = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(task); + await _tasks.SetPlanningStartedAsync(task.Id, "tok"); + + await _tasks.UpdatePlanningSessionIdAsync(task.Id, "claude-session-42"); + + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal("claude-session-42", loaded!.PlanningSessionId); + } + + [Fact] + public async Task FindByPlanningTokenAsync_ReturnsTask_WhenTokenMatches() + { + var listId = await CreateListAsync(); + var task = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(task); + await _tasks.SetPlanningStartedAsync(task.Id, "unique-token-123"); + + var found = await _tasks.FindByPlanningTokenAsync("unique-token-123"); + + Assert.NotNull(found); + Assert.Equal(task.Id, found!.Id); + } + + [Fact] + public async Task FindByPlanningTokenAsync_ReturnsNull_WhenTokenUnknown() + { + var found = await _tasks.FindByPlanningTokenAsync("no-such-token"); + 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() + { + var listId = await CreateListAsync(); + var parent = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(parent); + await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); + await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42"); + var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); + var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); + + var ok = await _tasks.DiscardPlanningAsync(parent.Id); + + Assert.True(ok); + Assert.Null(await _tasks.GetByIdAsync(c1.Id)); + Assert.Null(await _tasks.GetByIdAsync(c2.Id)); + + var parentLoaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Manual, parentLoaded!.Status); + Assert.Null(parentLoaded.PlanningSessionId); + Assert.Null(parentLoaded.PlanningSessionToken); + Assert.Null(parentLoaded.PlanningFinalizedAt); + } + + [Fact] + public async Task DiscardPlanningAsync_OnNonPlanningTask_ReturnsFalse() + { + var listId = await CreateListAsync(); + var task = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(task); + + var ok = await _tasks.DiscardPlanningAsync(task.Id); + + Assert.False(ok); + } + + [Fact] + public async Task DeleteAsync_ParentWithChildren_ThrowsOrDoesNotDelete() + { + var listId = await CreateListAsync(); + var parent = MakeTask(listId, TaskStatus.Planning); + await _tasks.AddAsync(parent); + await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + + // ExecuteDelete bypasses EF change tracking, so SQLite's FK enforcement + // (foreign_keys = ON, set by ClaudeDoDbContext) throws SqliteException directly. + await Assert.ThrowsAsync(async () => + { + await _tasks.DeleteAsync(parent.Id); + }); + + var stillThere = await _tasks.GetByIdAsync(parent.Id); + Assert.NotNull(stillThere); + } + + [Fact] + public async Task GetNextQueuedAgentTask_SkipsDraftPlanningPlanned() + { + var listId = await CreateListAsync(); + var agentTagId = await _tags.GetOrCreateAsync("agent"); + + async Task T(TaskStatus s, bool withTag, string? parent = null) + { + var t = MakeTask(listId, s, parentId: parent); + await _tasks.AddAsync(t); + if (withTag) await _tasks.AddTagAsync(t.Id, agentTagId); + return t; + } + + var planning = await T(TaskStatus.Planning, withTag: true); + var planned = await T(TaskStatus.Planned, withTag: true); + var draft = await T(TaskStatus.Draft, withTag: true, parent: planning.Id); + var queued = await T(TaskStatus.Queued, withTag: true); + + var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow); + + Assert.NotNull(picked); + Assert.Equal(queued.Id, picked!.Id); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs new file mode 100644 index 0000000..3a2d29d --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs @@ -0,0 +1,74 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Runner; + +public sealed class TaskRunnerParentCompletionTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + + public TaskRunnerParentCompletionTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + [Fact] + public async Task ChildMarkedDone_LastOne_ParentFinalized() + { + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + + var parent = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "p", + Status = TaskStatus.Planned, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(parent); + + var c1 = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "c1", + Status = TaskStatus.Done, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + ParentTaskId = parent.Id, + }; + var c2 = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "c2", + Status = TaskStatus.Running, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + ParentTaskId = parent.Id, + }; + await _tasks.AddAsync(c1); + await _tasks.AddAsync(c2); + + // Simulate the runner finishing the second child: + await _tasks.MarkDoneAsync(c2.Id, DateTime.UtcNow, "done"); + if (c2.ParentTaskId is not null) + await _tasks.TryCompleteParentAsync(c2.ParentTaskId); + + var parentLoaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Done, parentLoaded!.Status); + Assert.NotNull(parentLoaded.FinishedAt); + } +}