feat(worker): add review state transitions to TaskStateService

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-01 17:10:34 +02:00
parent 1ca32a6bdd
commit e8d018dd54
3 changed files with 296 additions and 1 deletions

View File

@@ -5,10 +5,16 @@ public interface ITaskStateService
Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct); Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct);
Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct); Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct);
Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct); Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
Task<TransitionResult> SubmitForReviewAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct); Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct); Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct); Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct);
Task<TransitionResult> RejectToQueueAsync(string taskId, string feedback, CancellationToken ct);
Task<TransitionResult> RejectToIdleAsync(string taskId, CancellationToken ct);
Task<TransitionResult> ClearReviewFeedbackAsync(string taskId, CancellationToken ct);
Task<TransitionResult> ForceSetStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status, CancellationToken ct); Task<TransitionResult> ForceSetStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status, CancellationToken ct);
Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct); Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);

View File

@@ -90,6 +90,88 @@ public sealed class TaskStateService : ITaskStateService
return new TransitionResult(true, null); return new TransitionResult(true, null);
} }
public async Task<TransitionResult> 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<TransitionResult> 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<TransitionResult> 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<TransitionResult> 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<TransitionResult> 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<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct) public async Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct)
{ {
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
@@ -116,7 +198,8 @@ public sealed class TaskStateService : ITaskStateService
{ {
var affected = await ctx.Tasks var affected = await ctx.Tasks
.Where(t => t.Id == taskId && .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 .ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Cancelled) .SetProperty(t => t.Status, TaskStatus.Cancelled)
.SetProperty(t => t.FinishedAt, finishedAt), ct); .SetProperty(t => t.FinishedAt, finishedAt), ct);

View File

@@ -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<string> 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<TaskEntity> 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);
}
}