From e8d018dd541f2c3585b67b3d3ba27d6e5b34cc18 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 1 Jun 2026 17:10:34 +0200 Subject: [PATCH] feat(worker): add review state transitions to TaskStateService Co-Authored-By: Claude Opus 4.7 --- .../State/Interfaces/ITaskStateService.cs | 6 + src/ClaudeDo.Worker/State/TaskStateService.cs | 85 +++++++- .../State/ReviewTransitionTests.cs | 206 ++++++++++++++++++ 3 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 tests/ClaudeDo.Worker.Tests/State/ReviewTransitionTests.cs diff --git a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs index 7ba0e05..dfcda9e 100644 --- a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs +++ b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs @@ -5,10 +5,16 @@ public interface ITaskStateService Task EnqueueAsync(string taskId, CancellationToken ct); Task StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct); Task CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct); + Task SubmitForReviewAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct); Task FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct); Task CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct); Task ResetToIdleAsync(string taskId, CancellationToken ct); + Task ApproveReviewAsync(string taskId, CancellationToken ct); + Task RejectToQueueAsync(string taskId, string feedback, CancellationToken ct); + Task RejectToIdleAsync(string taskId, CancellationToken ct); + Task ClearReviewFeedbackAsync(string taskId, CancellationToken ct); + Task ForceSetStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status, CancellationToken ct); Task StartPlanningAsync(string parentId, CancellationToken ct); diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index ea1b0fc..a8b7df5 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -90,6 +90,88 @@ public sealed class TaskStateService : ITaskStateService return new TransitionResult(true, null); } + public async Task SubmitForReviewAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var affected = await ctx.Tasks + .Where(t => t.Id == taskId && t.Status == TaskStatus.Running) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.WaitingForReview) + .SetProperty(t => t.FinishedAt, finishedAt) + .SetProperty(t => t.Result, result), ct); + + if (affected == 0) + return new TransitionResult(false, "Task not running; cannot submit for review."); + + await _broadcaster.TaskUpdated(taskId); + return new TransitionResult(true, null); + } + + public async Task ApproveReviewAsync(string taskId, CancellationToken ct) + { + await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) + { + var affected = await ctx.Tasks + .Where(t => t.Id == taskId && t.Status == TaskStatus.WaitingForReview) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Done), ct); + + if (affected == 0) + return new TransitionResult(false, "Task is not waiting for review; cannot approve."); + } + + await OnChildTerminalAsync(taskId, TaskStatus.Done); + await _broadcaster.TaskUpdated(taskId); + return new TransitionResult(true, null); + } + + public async Task RejectToQueueAsync(string taskId, string feedback, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(feedback)) + return new TransitionResult(false, "Feedback is required to reject for re-run."); + + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var affected = await ctx.Tasks + .Where(t => t.Id == taskId && t.Status == TaskStatus.WaitingForReview) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Queued) + .SetProperty(t => t.ReviewFeedback, feedback), ct); + + if (affected == 0) + return new TransitionResult(false, "Task is not waiting for review; cannot reject."); + + _waker.Wake(); + await _broadcaster.TaskUpdated(taskId); + return new TransitionResult(true, null); + } + + public async Task RejectToIdleAsync(string taskId, CancellationToken ct) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var affected = await ctx.Tasks + .Where(t => t.Id == taskId && t.Status == TaskStatus.WaitingForReview) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Idle) + .SetProperty(t => t.ReviewFeedback, (string?)null), ct); + + if (affected == 0) + return new TransitionResult(false, "Task is not waiting for review; cannot park."); + + await _broadcaster.TaskUpdated(taskId); + return new TransitionResult(true, null); + } + + public async Task ClearReviewFeedbackAsync(string taskId, CancellationToken ct) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var affected = await ctx.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.ReviewFeedback, (string?)null), ct); + + return affected == 0 + ? new TransitionResult(false, "Task not found.") + : new TransitionResult(true, null); + } + public async Task FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct) { await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) @@ -116,7 +198,8 @@ public sealed class TaskStateService : ITaskStateService { var affected = await ctx.Tasks .Where(t => t.Id == taskId && - (t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued)) + (t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued + || t.Status == TaskStatus.WaitingForReview)) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Cancelled) .SetProperty(t => t.FinishedAt, finishedAt), ct); diff --git a/tests/ClaudeDo.Worker.Tests/State/ReviewTransitionTests.cs b/tests/ClaudeDo.Worker.Tests/State/ReviewTransitionTests.cs new file mode 100644 index 0000000..86b8823 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/State/ReviewTransitionTests.cs @@ -0,0 +1,206 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.State; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.State; + +public sealed class ReviewTransitionTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly TestDbContextFactory _factory; + private readonly TaskStateServiceBuilder.Built _built; + private readonly ITaskStateService _sut; + private readonly string _listId; + + public ReviewTransitionTests() + { + _factory = _db.CreateFactory(); + _built = TaskStateServiceBuilder.Build(_factory); + _sut = _built.State; + + _listId = Guid.NewGuid().ToString(); + using var ctx = _factory.CreateDbContext(); + ctx.Lists.Add(new ListEntity + { + Id = _listId, + Name = "Test", + CreatedAt = DateTime.UtcNow, + DefaultCommitType = "chore", + }); + ctx.SaveChanges(); + } + + public void Dispose() => _db.Dispose(); + + private async Task SeedTaskAsync(TaskStatus status, string? result = null) + { + var id = Guid.NewGuid().ToString(); + await using var ctx = _factory.CreateDbContext(); + ctx.Tasks.Add(new TaskEntity + { + Id = id, + ListId = _listId, + Title = "task", + Status = status, + Result = result, + CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + return id; + } + + private async Task GetTaskAsync(string id) + { + await using var ctx = _factory.CreateDbContext(); + return await new TaskRepository(ctx).GetByIdAsync(id) + ?? throw new InvalidOperationException($"task {id} not found"); + } + + // ─── SubmitForReviewAsync ───────────────────────────────────────────── + + [Fact] + public async Task SubmitForReviewAsync_FromRunning_TransitionsToWaitingForReview() + { + var id = await SeedTaskAsync(TaskStatus.Running); + + var result = await _sut.SubmitForReviewAsync(id, DateTime.UtcNow, "the result", default); + + Assert.True(result.Ok); + var t = await GetTaskAsync(id); + Assert.Equal(TaskStatus.WaitingForReview, t.Status); + Assert.Equal("the result", t.Result); + Assert.NotNull(t.FinishedAt); + } + + [Fact] + public async Task SubmitForReviewAsync_FromQueued_Rejects() + { + var id = await SeedTaskAsync(TaskStatus.Queued); + + var result = await _sut.SubmitForReviewAsync(id, DateTime.UtcNow, "x", default); + + Assert.False(result.Ok); + Assert.Equal(TaskStatus.Queued, (await GetTaskAsync(id)).Status); + } + + // ─── ApproveReviewAsync ─────────────────────────────────────────────── + + [Fact] + public async Task ApproveReviewAsync_FromWaitingForReview_TransitionsToDone() + { + var id = await SeedTaskAsync(TaskStatus.WaitingForReview); + + var result = await _sut.ApproveReviewAsync(id, default); + + Assert.True(result.Ok); + Assert.Equal(TaskStatus.Done, (await GetTaskAsync(id)).Status); + } + + [Fact] + public async Task ApproveReviewAsync_FromIdle_Rejects() + { + var id = await SeedTaskAsync(TaskStatus.Idle); + + var result = await _sut.ApproveReviewAsync(id, default); + + Assert.False(result.Ok); + Assert.Equal(TaskStatus.Idle, (await GetTaskAsync(id)).Status); + } + + // ─── RejectToQueueAsync ─────────────────────────────────────────────── + + [Fact] + public async Task RejectToQueueAsync_StoresFeedback_AndQueues_AndWakes() + { + var id = await SeedTaskAsync(TaskStatus.WaitingForReview); + var wakesBefore = _built.WakeCount(); + + var result = await _sut.RejectToQueueAsync(id, "please fix the bug", default); + + Assert.True(result.Ok); + var t = await GetTaskAsync(id); + Assert.Equal(TaskStatus.Queued, t.Status); + Assert.Equal("please fix the bug", t.ReviewFeedback); + Assert.True(_built.WakeCount() > wakesBefore); + } + + [Fact] + public async Task RejectToQueueAsync_EmptyFeedback_Rejects() + { + var id = await SeedTaskAsync(TaskStatus.WaitingForReview); + + var result = await _sut.RejectToQueueAsync(id, " ", default); + + Assert.False(result.Ok); + Assert.Equal(TaskStatus.WaitingForReview, (await GetTaskAsync(id)).Status); + } + + [Fact] + public async Task RejectToQueueAsync_FromIdle_Rejects() + { + var id = await SeedTaskAsync(TaskStatus.Idle); + + var result = await _sut.RejectToQueueAsync(id, "feedback", default); + + Assert.False(result.Ok); + } + + // ─── RejectToIdleAsync ──────────────────────────────────────────────── + + [Fact] + public async Task RejectToIdleAsync_Parks_KeepsResult_ClearsFeedback() + { + var id = await SeedTaskAsync(TaskStatus.WaitingForReview, result: "run output"); + + var result = await _sut.RejectToIdleAsync(id, default); + + Assert.True(result.Ok); + var t = await GetTaskAsync(id); + Assert.Equal(TaskStatus.Idle, t.Status); + Assert.Equal("run output", t.Result); + Assert.Null(t.ReviewFeedback); + } + + [Fact] + public async Task RejectToIdleAsync_FromRunning_Rejects() + { + var id = await SeedTaskAsync(TaskStatus.Running); + + var result = await _sut.RejectToIdleAsync(id, default); + + Assert.False(result.Ok); + } + + // ─── ClearReviewFeedbackAsync ───────────────────────────────────────── + + [Fact] + public async Task ClearReviewFeedbackAsync_RemovesFeedback_WithoutChangingStatus() + { + var id = await SeedTaskAsync(TaskStatus.WaitingForReview); + await _sut.RejectToQueueAsync(id, "feedback", default); // sets feedback + Queued + + var result = await _sut.ClearReviewFeedbackAsync(id, default); + + Assert.True(result.Ok); + var t = await GetTaskAsync(id); + Assert.Null(t.ReviewFeedback); + Assert.Equal(TaskStatus.Queued, t.Status); + } + + // ─── CancelAsync from WaitingForReview ──────────────────────────────── + + [Fact] + public async Task CancelAsync_FromWaitingForReview_TransitionsToCancelled() + { + var id = await SeedTaskAsync(TaskStatus.WaitingForReview); + + var result = await _sut.CancelAsync(id, DateTime.UtcNow, default); + + Assert.True(result.Ok); + Assert.Equal(TaskStatus.Cancelled, (await GetTaskAsync(id)).Status); + } +}