fix(worker): harden review re-run, timestamps, and queue affordance
- Clear ReviewFeedback only after a successful re-run so a failed/cancelled run keeps it for a manual retry. - Clear stale StartedAt/FinishedAt when rejecting a task back to the queue. - Only non-planning standalone tasks gate on review (guard PlanningPhase). - Hide "send to queue" for WaitingForReview tasks so review isn't bypassed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -67,7 +67,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks
|
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
|
||||||
&& (!IsChild || ParentFinalized);
|
&& (!IsChild || ParentFinalized);
|
||||||
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
||||||
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using ClaudeDo.Worker.Config;
|
|||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
using ClaudeDo.Worker.State;
|
using ClaudeDo.Worker.State;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Queue;
|
namespace ClaudeDo.Worker.Queue;
|
||||||
|
|
||||||
@@ -188,17 +189,27 @@ public sealed class QueueService : BackgroundService
|
|||||||
using (var context = _dbFactory.CreateDbContext())
|
using (var context = _dbFactory.CreateDbContext())
|
||||||
sessionId = (await new TaskRunRepository(context).GetLatestByTaskIdAsync(taskId, ct))?.SessionId;
|
sessionId = (await new TaskRunRepository(context).GetLatestByTaskIdAsync(taskId, ct))?.SessionId;
|
||||||
|
|
||||||
await _state.ClearReviewFeedbackAsync(taskId, ct);
|
|
||||||
|
|
||||||
if (sessionId is not null)
|
if (sessionId is not null)
|
||||||
{
|
{
|
||||||
await _runner.ContinueAsync(taskId, feedback, "queue", ct);
|
await _runner.ContinueAsync(taskId, feedback, "queue", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
task.Description = string.IsNullOrWhiteSpace(task.Description)
|
task.Description = string.IsNullOrWhiteSpace(task.Description)
|
||||||
? $"Reviewer feedback: {feedback}"
|
? $"Reviewer feedback: {feedback}"
|
||||||
: $"{task.Description}\n\nReviewer feedback: {feedback}";
|
: $"{task.Description}\n\nReviewer feedback: {feedback}";
|
||||||
|
await _runner.RunAsync(task, "queue", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the consumed feedback only once the run reached a successful
|
||||||
|
// terminal state, so a failed or cancelled run keeps it for a manual retry.
|
||||||
|
TaskStatus statusAfter;
|
||||||
|
using (var context = _dbFactory.CreateDbContext())
|
||||||
|
statusAfter = await context.Tasks.Where(t => t.Id == taskId)
|
||||||
|
.Select(t => t.Status).FirstAsync(CancellationToken.None);
|
||||||
|
if (statusAfter is TaskStatus.WaitingForReview or TaskStatus.Done)
|
||||||
|
await _state.ClearReviewFeedbackAsync(taskId, CancellationToken.None);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _runner.RunAsync(task, "queue", ct);
|
await _runner.RunAsync(task, "queue", ct);
|
||||||
|
|||||||
@@ -324,8 +324,9 @@ public sealed class TaskRunner
|
|||||||
// after the Claude run already succeeded.
|
// after the Claude run already succeeded.
|
||||||
// Standalone tasks gate on review; planning children go straight to Done
|
// Standalone tasks gate on review; planning children go straight to Done
|
||||||
// so the sequential chain (which advances on terminal states) is unaffected.
|
// so the sequential chain (which advances on terminal states) is unaffected.
|
||||||
|
// Planning parents (PlanningPhase != None) are containers, not reviewable work.
|
||||||
var finishedAt = DateTime.UtcNow;
|
var finishedAt = DateTime.UtcNow;
|
||||||
if (task.ParentTaskId is null)
|
if (task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None)
|
||||||
{
|
{
|
||||||
await _state.SubmitForReviewAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
await _state.SubmitForReviewAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
||||||
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
|
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
.Where(t => t.Id == taskId && t.Status == TaskStatus.WaitingForReview)
|
.Where(t => t.Id == taskId && t.Status == TaskStatus.WaitingForReview)
|
||||||
.ExecuteUpdateAsync(s => s
|
.ExecuteUpdateAsync(s => s
|
||||||
.SetProperty(t => t.Status, TaskStatus.Queued)
|
.SetProperty(t => t.Status, TaskStatus.Queued)
|
||||||
.SetProperty(t => t.ReviewFeedback, feedback), ct);
|
.SetProperty(t => t.ReviewFeedback, feedback)
|
||||||
|
.SetProperty(t => t.StartedAt, (DateTime?)null)
|
||||||
|
.SetProperty(t => t.FinishedAt, (DateTime?)null), ct);
|
||||||
|
|
||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task is not waiting for review; cannot reject.");
|
return new TransitionResult(false, "Task is not waiting for review; cannot reject.");
|
||||||
|
|||||||
@@ -156,13 +156,24 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
await service.StartAsync(cts.Token);
|
await service.StartAsync(cts.Token);
|
||||||
_waker.Wake();
|
_waker.Wake();
|
||||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
await done.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
cts.Cancel();
|
|
||||||
|
|
||||||
Assert.Contains("--resume sess-1", capturedArgs);
|
Assert.Contains("--resume sess-1", capturedArgs);
|
||||||
Assert.Equal("fix the bug", capturedPrompt);
|
Assert.Equal("fix the bug", capturedPrompt);
|
||||||
|
|
||||||
var reloaded = await _taskRepo.GetByIdAsync(task.Id);
|
// Feedback is cleared after the run reaches a successful terminal state (post-run),
|
||||||
Assert.Null(reloaded!.ReviewFeedback);
|
// so poll rather than asserting on the handler-fired instant.
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
TaskEntity? reloaded;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
|
||||||
|
if (reloaded?.ReviewFeedback is null) break;
|
||||||
|
await Task.Delay(25);
|
||||||
|
} while (DateTime.UtcNow < deadline);
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
|
||||||
|
Assert.Null(reloaded.ReviewFeedback);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user