feat(review): submit interactive (ConPTY) work for review

An embedded ConPTY session leaves its worktree changed but never touches
task status, so hand-driven work had no path into the review/merge flow.

Add SubmitTaskForReview: commit the worktree (same auto-commit as a headless
run), then transition Idle/Failed -> WaitingForReview via the new
TaskStateService.SubmitInteractiveForReviewAsync. Approve then merges it.

Surfaces: a 'Submit for review' button in the detail work console (shown for
an Idle/Failed task with a worktree) and on the ConPTY Command Center pane
header (task-based panes; closes the pane on success). Tests cover the new
transition (Idle/Failed accepted, Running/Queued/Done/Review rejected).
This commit is contained in:
mika kuns
2026-07-24 13:05:22 +02:00
parent 34b17537fc
commit 109a35c505
15 changed files with 178 additions and 3 deletions
+38 -1
View File
@@ -130,6 +130,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly LogRingBuffer? _logBuffer;
private readonly ISessionSkillRegistry _skillRegistry;
private readonly IInteractiveLaunchSpecService? _interactiveLaunchSpec;
private readonly WorktreeManager? _worktreeManager;
public WorkerHub(
QueueService queue,
@@ -157,7 +158,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
Runner.PendingQuestionRegistry pendingQuestions,
ISessionSkillRegistry skillRegistry,
LogRingBuffer? logBuffer = null,
IInteractiveLaunchSpecService? interactiveLaunchSpec = null)
IInteractiveLaunchSpecService? interactiveLaunchSpec = null,
WorktreeManager? worktreeManager = null)
{
_queue = queue;
_waker = waker;
@@ -185,6 +187,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_skillRegistry = skillRegistry;
_logBuffer = logBuffer;
_interactiveLaunchSpec = interactiveLaunchSpec;
_worktreeManager = worktreeManager;
}
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
@@ -705,6 +708,40 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return _interactiveLaunchSpec.BuildPlanningResume(ctx);
});
// Submits an interactively-worked task for review: commits whatever the ConPTY session left
// in the worktree (so there is a diff to merge), then transitions Idle/Failed -> WaitingForReview.
// The normal Approve flow then merges it. This is the only path that flips a hand-driven session
// into the review pipeline — a ConPTY session never touches task status on its own.
public Task SubmitTaskForReview(string taskId) => HubGuard(async () =>
{
if (_worktreeManager is null)
throw new InvalidOperationException("Worktree manager is not configured.");
await using var ctx = await _dbFactory.CreateDbContextAsync();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, Context.ConnectionAborted)
?? 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.");
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted);
if (worktree is null || 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, Context.ConnectionAborted)
?? throw new InvalidOperationException("Task list not found.");
var wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
await _worktreeManager.CommitIfChangedAsync(wtCtx, task, list, Context.ConnectionAborted);
var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted);
if (!result.Ok)
throw new InvalidOperationException(result.Reason ?? "Could not submit for review.");
});
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
{
var outcome = await _planning.DiscardAsync(taskId, dequeueQueuedChildren, Context.ConnectionAborted);
@@ -6,6 +6,7 @@ public interface ITaskStateService
Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, 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> SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, CancellationToken ct);
Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
@@ -110,6 +110,26 @@ public sealed class TaskStateService : ITaskStateService
return new TransitionResult(true, null);
}
// Submit an interactively-worked task (a ConPTY session left its worktree with commits/changes)
// for review. Unlike SubmitForReviewAsync — which only fires from the headless Running state —
// this transitions from Idle or Failed, the states an interactive task sits in after the user
// finishes the session by hand. The caller commits the worktree first so there is a diff to merge.
public async Task<TransitionResult> SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var affected = await ctx.Tasks
.Where(t => t.Id == taskId && (t.Status == TaskStatus.Idle || t.Status == TaskStatus.Failed))
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.WaitingForReview)
.SetProperty(t => t.FinishedAt, finishedAt), ct);
if (affected == 0)
return new TransitionResult(false, "Task is not Idle or Failed; cannot submit for review.");
await _broadcaster.TaskUpdated(taskId);
return new TransitionResult(true, null);
}
public async Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);