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