feat(mission-control): give the list handler its own review task
"Let Claude handle it" now creates one ClaudeDo task per run to host the ConPTY session (Idle/IsManual, never queued) instead of an untracked ad-hoc tile, so the run has a real title, diff, and review outcome. Since the handler merges its own changes straight into the list's working dir, the task never gets a WorktreeEntity; its review range lives as new HandlerBaseCommit/HandlerHeadCommit columns on TaskEntity instead, reusing the existing commit-range diff machinery and keeping it out of the worktrees overview entirely.
This commit is contained in:
@@ -156,7 +156,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
|
||||
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
|
||||
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
|
||||
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
|
||||
- Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`. **Every** ConPTY spec that `InteractiveLaunchSpecService` builds leads with `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. The list-handler spec (`BuildForMergeHelperAsync`) uses `--permission-mode auto` so it runs unattended; the `--allowedTools` allowlist (`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`) remains the security boundary.
|
||||
- Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`, `GetMergeHelperLaunchSpec`, `CreateMergeHelperTask` (creates the ClaudeDo task that owns a list-handler run — `Idle`/`IsManual=true`, `HandlerBaseCommit` stamped to the list repo's current HEAD via `InteractiveLaunchSpecService.CreateMergeHelperTaskAsync` — called by the UI before it opens the task-based ConPTY tile), `SubmitTaskForReview` (branches on whether the task has a `WorktreeEntity`: with one, commits it and moves on; without one, it's a worktree-less list-handler host task and it just stamps `HandlerHeadCommit` to the list repo's current HEAD — both paths then flip the task Idle/Failed → WaitingForReview). **Every** ConPTY spec that `InteractiveLaunchSpecService` builds leads with `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. The list-handler spec (`BuildForMergeHelperAsync`) uses `--permission-mode auto` so it runs unattended; the `--allowedTools` allowlist (`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`) remains the security boundary.
|
||||
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
|
||||
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
|
||||
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
|
||||
|
||||
+40
-8
@@ -502,29 +502,30 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Get the diff for a task's worktree relative to its base commit. " +
|
||||
"Get the diff for a task's worktree relative to its base commit. For a worktree-less " +
|
||||
"list-handler host task (Mission Control's \"Let Claude handle it\"), returns the fixed " +
|
||||
"HandlerBaseCommit..HandlerHeadCommit range over the list's working dir instead. " +
|
||||
"stat=false (default): returns the full unified diff, capped at 200 KB (truncated=true when larger). " +
|
||||
"stat=true: returns a --stat summary (changed files with insertion/deletion counts). " +
|
||||
"files always lists the changed file paths regardless of stat mode. " +
|
||||
"totalBytes is the uncapped diff size (useful when truncated=true). " +
|
||||
"Throws if the task has no worktree or the worktree directory is missing from disk.")]
|
||||
"Throws if the task has no worktree/review range, or the relevant directory is missing from disk.")]
|
||||
public async Task<TaskDiffDto> GetTaskDiff(
|
||||
string taskId, bool stat = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
|
||||
|
||||
if (!Directory.Exists(wt.Path))
|
||||
throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}");
|
||||
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
|
||||
|
||||
const int maxBytes = 200 * 1024;
|
||||
|
||||
if (stat)
|
||||
{
|
||||
var diffStat = await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", cancellationToken);
|
||||
var diffStat = await _git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", cancellationToken);
|
||||
return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length);
|
||||
}
|
||||
|
||||
var diff = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, cancellationToken);
|
||||
var diff = headCommit is null
|
||||
? await _git.GetBranchDiffAsync(repoPath, baseCommit, cancellationToken)
|
||||
: await _git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, cancellationToken);
|
||||
var files = ParseDiffFileNames(diff);
|
||||
|
||||
if (diff.Length <= maxBytes)
|
||||
@@ -533,6 +534,37 @@ public sealed class ExternalMcpService
|
||||
return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length);
|
||||
}
|
||||
|
||||
// Resolves where a task's diff lives: a live worktree (repo path = worktree path, diffed
|
||||
// against HEAD) or, for a worktree-less list-handler host task, the fixed
|
||||
// HandlerBaseCommit..HandlerHeadCommit range over the list's working dir (headCommit
|
||||
// non-null signals "fixed range" to the caller instead of "diff against live HEAD").
|
||||
private async Task<(string RepoPath, string BaseCommit, string? HeadCommit)> LoadDiffRangeAsync(
|
||||
string taskId, CancellationToken ct)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
|
||||
|
||||
if (wt is not null)
|
||||
{
|
||||
if (!Directory.Exists(wt.Path))
|
||||
throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}");
|
||||
return (wt.Path, wt.BaseCommit, null);
|
||||
}
|
||||
|
||||
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
|
||||
{
|
||||
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
|
||||
?? throw new InvalidOperationException("List not found.");
|
||||
if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
|
||||
throw new InvalidOperationException("The list's working directory no longer exists.");
|
||||
return (list.WorkingDir, handlerBase, handlerHead);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Merge a task's worktree branch into targetBranch (default: main). " +
|
||||
"noFf=true (default): always creates a merge commit (--no-ff). " +
|
||||
|
||||
@@ -135,6 +135,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly ISessionSkillRegistry _skillRegistry;
|
||||
private readonly IInteractiveLaunchSpecService? _interactiveLaunchSpec;
|
||||
private readonly WorktreeManager? _worktreeManager;
|
||||
private readonly Data.Git.GitService? _git;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -163,7 +164,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
ISessionSkillRegistry skillRegistry,
|
||||
LogRingBuffer? logBuffer = null,
|
||||
IInteractiveLaunchSpecService? interactiveLaunchSpec = null,
|
||||
WorktreeManager? worktreeManager = null)
|
||||
WorktreeManager? worktreeManager = null,
|
||||
Data.Git.GitService? git = null)
|
||||
{
|
||||
_queue = queue;
|
||||
_waker = waker;
|
||||
@@ -192,6 +194,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_logBuffer = logBuffer;
|
||||
_interactiveLaunchSpec = interactiveLaunchSpec;
|
||||
_worktreeManager = worktreeManager;
|
||||
_git = git;
|
||||
}
|
||||
|
||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
||||
@@ -698,6 +701,19 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
// Creates the ClaudeDo task that owns a list-handler run, before the ConPTY tile opens --
|
||||
// one task per run, never queued (Idle/IsManual). Broadcasts TaskUpdated so it shows up in
|
||||
// the list immediately.
|
||||
public Task<string> CreateMergeHelperTask(string[] taskIds, string listId, string title, string descriptionHeader) => HubGuard(async () =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
var taskId = await _interactiveLaunchSpec.CreateMergeHelperTaskAsync(
|
||||
taskIds, listId, title, descriptionHeader, Context.ConnectionAborted);
|
||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||
return taskId;
|
||||
});
|
||||
|
||||
// Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
|
||||
// the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for
|
||||
// StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
|
||||
@@ -730,17 +746,16 @@ 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.
|
||||
// Submits an interactively-worked task for review, then transitions Idle/Failed ->
|
||||
// WaitingForReview. The normal Approve flow then merges it (or, for a worktree-less host
|
||||
// task below, just flips to Done — there's nothing to merge). 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)
|
||||
var taskRepo = new TaskRepository(ctx);
|
||||
var task = await taskRepo.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.");
|
||||
@@ -748,16 +763,41 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
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))
|
||||
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, 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);
|
||||
}
|
||||
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.
|
||||
if (_git is null)
|
||||
throw new InvalidOperationException("Git service is not configured.");
|
||||
|
||||
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, Context.ConnectionAborted)
|
||||
?? 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, Context.ConnectionAborted);
|
||||
await taskRepo.SetHandlerHeadCommitAsync(taskId, headCommit, Context.ConnectionAborted);
|
||||
}
|
||||
else
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
@@ -29,6 +30,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
private readonly ISessionSkillSeeder _skillSeeder;
|
||||
private readonly ISessionSkillRegistry _skillRegistry;
|
||||
private readonly WorktreeManager _wtManager;
|
||||
private readonly GitService _git;
|
||||
private readonly string _claudePath;
|
||||
|
||||
public InteractiveLaunchSpecService(
|
||||
@@ -36,12 +38,14 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
ISessionSkillSeeder skillSeeder,
|
||||
ISessionSkillRegistry skillRegistry,
|
||||
WorktreeManager wtManager,
|
||||
GitService git,
|
||||
WorkerConfig cfg)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_skillSeeder = skillSeeder;
|
||||
_skillRegistry = skillRegistry;
|
||||
_wtManager = wtManager;
|
||||
_git = git;
|
||||
_claudePath = cfg.ClaudeBin;
|
||||
}
|
||||
|
||||
@@ -248,6 +252,57 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
||||
}
|
||||
|
||||
// Creates the ClaudeDo task that hosts a list-handler run (Mission Control's "Let Claude
|
||||
// handle it") and stamps the list repo's current HEAD as the review range's base commit.
|
||||
// The handler never gets its own worktree -- it commits straight to the list's working
|
||||
// dir -- so this HandlerBaseCommit/HandlerHeadCommit pair (see TaskEntity) is what lets the
|
||||
// normal diff/get_task_diff paths show what the run changed once it submits for review.
|
||||
// IsManual=true so the queue picker, daily prep, and the "send to queue"/"refine" UI
|
||||
// affordances all skip it, matching the "reminder only a human/ConPTY session can act on"
|
||||
// semantics IsManual already carries elsewhere; the ConPTY session itself is still allowed.
|
||||
public async Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct)
|
||||
{
|
||||
if (taskIds.Count == 0)
|
||||
throw new InvalidOperationException("No tasks selected for the list handler.");
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var listRepo = new ListRepository(ctx);
|
||||
var taskRepo = new TaskRepository(ctx);
|
||||
|
||||
var list = await listRepo.GetByIdAsync(listId, ct)
|
||||
?? throw new KeyNotFoundException($"List not found: {listId}");
|
||||
|
||||
var repoDir = list.WorkingDir;
|
||||
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
|
||||
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
|
||||
|
||||
var descriptionLines = new List<string>();
|
||||
foreach (var id in taskIds)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is not null) descriptionLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
|
||||
}
|
||||
|
||||
var baseCommit = await _git.RevParseHeadAsync(repoDir, ct);
|
||||
|
||||
var handlerTask = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = title,
|
||||
Description = descriptionLines.Count > 0
|
||||
? $"{descriptionHeader}\n{string.Join("\n", descriptionLines)}"
|
||||
: descriptionHeader,
|
||||
IsManual = true,
|
||||
HandlerBaseCommit = baseCommit,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await taskRepo.AddAsync(handlerTask, ct);
|
||||
|
||||
return handlerTask.Id;
|
||||
}
|
||||
|
||||
// The reasoning effort configured for a model in Settings → General. Falls back to the shipped
|
||||
// preset for that model, so a missing/malformed settings row can never block a launch.
|
||||
private static string EffortFor(AppSettingsEntity settings, string? model)
|
||||
|
||||
@@ -34,4 +34,12 @@ public interface IInteractiveLaunchSpecService
|
||||
/// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
|
||||
/// taskIds is empty or the list has no existing working directory.</summary>
|
||||
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct);
|
||||
|
||||
/// <summary>Creates the ClaudeDo task that hosts a list-handler run (Mission Control's
|
||||
/// "Let Claude handle it") and stamps the list repo's current HEAD as the review range's
|
||||
/// base commit (see TaskEntity.HandlerBaseCommit). Returns the new task's id. Throws
|
||||
/// KeyNotFoundException if the list doesn't exist; InvalidOperationException if taskIds
|
||||
/// is empty or the list has no existing working directory.</summary>
|
||||
Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user