From 7a20534e7c1b98fc0cf8f258fc3d105b837df2ef Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 17:44:29 +0200 Subject: [PATCH 01/16] feat(data): add Planning, Planned, Draft task statuses --- src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs | 6 ++++++ src/ClaudeDo.Data/Models/TaskEntity.cs | 3 +++ 2 files changed, 9 insertions(+) diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index 0c1be42..bb8775b 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 = diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index 3ddb378..913ce7e 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 From 042a1b47c23738f7030db185e13c44d81bc284d3 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 17:44:55 +0200 Subject: [PATCH 02/16] feat(data): add planning columns and self-ref navigations to TaskEntity --- src/ClaudeDo.Data/Models/TaskEntity.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index 913ce7e..9b81aa6 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -34,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(); } From 253e6f05e0c60dcb6239d4de59a0ba10c6e57b72 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 17:45:31 +0200 Subject: [PATCH 03/16] feat(data): configure planning columns and self-ref FK with Restrict --- .../Configuration/TaskEntityConfiguration.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index bb8775b..26aa747 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -59,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) @@ -82,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"); } } From b3eb39a28b7b90e6df8542c1fa3e673e011082f8 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 17:48:10 +0200 Subject: [PATCH 04/16] feat(data): migration AddPlanningSupport --- .../20260423154708_AddPlanningSupport.cs | 80 +++++++++++++++++++ .../ClaudeDoDbContextModelSnapshot.cs | 28 +++++++ 2 files changed, 108 insertions(+) create mode 100644 src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs 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"); From b466246c1b1e617b9baf3794a3c3f7cfe8c7d88c Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 17:52:51 +0200 Subject: [PATCH 05/16] feat(data): TaskRepository.GetChildrenAsync --- .../Repositories/TaskRepository.cs | 13 +++ .../TaskRepositoryPlanningTests.cs | 83 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index b15ef15..4fe24b4 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -206,6 +206,19 @@ 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); + } + + #endregion + #region Queue selection public async Task GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs new file mode 100644 index 0000000..7773957 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -0,0 +1,83 @@ +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); + } +} From 74255ddc82e010d56b5d1ff3c850dfe3f8af1ece Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 17:54:43 +0200 Subject: [PATCH 06/16] feat(data): TaskRepository.CreateChildAsync --- .../Repositories/TaskRepository.cs | 50 +++++++++++++++++++ .../TaskRepositoryPlanningTests.cs | 39 +++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 4fe24b4..98862b8 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -217,6 +217,56 @@ public sealed class TaskRepository .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; + } + #endregion #region Queue selection diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs index 7773957..4f29c07 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -80,4 +80,43 @@ public sealed class TaskRepositoryPlanningTests : IDisposable 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)); + } } From 2278d97b7e6eb20f8274a9dbccf1eb4dbd13396e Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 17:56:19 +0200 Subject: [PATCH 07/16] feat(data): TaskRepository.SetPlanningStartedAsync --- .../Repositories/TaskRepository.cs | 15 +++++++++ .../TaskRepositoryPlanningTests.cs | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 98862b8..96fee59 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -267,6 +267,21 @@ public sealed class TaskRepository 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); + } + #endregion #region Queue selection diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs index 4f29c07..e47b09c 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -119,4 +119,37 @@ public sealed class TaskRepositoryPlanningTests : IDisposable 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); + } } From d09913848751edd526da46337a1a487a5efb205d Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 17:58:28 +0200 Subject: [PATCH 08/16] feat(data): TaskRepository.UpdatePlanningSessionIdAsync --- src/ClaudeDo.Data/Repositories/TaskRepository.cs | 11 +++++++++++ .../Repositories/TaskRepositoryPlanningTests.cs | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 96fee59..3fd2ad2 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -282,6 +282,17 @@ public sealed class TaskRepository 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); + } + #endregion #region Queue selection diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs index e47b09c..3ba9c2a 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -152,4 +152,18 @@ public sealed class TaskRepositoryPlanningTests : IDisposable 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); + } } From 2e80cc606e853526176cddb5abbda9e713a5b063 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 17:59:42 +0200 Subject: [PATCH 09/16] feat(data): TaskRepository.FindByPlanningTokenAsync --- .../Repositories/TaskRepository.cs | 10 +++++++++ .../TaskRepositoryPlanningTests.cs | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 3fd2ad2..8a52336 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -293,6 +293,16 @@ public sealed class TaskRepository .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); + } + #endregion #region Queue selection diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs index 3ba9c2a..7ead35c 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -166,4 +166,25 @@ public sealed class TaskRepositoryPlanningTests : IDisposable 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); + } } From a9e7479326c0e3e8d09aa8137b93226bc91818ba Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:03:10 +0200 Subject: [PATCH 10/16] feat(data): TaskRepository.FinalizePlanningAsync --- .../Repositories/TaskRepository.cs | 43 ++++++++++++++ .../TaskRepositoryPlanningTests.cs | 59 +++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 8a52336..a3709a0 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -303,6 +303,49 @@ 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; + } + #endregion #region Queue selection diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs index 7ead35c..612015b 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -187,4 +187,63 @@ public sealed class TaskRepositoryPlanningTests : IDisposable 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); + } } From 524aaf85afb953a1d95aef2e85edbc6fa5642187 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:04:40 +0200 Subject: [PATCH 11/16] feat(data): TaskRepository.DiscardPlanningAsync --- .../Repositories/TaskRepository.cs | 31 ++++++++++++++++ .../TaskRepositoryPlanningTests.cs | 36 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index a3709a0..8796e39 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -346,6 +346,37 @@ public sealed class TaskRepository 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; + } + #endregion #region Queue selection diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs index 612015b..648b97a 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -246,4 +246,40 @@ public sealed class TaskRepositoryPlanningTests : IDisposable 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); + } } From b7464c9a11d65b344e1a51d39c008ea1b7918c1f Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:08:14 +0200 Subject: [PATCH 12/16] feat(data): TaskRepository.TryCompleteParentAsync --- .../Repositories/TaskRepository.cs | 27 ++++ .../TaskRepositoryParentCompletionTests.cs | 136 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 8796e39..d746589 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -377,6 +377,33 @@ public sealed class TaskRepository 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 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); + } +} From 19bf032a2ed1ad22f4419aba892011639e43d0d0 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:09:29 +0200 Subject: [PATCH 13/16] test(data): queue skips Planning/Planned/Draft --- .../TaskRepositoryPlanningTests.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs index 648b97a..c95fe8e 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -282,4 +282,29 @@ public sealed class TaskRepositoryPlanningTests : IDisposable Assert.False(ok); } + + [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); + } } From 782110604b82fac9583490f4fd0201aa9e357a33 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:15:06 +0200 Subject: [PATCH 14/16] fix(data): enable foreign_keys pragma in MigrateAndConfigure --- src/ClaudeDo.Data/ClaudeDoDbContext.cs | 7 +++++++ 1 file changed, 7 insertions(+) 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()) From f704244b84f2c9319042c28b5a5ff9e7e2bc0083 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:15:12 +0200 Subject: [PATCH 15/16] test(data): parent delete with children is restricted --- .../TaskRepositoryPlanningTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs index c95fe8e..94a4e9c 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -283,6 +283,25 @@ public sealed class TaskRepositoryPlanningTests : IDisposable 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() { From d4a46420c975778bad136979f9dd8842f51c817f Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:18:50 +0200 Subject: [PATCH 16/16] feat(worker): hook TryCompleteParentAsync after MarkDone/MarkFailed Co-Authored-By: Claude Sonnet 4.6 --- src/ClaudeDo.Worker/Runner/TaskRunner.cs | 8 ++ .../Runner/TaskRunnerParentCompletionTests.cs | 74 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs 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/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); + } +}