104 lines
5.4 KiB
C#
104 lines
5.4 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.State;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Lifecycle;
|
|
|
|
// Submits an interactively-worked task -- one whose worktree/working-dir commits were made by a
|
|
// human-in-the-loop ConPTY session rather than an autonomous run -- into the review pipeline.
|
|
// Shared by WorkerHub.SubmitTaskForReview (a UI ConPTY session) and the external
|
|
// submit_task_for_review MCP tool (a list handler submitting its OWN handler task): both must
|
|
// stamp/commit identically before TaskStateService.SubmitInteractiveForReviewAsync flips the status.
|
|
public sealed class InteractiveReviewSubmissionService
|
|
{
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly GitService _git;
|
|
private readonly ITaskStateService _state;
|
|
private readonly WorktreeManager? _worktreeManager;
|
|
private readonly HubBroadcaster? _broadcaster;
|
|
|
|
public InteractiveReviewSubmissionService(
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
GitService git,
|
|
ITaskStateService state,
|
|
WorktreeManager? worktreeManager = null,
|
|
HubBroadcaster? broadcaster = null)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_git = git;
|
|
_state = state;
|
|
_worktreeManager = worktreeManager;
|
|
_broadcaster = broadcaster;
|
|
}
|
|
|
|
public async Task SubmitAsync(string taskId, CancellationToken ct)
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
|
var taskRepo = new TaskRepository(ctx);
|
|
var task = await taskRepo.GetByIdAsync(taskId, ct)
|
|
?? throw new KeyNotFoundException();
|
|
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
|
|
throw new InvalidOperationException("Can't submit a running or queued task — interrupt it first.");
|
|
if (task.Status is TaskStatus.WaitingForReview or TaskStatus.WaitingForChildren)
|
|
throw new InvalidOperationException("Task is already awaiting review.");
|
|
// Check the status gate up front, before either mutation branch, so a Done or Cancelled
|
|
// task can't get committed / have its HandlerHeadCommit stamped and then be rejected,
|
|
// stranding the work on an orphaned branch.
|
|
if (task.Status is not (TaskStatus.Idle or TaskStatus.Failed))
|
|
throw new InvalidOperationException("Task must be Idle or Failed to submit for review.");
|
|
|
|
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
|
|
if (worktree is not null)
|
|
{
|
|
if (_worktreeManager is null)
|
|
throw new InvalidOperationException("Worktree manager is not configured.");
|
|
if (worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
|
|
throw new InvalidOperationException("This task has no active worktree to submit.");
|
|
if (!Directory.Exists(worktree.Path))
|
|
throw new InvalidOperationException("The task's worktree directory no longer exists.");
|
|
|
|
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
|
|
?? throw new InvalidOperationException("Task list not found.");
|
|
|
|
var wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
|
|
await _worktreeManager.CommitIfChangedAsync(wtCtx, task, list, ct);
|
|
}
|
|
else if (task.HandlerBaseCommit is { Length: > 0 })
|
|
{
|
|
// Worktree-less "list handler" host task (Mission Control's "Let Claude handle it"):
|
|
// the handler commits its own changes straight to the list's working dir, so there
|
|
// is nothing for us to commit here — just stamp the review range's head commit.
|
|
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
|
|
?? throw new InvalidOperationException("Task list not found.");
|
|
if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
|
|
throw new InvalidOperationException("The list's working directory no longer exists.");
|
|
|
|
var headCommit = await _git.RevParseHeadAsync(list.WorkingDir, ct);
|
|
await taskRepo.SetHandlerHeadCommitAsync(taskId, headCommit, ct);
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException("This task has no active worktree to submit.");
|
|
}
|
|
|
|
var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, ct);
|
|
if (!result.Ok)
|
|
throw new InvalidOperationException(result.Reason ?? "Could not submit for review.");
|
|
|
|
// The transition itself only broadcasts TaskUpdated, and the detail pane answers that by
|
|
// refreshing the task row alone -- its whole REVIEW/MERGE block (Open diff, branch picker,
|
|
// change summary) is gated on the worktree it last read, refreshed only on WorktreeUpdated.
|
|
// An interactive session creates its worktree at launch and gets it committed HERE, both
|
|
// outside the autonomous pipeline that would normally announce it, so without this the task
|
|
// arrives in WaitingForReview with the diff button missing until it is re-selected.
|
|
if (_broadcaster is not null)
|
|
await _broadcaster.WorktreeUpdated(taskId);
|
|
}
|
|
}
|