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 _dbFactory; private readonly GitService _git; private readonly ITaskStateService _state; private readonly WorktreeManager? _worktreeManager; private readonly HubBroadcaster? _broadcaster; public InteractiveReviewSubmissionService( IDbContextFactory 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); } }