list_tasks on a list of ~100 verbosely-described tasks could return 390k+ characters in one call, blowing past the caller's token limit. Both tools now default to lean TaskRefDto references (no Description/Result) and take an includeDescription flag to opt back into the full TaskDto payload — same flag-alongside-nullable-payload idiom already used by BatchGetTaskResult/TaskConfigResult. get_task is unchanged.
1202 lines
59 KiB
C#
1202 lines
59 KiB
C#
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Queue;
|
|
using ClaudeDo.Worker.State;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ModelContextProtocol.Server;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.External;
|
|
|
|
public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
|
|
public sealed record DeleteTaskResult(bool Deleted, string Id);
|
|
public sealed record CancelTaskResult(bool Cancelled, string Id);
|
|
public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null);
|
|
public sealed record StatusValueDto(string Status, string Meaning);
|
|
public sealed record RunTaskNowResult(bool Started, string TaskId);
|
|
|
|
public sealed record TaskDto(
|
|
string Id,
|
|
string ListId,
|
|
string Title,
|
|
string? Description,
|
|
string Status,
|
|
string? Result,
|
|
string? CreatedBy,
|
|
DateTime CreatedAt,
|
|
DateTime? StartedAt,
|
|
DateTime? FinishedAt,
|
|
bool IsMyDay,
|
|
int SortOrder);
|
|
|
|
// Lean counterpart to TaskDto for writing/status-changing tools: echoes back what changed
|
|
// without re-sending Description/Result, which the caller just sent or already has.
|
|
public sealed record TaskRefDto(
|
|
string Id,
|
|
string ListId,
|
|
string Title,
|
|
string Status,
|
|
int SortOrder,
|
|
bool IsMyDay);
|
|
|
|
// tasks is populated when includeDescription=false (the default): lean references, no
|
|
// Description/Result. tasksFull is populated when includeDescription=true: full tasks incl.
|
|
// Description/Result. Exactly one of the two is non-null per the includeDescription flag —
|
|
// same "flag alongside nullable payload" idiom as BatchGetTaskResult.
|
|
public sealed record ListTasksResult(
|
|
bool IncludeDescription,
|
|
IReadOnlyList<TaskRefDto>? Tasks,
|
|
IReadOnlyList<TaskDto>? TasksFull);
|
|
|
|
public sealed record WorktreeInfoDto(
|
|
string Path, string Branch, string HeadCommit, string BaseCommit,
|
|
int Ahead, int Behind, bool IsDirty, string? MergeCommit = null);
|
|
|
|
public sealed record TaskDiffDto(
|
|
string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes);
|
|
|
|
public sealed record MergeTaskResultDto(
|
|
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts,
|
|
bool ConflictsInTree = false, string? RepoPath = null);
|
|
|
|
public sealed record MergeContinuationResultDto(
|
|
bool Merged, string TaskStatus, IReadOnlyList<string> Conflicts,
|
|
string? RepoPath, string? Message);
|
|
|
|
public sealed record MergePreviewToolDto(
|
|
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind);
|
|
|
|
public sealed record MergePreviewSetEntryDto(
|
|
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error);
|
|
|
|
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds);
|
|
|
|
public sealed record MergePreviewSetResultDto(
|
|
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps);
|
|
|
|
public sealed record WorktreeListItemDto(
|
|
string? TaskId, string Path, string Branch,
|
|
string HeadCommit, bool IsDirty, bool MergedIntoMain);
|
|
|
|
public sealed record CleanupWorktreeResult(
|
|
bool Removed, string WorktreePath, bool BranchDeleted);
|
|
|
|
public sealed record RevertMergeResultDto(
|
|
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
|
|
|
|
public sealed record DailyPrepCandidateDto(
|
|
string Id, string ListId, string ListName, string Title, string? Description,
|
|
bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);
|
|
|
|
public sealed record DailyPrepDataDto(
|
|
int MaxTasks,
|
|
IReadOnlyList<DailyPrepCandidateDto> Candidates,
|
|
IReadOnlyList<DailyPrepCandidateDto> CurrentMyDay);
|
|
|
|
[McpServerToolType]
|
|
public sealed class ExternalMcpService
|
|
{
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly QueueService _queue;
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly ITaskStateService _state;
|
|
private readonly GitService _git;
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly WorktreeMaintenanceService _maintenance;
|
|
private readonly TaskMergeService _merge;
|
|
private readonly PlanningMergeOrchestrator _planningMerge;
|
|
|
|
public ExternalMcpService(
|
|
TaskRepository tasks,
|
|
ListRepository lists,
|
|
QueueService queue,
|
|
HubBroadcaster broadcaster,
|
|
ITaskStateService state,
|
|
GitService git,
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
WorktreeMaintenanceService maintenance,
|
|
TaskMergeService merge,
|
|
PlanningMergeOrchestrator planningMerge)
|
|
{
|
|
_tasks = tasks;
|
|
_lists = lists;
|
|
_queue = queue;
|
|
_broadcaster = broadcaster;
|
|
_state = state;
|
|
_git = git;
|
|
_dbFactory = dbFactory;
|
|
_maintenance = maintenance;
|
|
_merge = merge;
|
|
_planningMerge = planningMerge;
|
|
}
|
|
|
|
[McpServerTool, Description("List all task lists available in ClaudeDo.")]
|
|
public async Task<IReadOnlyList<TaskListDto>> ListTaskLists(CancellationToken cancellationToken)
|
|
{
|
|
var lists = await _lists.GetAllAsync(cancellationToken);
|
|
return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList();
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
|
|
"Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled. " +
|
|
"includeDescription=false (default): returns lean task references in `tasks` (no Description/Result) — " +
|
|
"use this unless you actually need the description text, since a list of verbosely-described tasks can " +
|
|
"otherwise blow past the response size limit. " +
|
|
"includeDescription=true: returns full tasks (incl. Description/Result) in `tasksFull` instead; `tasks` is " +
|
|
"null in that case.")]
|
|
public async Task<ListTasksResult> ListTasks(
|
|
string listId,
|
|
string? createdBy = null,
|
|
string? status = null,
|
|
bool includeDescription = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
TaskStatus? statusFilter = null;
|
|
if (!string.IsNullOrWhiteSpace(status))
|
|
{
|
|
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
|
throw new InvalidOperationException(
|
|
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.");
|
|
statusFilter = parsed;
|
|
}
|
|
|
|
var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken);
|
|
IEnumerable<TaskEntity> query = tasks;
|
|
if (createdBy is not null)
|
|
query = query.Where(t => t.CreatedBy == createdBy);
|
|
if (statusFilter is not null)
|
|
query = query.Where(t => t.Status == statusFilter);
|
|
|
|
var filtered = query.ToList();
|
|
return includeDescription
|
|
? new ListTasksResult(true, null, filtered.Select(ToDto).ToList())
|
|
: new ListTasksResult(false, filtered.Select(ToRefDto).ToList(), null);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Get a single task by id, including its current status and result. " +
|
|
"Status lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
|
|
"A successful run lands in WaitingForReview; use review_task to approve, reject, or cancel. " +
|
|
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")]
|
|
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
return ToDto(task);
|
|
}
|
|
|
|
// Lean counterpart to GetTask, used internally by BatchGetTasks' default (includeDescription=false)
|
|
// path. Not an MCP tool itself — GetTask's own behavior stays untouched.
|
|
internal async Task<TaskRefDto> GetTaskRefAsync(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
return ToRefDto(task);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. " +
|
|
"Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " +
|
|
"'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
|
|
"Leave model null to inherit the list/global default. " +
|
|
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay) — not the description you just sent.")]
|
|
public async Task<TaskRefDto> AddTask(
|
|
string listId,
|
|
string title,
|
|
string? description = null,
|
|
string? createdBy = null,
|
|
bool queueImmediately = false,
|
|
string? model = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(listId))
|
|
throw new InvalidOperationException("listId is required.");
|
|
if (string.IsNullOrWhiteSpace(title))
|
|
throw new InvalidOperationException("title is required.");
|
|
|
|
var list = await _lists.GetByIdAsync(listId, cancellationToken)
|
|
?? throw new InvalidOperationException($"List {listId} not found.");
|
|
|
|
var entity = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = title,
|
|
Description = description,
|
|
Status = TaskStatus.Idle,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = list.DefaultCommitType,
|
|
CreatedBy = createdBy.NullIfBlank() ?? "mcp",
|
|
Model = ModelRegistry.NormalizeAlias(model),
|
|
// A manual list holds reminders, so anything filed there starts out manual.
|
|
IsManual = list.IsManual,
|
|
};
|
|
await _tasks.AddAsync(entity, cancellationToken);
|
|
|
|
if (queueImmediately)
|
|
{
|
|
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
|
|
if (!enqueue.Ok)
|
|
throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
|
|
entity.Status = TaskStatus.Queued;
|
|
}
|
|
|
|
await _broadcaster.TaskUpdated(entity.Id);
|
|
return ToRefDto(entity);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. " +
|
|
"Refuses if the task is currently Running. Returns a lean task reference (id, listId, title, status, " +
|
|
"sortOrder, isMyDay) — not the description you just sent.")]
|
|
public async Task<TaskRefDto> UpdateTask(
|
|
string taskId,
|
|
string? title = null,
|
|
string? description = null,
|
|
string? commitType = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
if (task.Status == TaskStatus.Running)
|
|
throw new InvalidOperationException("Cannot update a running task. Cancel it first.");
|
|
|
|
if (title is not null) task.Title = title;
|
|
if (description is not null) task.Description = description;
|
|
if (commitType is not null) task.CommitType = commitType;
|
|
await _tasks.UpdateAsync(task, cancellationToken);
|
|
|
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return ToRefDto(reload);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Append a subtask (step) to a task. orderNum defaults to the end. " +
|
|
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list. " +
|
|
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
|
|
public async Task<TaskRefDto> AddSubtask(
|
|
string taskId,
|
|
string title,
|
|
int? orderNum = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(title))
|
|
throw new InvalidOperationException("title is required.");
|
|
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
|
var tasks = new TaskRepository(ctx);
|
|
var subtasks = new SubtaskRepository(ctx);
|
|
|
|
var task = await tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
if (task.Status == TaskStatus.Running)
|
|
throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");
|
|
|
|
var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
|
|
var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);
|
|
|
|
await subtasks.AddAsync(new SubtaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
TaskId = taskId,
|
|
Title = title.Trim(),
|
|
Completed = false,
|
|
OrderNum = order,
|
|
CreatedAt = DateTime.UtcNow,
|
|
}, cancellationToken);
|
|
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return ToRefDto(task);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Update a task's status. Only 'Idle', 'Queued', 'Cancelled' and 'Done' are permitted externally — " +
|
|
"use run_task_now for execution control, and review_task to act on a WaitingForReview task. " +
|
|
"Settable: Idle (reset to editable), Queued (enqueue for execution), " +
|
|
"Cancelled (retire the task without deleting it; it can be reset to Idle later), " +
|
|
"Done (mark complete; refused if the task has an active worktree — use review_task to approve " +
|
|
"and merge that worktree instead). " +
|
|
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
|
|
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
|
|
public async Task<TaskRefDto> UpdateTaskStatus(
|
|
string taskId,
|
|
string status,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var target))
|
|
throw new InvalidOperationException(
|
|
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled.");
|
|
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
switch (target)
|
|
{
|
|
case TaskStatus.Idle:
|
|
await _tasks.ResetToManualAsync(taskId, cancellationToken);
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
break;
|
|
|
|
case TaskStatus.Queued:
|
|
var enqueueResult = await _state.EnqueueAsync(taskId, cancellationToken);
|
|
if (!enqueueResult.Ok)
|
|
throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task.");
|
|
break;
|
|
|
|
case TaskStatus.Cancelled:
|
|
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken, allowFromIdle: true);
|
|
if (!cancelResult.Ok)
|
|
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
|
|
break;
|
|
|
|
case TaskStatus.Done:
|
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
|
|
{
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken);
|
|
if (wt is not null && wt.State == WorktreeState.Active)
|
|
throw new InvalidOperationException(
|
|
"Task has an active worktree — use review_task to approve and merge instead.");
|
|
}
|
|
|
|
var doneResult = await _state.ForceSetStatusAsync(taskId, TaskStatus.Done, cancellationToken);
|
|
if (!doneResult.Ok)
|
|
throw new InvalidOperationException(doneResult.Reason ?? "Cannot set task to Done.");
|
|
break;
|
|
|
|
default:
|
|
throw new InvalidOperationException(
|
|
$"Status '{target}' is not settable externally. Use run_task_now or review_task.");
|
|
}
|
|
|
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
|
return ToRefDto(reload);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Review a task that is WaitingForReview. " +
|
|
"decision='approve' → review+merge, exactly like the UI's Approve: a childless task merges its worktree into " +
|
|
"targetBranch (default: the repo's current branch) then goes Done; a task with children drives the unit merge " +
|
|
"(parent worktree if active + each Done child in order); a task without an active worktree approves straight to Done. " +
|
|
"mergeStatus 'conflict' means the merge stopped on conflicts (files listed) — by default the merge is cleanly " +
|
|
"aborted and you resolve in the ClaudeDo UI; pass leaveConflictsInTree=true to instead leave the conflict " +
|
|
"markers in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " +
|
|
"or abort_merge to cancel. " +
|
|
"decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
|
|
"decision='reject_park' → Idle for manual editing (feedback ignored). " +
|
|
"decision='cancel' → Cancelled. " +
|
|
"Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued). " +
|
|
"The result's task field is a lean reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
|
|
public async Task<ReviewTaskResult> ReviewTask(
|
|
string taskId,
|
|
string decision,
|
|
string? feedback = null,
|
|
string? targetBranch = null,
|
|
bool leaveConflictsInTree = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
string? mergeStatus = null;
|
|
IReadOnlyList<string> mergeConflicts = Array.Empty<string>();
|
|
string? mergeMessage = null;
|
|
string? repoPath = null;
|
|
|
|
if (decision.Trim().ToLowerInvariant() == "approve")
|
|
{
|
|
// Approve is the single review+merge action — mirror the hub's ApproveReview
|
|
// routing instead of only flipping the status (which left branches unmerged).
|
|
bool hasChildren;
|
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
|
|
hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, cancellationToken);
|
|
|
|
if (hasChildren)
|
|
{
|
|
await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken);
|
|
var parentDone = (await _tasks.GetByIdAsync(taskId, cancellationToken))!.Status == TaskStatus.Done;
|
|
mergeStatus = parentDone ? TaskMergeService.StatusMerged : TaskMergeService.StatusConflict;
|
|
if (!parentDone)
|
|
{
|
|
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
|
repoPath = list?.WorkingDir;
|
|
mergeMessage = "unit merge paused on a conflict — markers left in the working tree; " +
|
|
"resolve them then call continue_merge with the parent task id, or abort_merge to cancel";
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, cancellationToken);
|
|
if (r.Status == TaskMergeService.StatusBlocked)
|
|
throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
|
|
mergeStatus = r.Status;
|
|
mergeConflicts = r.ConflictFiles;
|
|
if (r.Status == TaskMergeService.StatusConflict)
|
|
{
|
|
if (leaveConflictsInTree)
|
|
{
|
|
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
|
repoPath = list?.WorkingDir;
|
|
mergeMessage = "merge conflict — markers left in the working tree; " +
|
|
"resolve them then call continue_merge, or abort_merge to cancel";
|
|
}
|
|
else
|
|
{
|
|
mergeMessage = "merge conflict — the task stays WaitingForReview; resolve it in the ClaudeDo UI";
|
|
}
|
|
}
|
|
else
|
|
{
|
|
mergeMessage = r.ErrorMessage;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
TransitionResult result = decision.Trim().ToLowerInvariant() switch
|
|
{
|
|
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
|
|
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
|
|
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
|
|
_ => throw new InvalidOperationException(
|
|
$"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
|
|
};
|
|
|
|
if (!result.Ok)
|
|
throw new InvalidOperationException(result.Reason ?? "Review action failed.");
|
|
}
|
|
|
|
return new ReviewTaskResult(
|
|
ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!),
|
|
mergeStatus, mergeConflicts, mergeMessage, repoPath);
|
|
}
|
|
|
|
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue). Returns { started: true, taskId } on success.")]
|
|
public async Task<RunTaskNowResult> RunTaskNow(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await _queue.RunNow(taskId);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
throw new InvalidOperationException("Override slot busy. Try again later.");
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
throw new InvalidOperationException($"Task {taskId} not found.");
|
|
}
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return new RunTaskNowResult(true, taskId);
|
|
}
|
|
|
|
[McpServerTool, Description("Cancel a running task. Returns { cancelled: true, id } if the task was running and cancellation was requested; cancelled is false if the task was not running.")]
|
|
public async Task<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
var cancelled = _queue.CancelTask(taskId);
|
|
if (cancelled) await _broadcaster.TaskUpdated(taskId);
|
|
return new CancelTaskResult(cancelled, taskId);
|
|
}
|
|
|
|
[McpServerTool, Description("Delete a task. Returns { deleted: true, id } on success. Throws if the task is not found or is currently Running — cancel it first.")]
|
|
public async Task<DeleteTaskResult> DeleteTask(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
if (task.Status == TaskStatus.Running)
|
|
throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
|
|
|
|
await _tasks.DeleteAsync(taskId, cancellationToken);
|
|
if (task.ParentTaskId is not null)
|
|
await _state.TryAdvanceParentAsync(task.ParentTaskId);
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return new DeleteTaskResult(true, taskId);
|
|
}
|
|
|
|
// ── Status reference ─────────────────────────────────────────────────────
|
|
|
|
[McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")]
|
|
public Task<IReadOnlyList<StatusValueDto>> GetTaskStatusValues() =>
|
|
Task.FromResult<IReadOnlyList<StatusValueDto>>([
|
|
new("Idle", "Not yet queued; task is editable and will not run until enqueued."),
|
|
new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
|
|
new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
|
|
new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."),
|
|
new("WaitingForChildren", "Planning parent whose child tasks are still running. The parent resumes once all children reach a terminal state."),
|
|
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
|
|
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
|
|
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
|
|
]);
|
|
|
|
// ── Worktree / git tools ──────────────────────────────────────────────────
|
|
|
|
[McpServerTool, Description(
|
|
"Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " +
|
|
"baseCommit (SHA where the branch was created), ahead (commits on branch since base), " +
|
|
"behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " +
|
|
"isDirty (has uncommitted changes in the worktree directory), " +
|
|
"mergeCommit (SHA of the merge commit this worktree's branch produced on the target branch, " +
|
|
"if it has been merged and that succeeded after this field was introduced; null otherwise — " +
|
|
"required by revert_merge). " +
|
|
"Throws if the task or its worktree does not exist.")]
|
|
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
|
|
|
|
var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit)
|
|
? wt.HeadCommit
|
|
: await TryRunGitAsync(wt.Path, ["rev-parse", "HEAD"], cancellationToken) ?? wt.BaseCommit;
|
|
|
|
var isDirty = Directory.Exists(wt.Path) && await _git.HasChangesAsync(wt.Path, cancellationToken);
|
|
var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken);
|
|
var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken);
|
|
|
|
return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty, wt.MergeCommit);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"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/review range, or the relevant directory is missing from disk.")]
|
|
public async Task<TaskDiffDto> GetTaskDiff(
|
|
string taskId, bool stat = false, CancellationToken cancellationToken = default)
|
|
{
|
|
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
|
|
|
|
const int maxBytes = 200 * 1024;
|
|
|
|
if (stat)
|
|
{
|
|
var diffStat = await _git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", cancellationToken);
|
|
return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length);
|
|
}
|
|
|
|
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)
|
|
return new TaskDiffDto(diff, files, false, diff.Length);
|
|
|
|
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). " +
|
|
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
|
|
"allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " +
|
|
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
|
|
"On conflict: by default the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files. " +
|
|
"leaveConflictsInTree=true: on conflict the merge is NOT aborted — conflict markers are left in the working " +
|
|
"tree at repoPath (conflictsInTree=true in the result) so you can resolve them there and call continue_merge, " +
|
|
"or abort_merge to cancel.")]
|
|
public async Task<MergeTaskResultDto> MergeTask(
|
|
string taskId,
|
|
string targetBranch = "main",
|
|
bool noFf = true,
|
|
bool dryRun = false,
|
|
bool allowWaitingForReview = false,
|
|
bool leaveConflictsInTree = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
var canMerge = task.Status == TaskStatus.Done ||
|
|
(allowWaitingForReview && task.Status == TaskStatus.WaitingForReview);
|
|
if (!canMerge)
|
|
throw new InvalidOperationException(
|
|
$"Task must be Done to merge (current status: {task.Status}). " +
|
|
"Pass allowWaitingForReview=true to also merge a WaitingForReview task.");
|
|
|
|
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
|
|
|
if (dryRun)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
|
if (wt.State != WorktreeState.Active)
|
|
throw new InvalidOperationException(
|
|
$"Worktree state must be Active to merge (current: {wt.State}).");
|
|
return new MergeTaskResultDto(false, null, []);
|
|
}
|
|
|
|
var commitMessage = $"Merge task branch for: {task.Title}";
|
|
var result = await _merge.MergeAsync(
|
|
taskId, targetBranch, removeWorktree: false, commitMessage, leaveConflictsInTree, cancellationToken);
|
|
|
|
if (result.Status == TaskMergeService.StatusMerged)
|
|
{
|
|
string? mergeCommit = null;
|
|
try
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(list?.WorkingDir) && Directory.Exists(list.WorkingDir))
|
|
mergeCommit = await _git.RevParseHeadAsync(list.WorkingDir, cancellationToken);
|
|
}
|
|
catch { /* mergeCommit is optional */ }
|
|
return new MergeTaskResultDto(true, mergeCommit, []);
|
|
}
|
|
|
|
if (result.Status == TaskMergeService.StatusConflict)
|
|
return leaveConflictsInTree
|
|
? new MergeTaskResultDto(false, null, result.ConflictFiles,
|
|
ConflictsInTree: true, RepoPath: list?.WorkingDir)
|
|
: new MergeTaskResultDto(false, null, result.ConflictFiles);
|
|
|
|
throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Finish an in-progress conflicted merge after the conflict markers in the working tree (repoPath from " +
|
|
"merge_task/review_task) have been resolved. Handles both a single task's merge and a parent/children unit " +
|
|
"merge — pass the PARENT task id to continue a unit merge. On success merged=true and the task reaches its " +
|
|
"post-merge status (Done when approving). If conflict markers are still present, merged=false and conflicts " +
|
|
"lists the affected files — resolve them and call continue_merge again. " +
|
|
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")]
|
|
public async Task<MergeContinuationResultDto> ContinueMerge(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
|
var workingDir = list?.WorkingDir;
|
|
|
|
bool merged;
|
|
IReadOnlyList<string> conflicts = Array.Empty<string>();
|
|
string? repoPath = null;
|
|
string? message = null;
|
|
|
|
if (_planningMerge.HasActiveMerge(taskId))
|
|
{
|
|
await _planningMerge.ContinueAsync(taskId, cancellationToken);
|
|
var parent = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
|
if (parent.Status == TaskStatus.Done)
|
|
{
|
|
merged = true;
|
|
}
|
|
else
|
|
{
|
|
var remaining = !string.IsNullOrWhiteSpace(workingDir)
|
|
? await _git.ListConflictedFilesAsync(workingDir, cancellationToken)
|
|
: new List<string>();
|
|
merged = false;
|
|
if (remaining.Count > 0)
|
|
{
|
|
conflicts = remaining;
|
|
repoPath = workingDir;
|
|
message = "conflicts remain — resolve and call continue_merge again";
|
|
}
|
|
else
|
|
{
|
|
message = "unit merge did not complete — the orchestrator aborted or was blocked; " +
|
|
"check the parent task and approve again to restart the merge";
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken);
|
|
if (r.Status == TaskMergeService.StatusMerged)
|
|
{
|
|
merged = true;
|
|
}
|
|
else if (r.Status == TaskMergeService.StatusConflict)
|
|
{
|
|
merged = false;
|
|
conflicts = r.ConflictFiles;
|
|
repoPath = workingDir;
|
|
message = r.ErrorMessage;
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException(r.ErrorMessage ?? "continue failed");
|
|
}
|
|
}
|
|
|
|
var reloaded = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return new MergeContinuationResultDto(merged, reloaded.Status.ToString(), conflicts, repoPath, message);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
|
|
"Handles both a single task's merge and a parent/children unit merge — pass the PARENT task id to abort a " +
|
|
"unit merge. The task keeps its pre-merge status (e.g. WaitingForReview). " +
|
|
"Throws if there is no in-progress merge for the task. " +
|
|
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
|
|
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
if (_planningMerge.HasActiveMerge(taskId))
|
|
{
|
|
await _planningMerge.AbortAsync(taskId, cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
var r = await _merge.AbortMergeAsync(taskId, cancellationToken);
|
|
if (r.Status == TaskMergeService.StatusBlocked)
|
|
throw new InvalidOperationException(r.ErrorMessage ?? "abort failed");
|
|
}
|
|
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Non-destructive merge preview for a task's worktree branch against targetBranch (default: the repo's " +
|
|
"current branch), via `git merge-tree --write-tree` — does NOT touch the working tree, index, or HEAD. " +
|
|
"status: 'clean' (mergeable; changedFileCount is the size of that merge) or 'conflict' (conflictFiles " +
|
|
"lists the paths git would stop on). behind = commits on targetBranch not yet on this task's branch, so " +
|
|
"you can spot a stale branch even when the preview itself is clean. " +
|
|
"IMPORTANT: a clean preview says nothing about whether the merged result compiles or passes tests — git " +
|
|
"can merge two changes cleanly (e.g. one file deletes a symbol another file still references) and still " +
|
|
"break the build. " +
|
|
"Throws a clear error if the task has no worktree, the worktree is not Active, or the list's working " +
|
|
"directory is missing from disk.")]
|
|
public async Task<MergePreviewToolDto> PreviewMerge(
|
|
string taskId,
|
|
string? targetBranch = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var (preview, behind, _) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
|
|
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Merge preview plus file-overlap check across several tasks at once, all previewed against the same " +
|
|
"targetBranch (default: the repo's current branch). For each taskId returns the same fields as " +
|
|
"preview_merge (status/conflictFiles/changedFileCount/behind; error is set instead if that task could not " +
|
|
"be previewed, and it is then excluded from the overlap computation). overlaps lists, for each file " +
|
|
"touched by MORE THAN ONE of the given tasks (via each task's own diff, not the merge preview itself), " +
|
|
"which tasks touch it — passing a single taskId always yields an empty overlaps list. " +
|
|
"IMPORTANT: file-name overlap is a HINT, not a guarantee of a real collision, and its absence is not a " +
|
|
"guarantee of safety — two tasks touching different files entirely (e.g. one deletes a symbol, another " +
|
|
"still references it elsewhere) can still collide, and this tool will not flag that case.")]
|
|
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
|
|
IReadOnlyList<string> taskIds,
|
|
string? targetBranch = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (taskIds is null || taskIds.Count == 0)
|
|
throw new InvalidOperationException("taskIds must contain at least one task id.");
|
|
|
|
var entries = new List<MergePreviewSetEntryDto>();
|
|
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
|
|
|
|
foreach (var taskId in taskIds)
|
|
{
|
|
try
|
|
{
|
|
var (preview, behind, changedFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
|
|
entries.Add(new MergePreviewSetEntryDto(
|
|
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null));
|
|
filesByTask[taskId] = changedFiles;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
entries.Add(new MergePreviewSetEntryDto(
|
|
taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message));
|
|
}
|
|
}
|
|
|
|
var overlaps = filesByTask
|
|
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
|
|
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase)
|
|
.Where(g => g.Select(x => x.TaskId).Distinct().Count() > 1)
|
|
.Select(g => new FileOverlapDto(g.Key, g.Select(x => x.TaskId).Distinct().ToList()))
|
|
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
return new MergePreviewSetResultDto(entries, overlaps);
|
|
}
|
|
|
|
// Shared core for PreviewMerge/PreviewMergeSet: throws a clear InvalidOperationException instead of
|
|
// TaskMergeService.PreviewAsync's silent "unavailable" status, and adds `behind` + the task's own
|
|
// changed-file list (via diff-stat, not the merge-tree preview) for overlap detection.
|
|
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles)> PreviewMergeCoreAsync(
|
|
string taskId, string? targetBranch, CancellationToken ct)
|
|
{
|
|
var (_, list, wt) = await LoadWorktreeContextAsync(taskId, ct);
|
|
if (wt.State != WorktreeState.Active)
|
|
throw new InvalidOperationException(
|
|
$"Worktree state must be Active to preview a merge (current: {wt.State}).");
|
|
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
|
|
throw new InvalidOperationException("The list's working directory no longer exists.");
|
|
|
|
var preview = await _merge.PreviewAsync(taskId, targetBranch ?? "", ct);
|
|
if (preview.Status == TaskMergeService.PreviewUnavailable)
|
|
throw new InvalidOperationException(
|
|
"Merge preview unavailable for this task (worktree inactive or repo is not a git repository).");
|
|
|
|
var target = string.IsNullOrWhiteSpace(targetBranch)
|
|
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
|
: targetBranch;
|
|
var behind = await GitRevListCountAsync(list.WorkingDir, $"{wt.BranchName}..{target}", ct);
|
|
|
|
var changedFiles = Directory.Exists(wt.Path)
|
|
? ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct))
|
|
: Array.Empty<string>();
|
|
|
|
return (preview, behind, changedFiles);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Revert a previously merged task's merge commit on targetBranch (default: main), via `git revert -m 1` — " +
|
|
"a new commit, never a reset/rewrite (the target working directory is shared with other sessions). " +
|
|
"Requires the task to be Done with a Merged worktree that has a recorded merge commit; tasks merged " +
|
|
"before this feature existed have no recorded commit and are refused rather than guessed via git log. " +
|
|
"On success: reverted=true, revertCommit is the new commit's SHA, and the task returns to " +
|
|
"WaitingForReview so it can be reconsidered. " +
|
|
"On a conflicting revert: reverted=false, the revert is aborted immediately (no half-resolved state " +
|
|
"left in the tree) and conflicts lists the files that would have conflicted. " +
|
|
"Throws if there is no recorded merge commit, the repo is mid-merge/mid-revert, or the target working " +
|
|
"tree has uncommitted changes from another session.")]
|
|
public async Task<RevertMergeResultDto> RevertMerge(
|
|
string taskId, string targetBranch = "main", CancellationToken cancellationToken = default)
|
|
{
|
|
var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken);
|
|
|
|
if (result.Status == TaskMergeService.StatusReverted)
|
|
{
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return new RevertMergeResultDto(true, result.RevertCommit, Array.Empty<string>(), null);
|
|
}
|
|
|
|
if (result.Status == TaskMergeService.StatusConflictAborted)
|
|
return new RevertMergeResultDto(false, null, result.ConflictFiles, result.ErrorMessage);
|
|
|
|
throw new InvalidOperationException(result.ErrorMessage ?? $"Revert blocked: {result.Status}");
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"List all ClaudeDo-tracked worktrees. " +
|
|
"Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +
|
|
"isDirty (has uncommitted changes), mergedIntoMain (worktree state is Merged). " +
|
|
"Only worktrees recorded in the ClaudeDo database are returned.")]
|
|
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(CancellationToken cancellationToken)
|
|
{
|
|
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
|
|
var results = await Task.WhenAll(rows.Select(async row =>
|
|
{
|
|
var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken);
|
|
var headCommit = row.PathExistsOnDisk
|
|
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
|
|
: "";
|
|
return new WorktreeListItemDto(
|
|
row.TaskId, row.Path, row.BranchName, headCommit,
|
|
isDirty, row.State == WorktreeState.Merged);
|
|
}));
|
|
return results;
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Remove a task's worktree directory and delete its git branch. " +
|
|
"force=false (default): refuses if the worktree has uncommitted changes or the task is Running. " +
|
|
"force=true: removes even a dirty worktree (uncommitted changes are lost); task must not be Running. " +
|
|
"Returns removed=true on success; branchDeleted reflects whether the branch was also removed.")]
|
|
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
|
|
string taskId, bool force = false, CancellationToken cancellationToken = default)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
|
|
|
if (task.Status == TaskStatus.Running)
|
|
throw new InvalidOperationException("Cannot remove worktree of a running task.");
|
|
|
|
if (!force && Directory.Exists(wt.Path))
|
|
{
|
|
var isDirty = await _git.HasChangesAsync(wt.Path, cancellationToken);
|
|
if (isDirty)
|
|
throw new InvalidOperationException(
|
|
"Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost).");
|
|
}
|
|
|
|
var path = wt.Path;
|
|
var result = await _maintenance.ForceRemoveAsync(taskId, cancellationToken);
|
|
return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Send a follow-up prompt to an existing Claude session (multi-turn continuation). " +
|
|
"The agent resumes using --resume with the session ID from the task's last run. " +
|
|
"Runs in the override execution slot; throws if the slot is busy — try again later. " +
|
|
"Returns a status string from the execution slot.")]
|
|
public async Task<string> ContinueTask(
|
|
string taskId,
|
|
string followUpPrompt,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(followUpPrompt))
|
|
throw new InvalidOperationException("followUpPrompt is required.");
|
|
|
|
string result;
|
|
try
|
|
{
|
|
result = await _queue.ContinueTask(taskId, followUpPrompt);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
throw new InvalidOperationException("Override slot busy. Try again later.");
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
throw new InvalidOperationException($"Task {taskId} not found.");
|
|
}
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return result;
|
|
}
|
|
|
|
// ── Daily prep ───────────────────────────────────────────────────────────
|
|
|
|
[McpServerTool, Description(
|
|
"Daily prep: returns the open tasks eligible for today's MyDay selection. " +
|
|
"candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " +
|
|
"currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " +
|
|
"maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")]
|
|
public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
|
|
var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths);
|
|
var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
|
|
|
|
var idle = await ctx.Tasks
|
|
.AsNoTracking()
|
|
.Include(t => t.List)
|
|
.Where(t => t.Status == TaskStatus.Idle)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var currentMyDay = idle
|
|
.Where(t => t.IsMyDay)
|
|
.OrderBy(t => t.SortOrder)
|
|
.Select(ToCandidate)
|
|
.ToList();
|
|
|
|
var candidates = idle
|
|
.Where(t => !t.IsMyDay
|
|
&& t.BlockedByTaskId == null
|
|
// A manual task is a reminder only the user can do — never a Claude candidate.
|
|
&& !t.IsManual
|
|
&& DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
|
|
.OrderBy(t => t.CreatedAt)
|
|
.Select(ToCandidate)
|
|
.ToList();
|
|
|
|
return new DailyPrepDataDto(maxTasks, candidates, currentMyDay);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " +
|
|
"(use consecutive sortOrder values to keep related tasks together). " +
|
|
"Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " +
|
|
"clearing (isMyDay=false) is always allowed. " +
|
|
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
|
|
public async Task<TaskRefDto> SetMyDay(
|
|
string taskId,
|
|
bool isMyDay,
|
|
int? sortOrder = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
if (isMyDay && !task.IsMyDay)
|
|
{
|
|
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
|
|
var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
|
|
var openMyDay = await ctx.Tasks.CountAsync(
|
|
t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken);
|
|
if (openMyDay >= max)
|
|
throw new InvalidOperationException(
|
|
$"MyDay limit {max} reached. Clear a task before adding another.");
|
|
}
|
|
|
|
task.IsMyDay = isMyDay;
|
|
if (sortOrder is not null) task.SortOrder = sortOrder.Value;
|
|
await ctx.SaveChangesAsync(cancellationToken);
|
|
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return ToRefDto(task);
|
|
}
|
|
|
|
private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
|
|
t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
|
|
t.IsStarred, t.ScheduledFor, t.CreatedAt);
|
|
|
|
// ── Private helpers ───────────────────────────────────────────────────────
|
|
|
|
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync(
|
|
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 list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
|
|
?? throw new InvalidOperationException("List not found.");
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
|
|
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
|
return (task, list, wt);
|
|
}
|
|
|
|
private async Task<bool> TryGetIsDirtyAsync(string path, CancellationToken ct)
|
|
{
|
|
try { return await _git.HasChangesAsync(path, ct); }
|
|
catch { return false; }
|
|
}
|
|
|
|
// Minimal git runner for operations not covered by GitService (rev-list --count, rev-parse from worktree).
|
|
private static async Task<string?> TryRunGitAsync(string dir, string[] args, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var psi = new ProcessStartInfo("git")
|
|
{
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
CreateNoWindow = true,
|
|
};
|
|
psi.ArgumentList.Add("-C");
|
|
psi.ArgumentList.Add(dir);
|
|
foreach (var a in args) psi.ArgumentList.Add(a);
|
|
using var proc = Process.Start(psi)!;
|
|
await using var _ = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { } });
|
|
var stdout = await proc.StandardOutput.ReadToEndAsync();
|
|
await proc.WaitForExitAsync(CancellationToken.None);
|
|
ct.ThrowIfCancellationRequested();
|
|
return proc.ExitCode == 0 ? stdout.Trim() : null;
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch { return null; }
|
|
}
|
|
|
|
private static async Task<int> GitRevListCountAsync(string dir, string range, CancellationToken ct)
|
|
{
|
|
var result = await TryRunGitAsync(dir, ["rev-list", "--count", range], ct);
|
|
return int.TryParse(result, out var n) ? n : 0;
|
|
}
|
|
|
|
private static IReadOnlyList<string> ParseDiffFileNames(string diff)
|
|
{
|
|
var files = new List<string>();
|
|
foreach (var line in diff.Split('\n'))
|
|
{
|
|
var s = line.TrimEnd('\r');
|
|
if (s.StartsWith("+++ b/", StringComparison.Ordinal))
|
|
files.Add(s[6..]);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
private static IReadOnlyList<string> ParseDiffStatFileNames(string stat)
|
|
{
|
|
var files = new List<string>();
|
|
foreach (var line in stat.Split('\n'))
|
|
{
|
|
var idx = line.IndexOf('|');
|
|
if (idx > 0) files.Add(line[..idx].Trim());
|
|
}
|
|
return files;
|
|
}
|
|
|
|
private static TaskDto ToDto(TaskEntity t) => new(
|
|
t.Id,
|
|
t.ListId,
|
|
t.Title,
|
|
t.Description,
|
|
t.Status.ToString(),
|
|
t.Result,
|
|
t.CreatedBy,
|
|
t.CreatedAt,
|
|
t.StartedAt,
|
|
t.FinishedAt,
|
|
t.IsMyDay,
|
|
t.SortOrder);
|
|
|
|
private static TaskRefDto ToRefDto(TaskEntity t) => new(
|
|
t.Id,
|
|
t.ListId,
|
|
t.Title,
|
|
t.Status.ToString(),
|
|
t.SortOrder,
|
|
t.IsMyDay);
|
|
}
|
|
|
|
internal static class DailyPrepFilter
|
|
{
|
|
public static string[] ParseExcludes(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json)) return [];
|
|
try
|
|
{
|
|
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
|
|
return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray();
|
|
}
|
|
catch (System.Text.Json.JsonException) { return []; }
|
|
}
|
|
|
|
public static bool IsIncludedRepo(string? workingDir, string[] excludes)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(workingDir)) return false;
|
|
var norm = Normalize(workingDir);
|
|
return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static string Normalize(string path) =>
|
|
path.Trim().Replace('/', '\\').TrimEnd('\\');
|
|
}
|