continue_merge and the parent/children unit-merge drain (PlanningMergeOrchestrator) re-run the post-merge verify gate but never forwarded their IProgress token into it, so a slow verify command on either path went silent past Claude Code's 300s MCP idle-abort even though D1-D3 already fixed this for merge_task/review_task's childless path. list_worktrees also gets elapsed-time progress: many tracked worktrees means many concurrent git subprocess spawns. Worker CLAUDE.md's existing progress rule now points at ProgressReporter as the one implementation instead of a fresh polling loop.
1788 lines
92 KiB
C#
1788 lines
92 KiB
C#
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Git;
|
|
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;
|
|
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, int Number);
|
|
public sealed record CancelTaskResult(bool Cancelled, string Id, int? Number = null);
|
|
// EmptyChildren is non-null only for a parent's approve (unit merge): the Done children whose
|
|
// review range (worktree ahead, or HandlerBaseCommit..HandlerHeadCommit for a worktree-less
|
|
// child) contributed nothing, so a reviewer sees them before approving instead of after.
|
|
public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList<TaskRefDto>? EmptyChildren = null);
|
|
public sealed record RunTaskNowResult(bool Started, string TaskId, DirtyBaseWarning? BaseDirty = null, int? Number = null);
|
|
|
|
public sealed record TaskDto(
|
|
string Id,
|
|
int Number,
|
|
string ListId,
|
|
string Title,
|
|
string? Description,
|
|
string Status,
|
|
string? Result,
|
|
string? CreatedBy,
|
|
DateTime CreatedAt,
|
|
DateTime? StartedAt,
|
|
DateTime? FinishedAt,
|
|
bool IsMyDay,
|
|
int SortOrder,
|
|
// Count of CLAUDEDO_BLOCKED roadblocks the run reported, stamped by TaskRunner on finish.
|
|
// A planning/improvement child reporting > 0 still goes straight to Done (see
|
|
// ClaudeDo.Worker/CLAUDE.md → Unified parent model) -- this is the only MCP-visible signal
|
|
// that it may have delivered nothing despite that Done status.
|
|
int RoadblockCount = 0,
|
|
// The three below are non-null only when Status=Failed; stamped by TaskRunner.MarkFailed via
|
|
// TaskStateService.FailAsync. failureReason is "unknown" for a Failed task that predates this
|
|
// field. Lets a caller triage without pulling get_task_log's raw NDJSON.
|
|
string? FailureReason = null,
|
|
int? FailureTurnsUsed = null,
|
|
int? FailureMaxTurns = null,
|
|
// A user/MCP-declared predecessor (set via add_task/update_task), distinct from the
|
|
// planning chain's own internal BlockedByTaskId link.
|
|
string? DependsOnTaskId = null,
|
|
// True only while Status is Queued and the picker will not claim this task yet -- either a
|
|
// planning-chain predecessor or DependsOnTaskId hasn't reached Done. See BlockedReason.
|
|
bool Blocked = false,
|
|
string? BlockedReason = null);
|
|
|
|
// 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.
|
|
// BaseDirty is populated only where the task just transitioned to Queued (or ran immediately
|
|
// via run_task_now) and its list's working directory has uncommitted changes -- see
|
|
// ClaudeDo.Worker.Git.BaseDirtyChecker. Every other caller leaves it null.
|
|
public sealed record TaskRefDto(
|
|
string Id,
|
|
int Number,
|
|
string ListId,
|
|
string Title,
|
|
string Status,
|
|
int SortOrder,
|
|
bool IsMyDay,
|
|
int RoadblockCount = 0,
|
|
string? FailureReason = null,
|
|
int? FailureTurnsUsed = null,
|
|
int? FailureMaxTurns = null,
|
|
string? DependsOnTaskId = null,
|
|
bool Blocked = false,
|
|
string? BlockedReason = null,
|
|
DirtyBaseWarning? BaseDirty = null);
|
|
|
|
// 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);
|
|
|
|
// Deliberately small: no descriptions, capped candidate count (see AddTask's duplicate check).
|
|
public sealed record PossibleDuplicateDto(string TaskId, int Number, string Title, string Status);
|
|
|
|
public sealed record AddTaskResult(TaskRefDto Task, IReadOnlyList<PossibleDuplicateDto> PossibleDuplicates);
|
|
|
|
|
|
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 ConflictHunkDto(
|
|
int Index, int StartLine, string Ours, string? Base, string Theirs);
|
|
|
|
public sealed record ConflictFileHunksDto(
|
|
string Path, bool IsBinary, IReadOnlyList<ConflictHunkDto> Hunks);
|
|
|
|
public sealed record GetMergeConflictsResultDto(
|
|
IReadOnlyList<ConflictFileHunksDto> Files, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);
|
|
|
|
public sealed record ResolveConflictHunkResultDto(
|
|
bool Resolved, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);
|
|
|
|
// IsEmpty = the review range contributed nothing (worktree ahead-of-base is empty, or a
|
|
// worktree-less handler task's HandlerBaseCommit == HandlerHeadCommit) -- distinguishable from
|
|
// a merge that is merely small, so an empty branch can't be misread as "changedFileCount: 0
|
|
// means tiny" when it actually means "nothing to review".
|
|
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are null unless a verify run was attempted
|
|
// (see PreviewMerge/PreviewMergeSet descriptions) -- 0 exit means the merge-tree result built/
|
|
// tested clean; a non-zero or -1 (timeout/failed to start) exit means it doesn't, with the tail
|
|
// of its output in VerifyOutputTail.
|
|
public sealed record MergePreviewToolDto(
|
|
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false,
|
|
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
|
|
// Files this branch touches that the target branch ALSO touched since the branch's fork
|
|
// point -- an honest staleness signal `behind` alone can't give (a branch can be far behind
|
|
// yet touch nothing the target changed, or close behind yet collide on the one file that
|
|
// matters). Empty for a worktree-less handler task, which commits straight onto the list's
|
|
// working dir and has no fork point to compare against.
|
|
IReadOnlyList<string>? StaleFiles = null);
|
|
|
|
public sealed record MergePreviewSetEntryDto(
|
|
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false,
|
|
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
|
|
IReadOnlyList<string>? StaleFiles = null, int? Number = null);
|
|
|
|
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds, IReadOnlyList<int> Numbers);
|
|
|
|
// TaskId's changed-file list is a non-empty PROPER subset of SupersetTaskId's -- e.g. one task
|
|
// deletes two files and another deletes just one of those same two. Stronger than an Overlaps
|
|
// entry (which only says some files are shared): a real subset is the best available machine
|
|
// signal that TaskId's work may already be entirely covered by SupersetTaskId.
|
|
public sealed record SubsetRelationDto(string TaskId, int Number, string SupersetTaskId, int SupersetNumber);
|
|
|
|
public sealed record MergePreviewSetResultDto(
|
|
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
|
|
IReadOnlyList<SubsetRelationDto> Subsets);
|
|
|
|
public sealed record WorktreeListItemDto(
|
|
string? TaskId, int? Number, string Path, string Branch,
|
|
string HeadCommit, bool IsDirty, bool MergedIntoMain);
|
|
|
|
public sealed record CleanupWorktreeResult(
|
|
bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null);
|
|
|
|
public sealed record RevertMergeResultDto(
|
|
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
|
|
|
|
public sealed record DailyPrepCandidateDto(
|
|
string Id, int Number, 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
|
|
{
|
|
// Mirrors TaskMergeService.ProgressReportInterval / TaskWaitMcpTools.ProgressReportInterval:
|
|
// a separate field (not shared) so shrinking one for a test can't race another's tests. Used
|
|
// by the single-element long-runners here (GetTaskDiff, CleanupTaskWorktree) that wrap a bare
|
|
// GitService/WorktreeMaintenanceService call with no natural i/n to report instead.
|
|
internal static TimeSpan ProgressReportInterval = TimeSpan.FromSeconds(30);
|
|
|
|
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;
|
|
private readonly IBaseDirtyChecker _baseDirtyChecker;
|
|
|
|
public ExternalMcpService(
|
|
TaskRepository tasks,
|
|
ListRepository lists,
|
|
QueueService queue,
|
|
HubBroadcaster broadcaster,
|
|
ITaskStateService state,
|
|
GitService git,
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
WorktreeMaintenanceService maintenance,
|
|
TaskMergeService merge,
|
|
PlanningMergeOrchestrator planningMerge,
|
|
IBaseDirtyChecker baseDirtyChecker)
|
|
{
|
|
_tasks = tasks;
|
|
_lists = lists;
|
|
_queue = queue;
|
|
_broadcaster = broadcaster;
|
|
_state = state;
|
|
_git = git;
|
|
_dbFactory = dbFactory;
|
|
_maintenance = maintenance;
|
|
_merge = merge;
|
|
_planningMerge = planningMerge;
|
|
_baseDirtyChecker = baseDirtyChecker;
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"List all task lists available in ClaudeDo. Start here — every task tool needs a listId from this call.")]
|
|
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 the tasks in one list — the usual way to find a taskId. Optionally filter by creator and/or status." +
|
|
McpToolDocs.TaskNumberHint)]
|
|
public async Task<ListTasksResult> ListTasks(
|
|
string listId,
|
|
[Description("Only return tasks with this CreatedBy value.")]
|
|
string? createdBy = null,
|
|
[Description("Only return tasks in this status: Idle, Queued, Running, WaitingForReview, " +
|
|
"WaitingForChildren, Done, Failed or Cancelled.")]
|
|
string? status = null,
|
|
[Description("false (default): lean references in `tasks`, no Description/Result — keep this unless you " +
|
|
"need the description text, since verbosely-described tasks can blow past the response size " +
|
|
"limit. true: full tasks in `tasksFull` instead (`tasks` is then 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();
|
|
var blocked = await ComputeBlockedInfoAsync(filtered, cancellationToken);
|
|
return includeDescription
|
|
? new ListTasksResult(true, null, filtered.Select(t => ToDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList())
|
|
: new ListTasksResult(false, filtered.Select(t => ToRefDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList(), null);
|
|
}
|
|
|
|
// Batch-resolves, for each task, whether the picker is currently skipping it (Queued with
|
|
// either a planning-chain BlockedByTaskId or an unmet DependsOnTaskId) and why. Only Queued
|
|
// tasks can be blocked -- once a task has run, or hasn't been queued yet, blocking is moot.
|
|
private async Task<Dictionary<string, (bool Blocked, string? Reason)>> ComputeBlockedInfoAsync(
|
|
IReadOnlyList<TaskEntity> tasks, CancellationToken ct)
|
|
{
|
|
var dependencyIds = tasks
|
|
.Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null && t.DependsOnTaskId is not null)
|
|
.Select(t => t.DependsOnTaskId!)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
var dependencyStatuses = new Dictionary<string, TaskStatus>();
|
|
if (dependencyIds.Count > 0)
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
|
dependencyStatuses = await ctx.Tasks.AsNoTracking()
|
|
.Where(t => dependencyIds.Contains(t.Id))
|
|
.Select(t => new { t.Id, t.Status })
|
|
.ToDictionaryAsync(t => t.Id, t => t.Status, ct);
|
|
}
|
|
|
|
var result = new Dictionary<string, (bool Blocked, string? Reason)>();
|
|
foreach (var t in tasks)
|
|
{
|
|
if (t.Status != TaskStatus.Queued)
|
|
{
|
|
result[t.Id] = (false, null);
|
|
}
|
|
else if (t.BlockedByTaskId is not null)
|
|
{
|
|
result[t.Id] = (true, $"Blocked by planning-chain predecessor {t.BlockedByTaskId}.");
|
|
}
|
|
else if (t.DependsOnTaskId is not null)
|
|
{
|
|
var known = dependencyStatuses.TryGetValue(t.DependsOnTaskId, out var depStatus);
|
|
result[t.Id] = known && depStatus == TaskStatus.Done
|
|
? (false, null)
|
|
: (true, $"Blocked: depends on task {t.DependsOnTaskId} (status: " +
|
|
(known ? depStatus.ToString() : "not found") + ").");
|
|
}
|
|
else
|
|
{
|
|
result[t.Id] = (false, null);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Get a single task by id, including its current status and result — the canonical reference for what a " +
|
|
"status means. Lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
|
|
"A successful run lands in WaitingForReview; use review_task to approve, reject or cancel it. " +
|
|
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution. A Queued task with a blocker waits " +
|
|
"for its predecessor before the picker will claim it, and WaitingForChildren is a parent whose own work " +
|
|
"is done but whose children are still running. For Status=Failed, failureReason (max_turns|timeout|" +
|
|
"error|cancelled|unknown) plus failureTurnsUsed/failureMaxTurns say why without pulling get_task_log." +
|
|
McpToolDocs.TaskNumberHint)]
|
|
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
var blocked = await ComputeBlockedInfoAsync([task], cancellationToken);
|
|
return ToDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason);
|
|
}
|
|
|
|
// 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)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
var blocked = await ComputeBlockedInfoAsync([task], cancellationToken);
|
|
return ToRefDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Create a new task in the given list. The task is always created — possibleDuplicates is a non-blocking " +
|
|
"heads-up (up to 3 open tasks in the same list with a strongly overlapping title); check it and mention " +
|
|
"any hit to the caller, but do not treat it as an error." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)]
|
|
public async Task<AddTaskResult> AddTask(
|
|
string listId,
|
|
string title,
|
|
string? description = null,
|
|
string? createdBy = null,
|
|
[Description("true: enqueue the task for agent execution right away.")]
|
|
bool queueImmediately = false,
|
|
[Description("Cheapest model that can do the task well: 'haiku' for trivial/mechanical work, 'sonnet' " +
|
|
"for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " +
|
|
"list/global default (normally sonnet).")]
|
|
string? model = null,
|
|
[Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " +
|
|
"even once queued. Rejected if it doesn't exist, is this task's own id, or would create a " +
|
|
"dependency cycle. If that predecessor instead ends up Failed or Cancelled, this task simply " +
|
|
"stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")]
|
|
string? dependsOnTaskId = 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.");
|
|
|
|
dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken);
|
|
|
|
var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken);
|
|
|
|
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 (dependsOnTaskId is not null)
|
|
{
|
|
var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOnTaskId, cancellationToken);
|
|
if (!dependsResult.Ok)
|
|
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
|
|
entity.DependsOnTaskId = dependsOnTaskId;
|
|
}
|
|
|
|
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);
|
|
var blocked = await ComputeBlockedInfoAsync([entity], cancellationToken);
|
|
return new AddTaskResult(ToRefDto(entity, blocked[entity.Id].Blocked, blocked[entity.Id].Reason), possibleDuplicates);
|
|
}
|
|
|
|
// Non-terminal: a task still open enough that a new, similarly-titled task might be a duplicate
|
|
// of it. Done/Failed/Cancelled tasks are retired and never flagged.
|
|
private static readonly TaskStatus[] NonTerminalStatuses =
|
|
[
|
|
TaskStatus.Idle, TaskStatus.Queued, TaskStatus.Running,
|
|
TaskStatus.WaitingForChildren, TaskStatus.WaitingForReview,
|
|
];
|
|
|
|
private const int MaxPossibleDuplicates = 3;
|
|
|
|
// Cheap, dependency-free "near-duplicate title" heuristic — no embeddings, no LLM call, just a
|
|
// normalized-word overlap coefficient (shared significant words / smaller word-set size, not
|
|
// Jaccard). Overlap-coefficient beats Jaccard here because titles in this project vary wildly in
|
|
// length (a terse one-liner vs. a fully-spelled-out German sentence) — dividing by the union
|
|
// would wash out a real match against a longer title. Threshold tuned against the pair that
|
|
// motivated this check: "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal" vs.
|
|
// "Settings: MaxTurnsCeiling editierbar machen" (>=2 shared significant words, >=50% overlap of
|
|
// the shorter title's words) without over-firing on titles that merely share one generic word.
|
|
private async Task<IReadOnlyList<PossibleDuplicateDto>> FindPossibleDuplicatesAsync(
|
|
string listId, string title, CancellationToken ct)
|
|
{
|
|
var newWords = NormalizeTitleWords(title);
|
|
if (newWords.Count == 0) return [];
|
|
|
|
var candidates = await _tasks.GetByListIdAsync(listId, ct);
|
|
|
|
return candidates
|
|
.Where(t => NonTerminalStatuses.Contains(t.Status))
|
|
.Select(t => (Task: t, Words: NormalizeTitleWords(t.Title)))
|
|
.Select(x => (x.Task, Shared: newWords.Count(x.Words.Contains), x.Words))
|
|
.Where(x => x.Shared >= 2 && x.Words.Count > 0
|
|
&& (double)x.Shared / Math.Min(newWords.Count, x.Words.Count) >= 0.5)
|
|
.OrderByDescending(x => (double)x.Shared / Math.Min(newWords.Count, x.Words.Count))
|
|
.ThenByDescending(x => x.Shared)
|
|
.Take(MaxPossibleDuplicates)
|
|
.Select(x => new PossibleDuplicateDto(x.Task.Id, x.Task.Number, x.Task.Title, x.Task.Status.ToString()))
|
|
.ToList();
|
|
}
|
|
|
|
private static readonly Regex NonWordChars = new(@"[^a-z0-9]+", RegexOptions.Compiled);
|
|
|
|
// Common structural filler that would otherwise dominate the overlap score across most titles
|
|
// in this project's lists without indicating a real duplicate (German articles/prepositions
|
|
// plus the recurring "mcp"/"task(s)"/"cleanup" nouns called out in the task write-up).
|
|
private static readonly HashSet<string> TitleStopWords = new(StringComparer.Ordinal)
|
|
{
|
|
"der", "die", "das", "und", "von", "auf", "mit", "fuer", "ein", "eine",
|
|
"ist", "sind", "oder", "nicht", "mcp", "task", "tasks", "cleanup",
|
|
};
|
|
|
|
// Lowercases, transliterates German umlauts/ß to their digraph spelling (ä→ae, ß→ss, ...) so
|
|
// "Prüfung" and "Pruefung" normalize to the same token, strips remaining diacritics/punctuation,
|
|
// then drops short (<3 char) and stopword tokens.
|
|
private static HashSet<string> NormalizeTitleWords(string title)
|
|
{
|
|
var s = title.ToLowerInvariant()
|
|
.Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue").Replace("ß", "ss");
|
|
s = s.Normalize(NormalizationForm.FormD);
|
|
|
|
var sb = new StringBuilder(s.Length);
|
|
foreach (var ch in s)
|
|
if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark)
|
|
sb.Append(ch);
|
|
|
|
var ascii = NonWordChars.Replace(sb.ToString(), " ");
|
|
return ascii.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
|
.Where(w => w.Length >= 3 && !TitleStopWords.Contains(w))
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Update an existing task's title, description, commit type, and/or dependsOn link. Pass null to leave a " +
|
|
"field unchanged." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
|
|
public async Task<TaskRefDto> UpdateTask(
|
|
string taskId,
|
|
string? title = null,
|
|
string? description = null,
|
|
string? commitType = null,
|
|
[Description("Id of a task that must reach Done before the picker will claim this one. Pass an empty " +
|
|
"string to clear an existing link; null leaves it unchanged. Rejected if it doesn't exist, " +
|
|
"is this task's own id, or would create a dependency cycle.")]
|
|
string? dependsOnTaskId = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
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);
|
|
|
|
if (dependsOnTaskId is not null)
|
|
{
|
|
// Empty string is a deliberate sentinel (see the dependsOnTaskId parameter doc) to
|
|
// clear the link — TaskIdResolver passes it through untouched, then NullIfBlank
|
|
// below still turns it into the clear signal SetDependsOnAsync expects.
|
|
dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken);
|
|
var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOnTaskId.NullIfBlank(), cancellationToken);
|
|
if (!dependsResult.Ok)
|
|
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
|
|
}
|
|
|
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
var blocked = await ComputeBlockedInfoAsync([reload], cancellationToken);
|
|
return ToRefDto(reload, blocked[reload.Id].Blocked, blocked[reload.Id].Reason);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Append a subtask (step) to a task. Subtasks are surfaced to the agent at run time and shown in the " +
|
|
"task's Steps list." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
|
|
public async Task<TaskRefDto> AddSubtask(
|
|
string taskId,
|
|
string title,
|
|
[Description("Position among the existing steps; defaults to the end.")]
|
|
int? orderNum = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(title))
|
|
throw new InvalidOperationException("title is required.");
|
|
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
|
|
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(
|
|
"Move a task between the statuses a caller may set directly. Use run_task_now for execution control and " +
|
|
"review_task to act on a WaitingForReview task — neither is reachable from here." +
|
|
McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)]
|
|
public async Task<TaskRefDto> UpdateTaskStatus(
|
|
string taskId,
|
|
[Description("'Idle' (reset to editable), 'Queued' (enqueue for execution), 'Cancelled' (retire without " +
|
|
"deleting; can be reset to Idle later) or 'Done' (mark complete; refused if the task has an " +
|
|
"active worktree — use review_task to approve and merge that worktree instead). No other " +
|
|
"value is settable externally.")]
|
|
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.");
|
|
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
DirtyBaseWarning? baseDirty = null;
|
|
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.");
|
|
baseDirty = enqueueResult.BaseDirty;
|
|
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) with { BaseDirty = baseDirty };
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Act on a task that is WaitingForReview — the only way to approve, reject or retire a reviewed run. " +
|
|
"'approve' is review+merge, exactly like the UI's Approve: a childless task merges its worktree into " +
|
|
"targetBranch 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. Fails if the task is " +
|
|
"not WaitingForReview (except 'cancel', which also works while Running/Queued). mergeStatus 'conflict' " +
|
|
"means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " +
|
|
"the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " +
|
|
"reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " +
|
|
"delivered something." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint + McpToolDocs.ProgressHint)]
|
|
public async Task<ReviewTaskResult> ReviewTask(
|
|
string taskId,
|
|
[Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")]
|
|
string decision,
|
|
[Description("Rejection comment. Required for 'reject_rerun', where the task goes Queued and re-runs with " +
|
|
"this text as the next turn of the agent's resumed session; ignored for 'reject_park', which " +
|
|
"just returns the task to Idle for manual editing.")]
|
|
string? feedback = null,
|
|
[Description("Branch an approve merges into; defaults to the repo's current branch.")]
|
|
string? targetBranch = null,
|
|
[Description("What an approve does when the merge hits conflicts. false (default): abort cleanly, leaving " +
|
|
"no half-merged state, and you resolve in the ClaudeDo UI. true: 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.")]
|
|
bool leaveConflictsInTree = false,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
// First report fires before any git/verify work starts -- an approve that has to wait on
|
|
// the per-repo merge gate (another merge/verify already running) must not sit silent long
|
|
// enough to trip Claude Code's ~300s MCP idle-silence abort before RunVerifyGateAsync's own
|
|
// periodic reports even begin.
|
|
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "review_task started" });
|
|
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
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;
|
|
IReadOnlyList<TaskRefDto>? emptyChildren = 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)
|
|
{
|
|
// Compute before the merge starts -- children are still Done with their own
|
|
// pre-merge worktree/commit range at this point, so "did it contribute anything"
|
|
// reflects the review range the reviewer is about to approve.
|
|
emptyChildren = await GetEmptyDoneChildrenAsync(taskId, cancellationToken);
|
|
// externallyDriven: true — this call came from an MCP session, not the UI's
|
|
// Approve button. A unit-merge conflict must not auto-open the in-app resolver;
|
|
// the driving session resolves it via continue_merge/abort_merge instead.
|
|
await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken, externallyDriven: true, progress);
|
|
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, progress);
|
|
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, emptyChildren);
|
|
}
|
|
|
|
// Done children about to be unit-merged whose own review range contributed nothing: an
|
|
// active worktree with zero changed files against its base commit, or a worktree-less
|
|
// handler child whose HandlerBaseCommit == HandlerHeadCommit. A child with neither (never
|
|
// committed anything at all) also counts as empty. Best-effort per child -- a diff failure
|
|
// on one child must not block the caller from seeing the others or from approving.
|
|
private async Task<IReadOnlyList<TaskRefDto>> GetEmptyDoneChildrenAsync(string parentTaskId, CancellationToken ct)
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
|
var children = await ctx.Tasks
|
|
.AsNoTracking()
|
|
.Include(t => t.Worktree)
|
|
.Where(t => t.ParentTaskId == parentTaskId && t.Status == TaskStatus.Done)
|
|
.ToListAsync(ct);
|
|
|
|
var empty = new List<TaskRefDto>();
|
|
foreach (var child in children)
|
|
{
|
|
if (await IsChildEmptyAsync(child, ct))
|
|
empty.Add(ToRefDto(child));
|
|
}
|
|
return empty;
|
|
}
|
|
|
|
private async Task<bool> IsChildEmptyAsync(TaskEntity child, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
if (child.Worktree is not null)
|
|
{
|
|
if (!Directory.Exists(child.Worktree.Path)) return false;
|
|
var files = ParseDiffStatFileNames(
|
|
await _git.DiffStatAsync(child.Worktree.Path, child.Worktree.BaseCommit, "HEAD", ct: ct));
|
|
return files.Count == 0;
|
|
}
|
|
if (child.HandlerBaseCommit is { Length: > 0 } handlerBase && child.HandlerHeadCommit is { Length: > 0 } handlerHead)
|
|
return string.Equals(handlerBase, handlerHead, StringComparison.Ordinal);
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Run a task immediately in the override execution slot, bypassing the agent queue. That slot is single-" +
|
|
"occupancy and shared with continue_task — throws \"Override slot busy\" if something else holds it; " +
|
|
"enqueue via update_task_status instead of retrying in a loop.")]
|
|
public async Task<RunTaskNowResult> RunTaskNow(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, 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);
|
|
|
|
// Heads-up only, same as update_task_status's Queued path: a worktree forks from the
|
|
// commit tip, not the working tree, so uncommitted changes in the list's repo right now
|
|
// won't be included.
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
|
|
var list = task is not null ? await _lists.GetByIdAsync(task.ListId, cancellationToken) : null;
|
|
var baseDirty = await _baseDirtyChecker.CheckAsync(list?.WorkingDir, cancellationToken);
|
|
|
|
return new RunTaskNowResult(true, taskId, baseDirty, task?.Number);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Cancel a running task, killing its agent process. cancelled=false means the task was not running.")]
|
|
public async Task<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
var cancelled = _queue.CancelTask(taskId);
|
|
if (cancelled) await _broadcaster.TaskUpdated(taskId);
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
|
|
return new CancelTaskResult(cancelled, taskId, task?.Number);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Delete a task permanently. Prefer update_task_status 'Cancelled' to retire a task you may want back." +
|
|
McpToolDocs.NotWhileRunning)]
|
|
public async Task<DeleteTaskResult> DeleteTask(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, 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, task.Number);
|
|
}
|
|
|
|
// ── Worktree / git tools ──────────────────────────────────────────────────
|
|
|
|
[McpServerTool, Description(
|
|
"Get a task's git worktree state — path, branch, base/head commit, ahead/behind counts, isDirty, and the " +
|
|
"mergeCommit its branch produced once merged. behind is 0 when the 'main' ref is unreachable, so do not " +
|
|
"read 0 as \"up to date\" without checking. A null mergeCommit means revert_merge cannot act on this task. " +
|
|
"Throws if the task or its worktree does not exist.")]
|
|
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, 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(
|
|
"Read what a task actually changed — the diff of its worktree against its base commit (for a worktree-less " +
|
|
"list-handler host task, the fixed HandlerBaseCommit..HandlerHeadCommit range over the list's working dir " +
|
|
"instead). files lists the changed paths in either mode; truncated=true means the diff was capped and " +
|
|
"totalBytes holds its real size. Throws if the task has no worktree/review range, or the relevant " +
|
|
"directory is missing from disk.")]
|
|
public async Task<TaskDiffDto> GetTaskDiff(
|
|
string taskId,
|
|
[Description("false (default): the full unified diff, capped at 200 KB. true: a --stat summary with " +
|
|
"per-file insertion/deletion counts — start here when the diff may be large.")]
|
|
bool stat = false,
|
|
[Description("Restrict the diff to these paths (relative to the repo root), e.g. after a --stat pass or a " +
|
|
"conflict report already narrowed down which files matter. Omit/empty for every changed file " +
|
|
"— the default and the only prior behavior. Works in both stat and full-diff mode.")]
|
|
IReadOnlyList<string>? paths = null,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
|
|
|
|
const int maxBytes = 200 * 1024;
|
|
|
|
if (stat)
|
|
{
|
|
var diffStat = await ProgressReporter.RunAsync(
|
|
_git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", paths, cancellationToken),
|
|
ProgressReportInterval, progress, "computing diff stat");
|
|
return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length);
|
|
}
|
|
|
|
var diff = headCommit is null
|
|
? await ProgressReporter.RunAsync(
|
|
_git.GetBranchDiffAsync(repoPath, baseCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff")
|
|
: await ProgressReporter.RunAsync(
|
|
_git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff");
|
|
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 Done task's worktree branch into targetBranch. For a task still in WaitingForReview prefer " +
|
|
"review_task, which merges as part of approving. merged=true carries the new mergeCommit SHA; on conflict " +
|
|
"merged=false and conflicts lists the affected files." + McpToolDocs.ProgressHint)]
|
|
public async Task<MergeTaskResultDto> MergeTask(
|
|
string taskId,
|
|
string targetBranch = "main",
|
|
[Description("true (default): always create a merge commit (--no-ff).")]
|
|
bool noFf = true,
|
|
[Description("true: validate preconditions only and do not merge — merged=false then means \"not attempted\".")]
|
|
bool dryRun = false,
|
|
[Description("true: also allow merging a task in WaitingForReview; false (default) allows Done only.")]
|
|
bool allowWaitingForReview = false,
|
|
[Description("What to do on conflict. false (default): abort cleanly, leaving no half-merged state. true: " +
|
|
"leave the conflict markers in the working tree at repoPath (conflictsInTree=true) so you can " +
|
|
"resolve them there and call continue_merge, or abort_merge to cancel.")]
|
|
bool leaveConflictsInTree = false,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "merge_task started" });
|
|
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
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, []);
|
|
}
|
|
|
|
// Blank on purpose: TaskMergeService builds the conventional default message.
|
|
var result = await _merge.MergeAsync(
|
|
taskId, targetBranch, removeWorktree: false, commitMessage: "", leaveConflictsInTree, cancellationToken, progress);
|
|
|
|
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 once you have resolved the conflict markers in the working tree " +
|
|
"(repoPath from merge_task/review_task). Pass the PARENT task id to continue a parent/children unit merge. " +
|
|
"merged=false with conflicts listed means markers are still present — resolve them and call again. " +
|
|
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead." +
|
|
McpToolDocs.ProgressHint)]
|
|
public async Task<MergeContinuationResultDto> ContinueMerge(
|
|
string taskId,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, 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, progress);
|
|
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, progress);
|
|
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. " +
|
|
"Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " +
|
|
"(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)]
|
|
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, 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(
|
|
"List every conflict hunk left in a paused merge (started via merge_task/review_task with " +
|
|
"leaveConflictsInTree=true), so you can resolve them with resolve_conflict_hunk instead of parsing marker " +
|
|
"text yourself. Each hunk carries the file's path, its index within that file (what resolve_conflict_hunk " +
|
|
"expects back), and its startLine. remainingHunks is the total across every file — call continue_merge once " +
|
|
"it reaches 0, or abort_merge to cancel. Throws if the task has no in-progress merge." + " " + McpToolDocs.Diff3Note)]
|
|
public async Task<GetMergeConflictsResultDto> GetMergeConflicts(string taskId, CancellationToken cancellationToken = default)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
|
var files = docs.Files.Select(ToConflictFileHunksDto).ToList();
|
|
var remaining = files.Sum(f => f.Hunks.Count);
|
|
return new GetMergeConflictsResultDto(files, remaining);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Resolve exactly one conflict hunk from get_merge_conflicts, identified by file path and its index within " +
|
|
"that file. Writes only that hunk — every other hunk (in this file or another) is left exactly as it was, " +
|
|
"so call get_merge_conflicts again afterward: resolved hunks disappear and every remaining hunk's index " +
|
|
"shifts down to stay zero-based. This never decides FOR you which side wins — resolution must be 'ours', " +
|
|
"'theirs', 'base' (only valid when that hunk has a diff3 base), or literal replacement text (used exactly " +
|
|
"as given, including any trailing newline the surrounding file needs). remainingHunks is the total across " +
|
|
"every file; call continue_merge once it reaches 0. Throws if the file/index don't match a current hunk." +
|
|
" " + McpToolDocs.Diff3Note)]
|
|
public async Task<ResolveConflictHunkResultDto> ResolveConflictHunk(
|
|
string taskId,
|
|
[Description("File path exactly as returned by get_merge_conflicts, relative to the repo root.")]
|
|
string file,
|
|
[Description("Zero-based hunk index within that file, as returned by get_merge_conflicts.")]
|
|
int index,
|
|
[Description("'ours', 'theirs', 'base', or literal text to use for this hunk.")]
|
|
string resolution,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrEmpty(resolution))
|
|
throw new InvalidOperationException("resolution is required.");
|
|
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
|
var doc = docs.Files.FirstOrDefault(f => string.Equals(f.Path, file, StringComparison.Ordinal))
|
|
?? throw new InvalidOperationException($"File '{file}' has no conflict for task {taskId}.");
|
|
if (doc.IsBinary)
|
|
throw new InvalidOperationException($"File '{file}' is binary; resolve it directly in the working tree.");
|
|
|
|
var hunkCount = doc.Segments.Count(s => s.IsConflict);
|
|
if (index < 0 || index >= hunkCount)
|
|
throw new InvalidOperationException(
|
|
$"Hunk index {index} is out of range for '{file}' ({hunkCount} hunk(s) remaining).");
|
|
|
|
var counter = -1;
|
|
var composed = ConflictMarkerParser.Compose(doc.Segments, seg =>
|
|
{
|
|
counter++;
|
|
if (counter != index) return ReconstructConflictMarkers(seg);
|
|
|
|
var choice = resolution.Trim().ToLowerInvariant();
|
|
return choice switch
|
|
{
|
|
"ours" => seg.Ours,
|
|
"theirs" => seg.Theirs,
|
|
"base" => seg.Base ?? throw new InvalidOperationException(
|
|
$"Hunk {index} in '{file}' has no diff3 base to resolve to."),
|
|
_ => resolution,
|
|
};
|
|
});
|
|
|
|
await _merge.WriteConflictFileAsync(taskId, file, composed, cancellationToken);
|
|
|
|
var refreshed = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
|
var remaining = refreshed.Files.Sum(f => f.Segments.Count(s => s.IsConflict));
|
|
return new ResolveConflictHunkResultDto(true, remaining);
|
|
}
|
|
|
|
private static ConflictFileHunksDto ToConflictFileHunksDto(ConflictDocumentContent f)
|
|
{
|
|
var hunks = new List<ConflictHunkDto>();
|
|
foreach (var seg in f.Segments)
|
|
{
|
|
if (!seg.IsConflict) continue;
|
|
hunks.Add(new ConflictHunkDto(hunks.Count, seg.StartLine, seg.Ours, seg.Base, seg.Theirs));
|
|
}
|
|
return new ConflictFileHunksDto(f.Path, f.IsBinary, hunks);
|
|
}
|
|
|
|
// Re-renders a still-unresolved hunk as valid (unlabeled) conflict markers so it round-trips through
|
|
// ConflictMarkerParser.Parse again untouched, and git still refuses continue_merge while it stands. The
|
|
// original marker labels (e.g. "<<<<<<< HEAD") aren't recoverable from a parsed MergeSegment and aren't
|
|
// needed for either check -- ConflictMarkerParser.IsMarker and ContinueMergeAsync's rescan only match the
|
|
// marker prefixes.
|
|
private static string ReconstructConflictMarkers(MergeSegment seg)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.Append("<<<<<<<\n").Append(seg.Ours);
|
|
if (seg.Base is not null)
|
|
sb.Append("|||||||\n").Append(seg.Base);
|
|
sb.Append("=======\n").Append(seg.Theirs);
|
|
sb.Append(">>>>>>>\n");
|
|
return sb.ToString();
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " +
|
|
"working tree, index and HEAD are untouched. status is 'clean', 'conflict' (conflictFiles lists where git " +
|
|
"would stop), or 'untracked_collision' (conflictFiles lists a path the branch adds that already exists, " +
|
|
"untracked, in the target working directory — merge-tree can't see the working tree at all, so this is the " +
|
|
"only way to catch it before a real merge either refuses or, if that path became tracked in the meantime, " +
|
|
"silently overwrites it); behind counts commits on targetBranch not yet on this branch, which flags a stale " +
|
|
"branch even when the preview is clean. If the list has a verify command configured, a clean preview is " +
|
|
"additionally built/tested in a scratch worktree (still without touching the real working tree) — " +
|
|
"verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " +
|
|
"tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " +
|
|
"isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " +
|
|
"small changedFileCount as empty. staleFiles lists files this branch touches that the target branch ALSO " +
|
|
"changed since this branch's fork point — a more honest staleness signal than `behind` alone, since a " +
|
|
"branch can be far behind yet touch nothing the target changed, or barely behind yet collide on the one " +
|
|
"file that matters (always empty for a worktree-less handler task, which has no fork point). Throws if the " +
|
|
"task has neither an active worktree nor a handler commit range, or the list's working directory is missing " +
|
|
"from disk." + McpToolDocs.ProgressHint)]
|
|
public async Task<MergePreviewToolDto> PreviewMerge(
|
|
string taskId,
|
|
[Description("Branch to preview against; defaults to the repo's current branch.")]
|
|
string? targetBranch = null,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
var (preview, behind, _, isEmpty, staleFiles, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress);
|
|
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
|
|
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
|
|
"check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
|
|
"be previewed (it is then left out of the overlap/subset computation). overlaps names, for each file " +
|
|
"touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no " +
|
|
"overlaps. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
|
|
"SupersetTaskId's — the strongest hint you get post-hoc that TaskId may be redundant with SupersetTaskId, " +
|
|
"worth checking before merging both. IMPORTANT: neither overlap nor subset is a safety guarantee — two " +
|
|
"tasks touching entirely different files (one deleting a symbol, another still referencing it) can still " +
|
|
"collide unflagged, and as with preview_merge a clean result does not mean the merge builds. " +
|
|
"runVerify=false (default) never builds — set it true to also run each task's list's verify command in a " +
|
|
"scratch worktree per entry (same fields as preview_merge); this can take a long time across many tasks, " +
|
|
"since builds run one at a time." + McpToolDocs.ProgressHint)]
|
|
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
|
|
IReadOnlyList<string> taskIds,
|
|
[Description("Branch to preview every task against; defaults to the repo's current branch.")]
|
|
string? targetBranch = null,
|
|
[Description("true: also run the verify command (if configured) for each task, one build at a time. " +
|
|
"false (default): no builds, however many tasks are given.")]
|
|
bool runVerify = false,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
if (taskIds is null || taskIds.Count == 0)
|
|
throw new InvalidOperationException("taskIds must contain at least one task id.");
|
|
|
|
taskIds = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken);
|
|
|
|
var entries = new List<MergePreviewSetEntryDto>();
|
|
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
|
|
var numbersByTask = new Dictionary<string, int>();
|
|
|
|
for (var i = 0; i < taskIds.Count; i++)
|
|
{
|
|
var taskId = taskIds[i];
|
|
try
|
|
{
|
|
var (preview, behind, changedFiles, isEmpty, staleFiles, number) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
|
|
entries.Add(new MergePreviewSetEntryDto(
|
|
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
|
|
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number));
|
|
filesByTask[taskId] = changedFiles;
|
|
numbersByTask[taskId] = number;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
|
|
entries.Add(new MergePreviewSetEntryDto(
|
|
taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message,
|
|
Number: task?.Number));
|
|
}
|
|
|
|
ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
|
|
}
|
|
|
|
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 =>
|
|
{
|
|
var ids = g.Select(x => x.TaskId).Distinct().ToList();
|
|
return new FileOverlapDto(g.Key, ids, ids.Select(id => numbersByTask[id]).ToList());
|
|
})
|
|
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
var subsets = FindSubsetRelations(filesByTask, numbersByTask);
|
|
|
|
return new MergePreviewSetResultDto(entries, overlaps, subsets);
|
|
}
|
|
|
|
// A task's changed-file set is flagged only against another task's STRICTLY LARGER set that
|
|
// contains every one of its files -- an empty set (isEmpty task) never qualifies, since that
|
|
// case is already surfaced via IsEmpty and would otherwise match every other task trivially.
|
|
private static List<SubsetRelationDto> FindSubsetRelations(
|
|
Dictionary<string, IReadOnlyList<string>> filesByTask, Dictionary<string, int> numbersByTask)
|
|
{
|
|
var fileSets = filesByTask.ToDictionary(
|
|
kv => kv.Key,
|
|
kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase));
|
|
|
|
var subsets = new List<SubsetRelationDto>();
|
|
foreach (var (taskId, fileSet) in fileSets)
|
|
{
|
|
if (fileSet.Count == 0) continue;
|
|
foreach (var (otherTaskId, otherFileSet) in fileSets)
|
|
{
|
|
if (otherTaskId == taskId || otherFileSet.Count <= fileSet.Count) continue;
|
|
if (fileSet.IsSubsetOf(otherFileSet))
|
|
subsets.Add(new SubsetRelationDto(taskId, numbersByTask[taskId], otherTaskId, numbersByTask[otherTaskId]));
|
|
}
|
|
}
|
|
|
|
return subsets
|
|
.OrderBy(s => s.TaskId, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(s => s.SupersetTaskId, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
|
|
// 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, plus
|
|
// `isEmpty`. A worktree-less list-handler host task has no branch to merge-tree-preview at all
|
|
// (its commits already sit on the list's working dir) — falls back to the fixed
|
|
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
|
|
// range's own diff-stat instead of throwing "has no worktree".
|
|
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number)> PreviewMergeCoreAsync(
|
|
string taskId, string? targetBranch, bool runVerify, CancellationToken ct,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
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);
|
|
|
|
if (wt is not null)
|
|
{
|
|
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 ?? "", runVerify, ct, progress);
|
|
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: ct))
|
|
: Array.Empty<string>();
|
|
|
|
// What the target itself picked up since this branch's fork point, so `behind`
|
|
// (a commit count) doesn't have to stand in for "does this collide" -- a branch can
|
|
// be far behind but touch nothing the target changed, or close behind and collide on
|
|
// the one file that matters.
|
|
var targetChangedSinceFork = await _git.GetChangedFileNamesAsync(list.WorkingDir, wt.BaseCommit, target, ct);
|
|
var staleFiles = changedFiles
|
|
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number);
|
|
}
|
|
|
|
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
|
|
throw new InvalidOperationException("The list's working directory no longer exists.");
|
|
|
|
var isEmpty = string.Equals(handlerBase, handlerHead, StringComparison.Ordinal);
|
|
var changedFiles = isEmpty
|
|
? Array.Empty<string>()
|
|
: ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct: ct));
|
|
|
|
var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty<string>(), changedFiles.Count);
|
|
// No fork point to diff against: a handler task commits straight onto the list's
|
|
// working dir instead of a branch, so there is nothing else that could have "changed
|
|
// in the target since the fork".
|
|
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number);
|
|
}
|
|
|
|
throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Undo a merged task by reverting its merge commit — `git revert -m 1`, always a new commit and never a " +
|
|
"reset/rewrite, since 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 (check get_task_worktree's mergeCommit " +
|
|
"first). On success the task returns to WaitingForReview so it can be reconsidered. On conflict the revert " +
|
|
"is aborted immediately and conflicts lists the files. 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,
|
|
[Description("Branch carrying the merge commit; defaults to main.")]
|
|
string targetBranch = "main",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
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(
|
|
"Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " +
|
|
"the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk." +
|
|
McpToolDocs.ProgressHint)]
|
|
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
|
|
// One git status + one rev-parse per row, run concurrently -- with many tracked worktrees
|
|
// (this tool's whole point is surfacing leftovers nobody cleaned up) that can still take a
|
|
// while, so this reports on the same elapsed-time cadence as a single long git call rather
|
|
// than per-row (rows finish out of order under Task.WhenAll, so there's no natural i/n).
|
|
var results = await ProgressReporter.RunAsync(
|
|
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.TaskNumber, row.Path, row.BranchName, headCommit,
|
|
isDirty, row.State == WorktreeState.Merged);
|
|
})),
|
|
ProgressReportInterval, progress, "surveying worktrees");
|
|
return results;
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " +
|
|
"went too." + McpToolDocs.NotWhileRunning)]
|
|
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
|
|
string taskId,
|
|
[Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " +
|
|
"those changes.")]
|
|
bool force = false,
|
|
CancellationToken cancellationToken = default,
|
|
IProgress<ProgressNotificationValue>? progress = null)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
|
|
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 ProgressReporter.RunAsync(
|
|
_git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes");
|
|
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 ProgressReporter.RunAsync(
|
|
_maintenance.ForceRemoveAsync(taskId, cancellationToken), ProgressReportInterval, progress, "removing worktree");
|
|
return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted, task.Number);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Send a follow-up prompt to a task's existing Claude session instead of starting a fresh run — the agent " +
|
|
"resumes via --resume with the session id from the task's last run, so its prior context is kept. Uses the " +
|
|
"same single-occupancy override slot as run_task_now and throws \"Override slot busy\" when that is taken.")]
|
|
public async Task<string> ContinueTask(
|
|
string taskId,
|
|
string followUpPrompt,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(followUpPrompt))
|
|
throw new InvalidOperationException("followUpPrompt is required.");
|
|
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
|
|
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: the open tasks eligible for today's MyDay selection. candidates are Idle, unblocked, " +
|
|
"non-manual and in a git repo not excluded from the weekly report; currentMyDay are Idle tasks already " +
|
|
"flagged and count toward maxTasks, the hard cap on open MyDay tasks. Add your picks with set_my_day and " +
|
|
"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. Setting it is rejected once the MyDay cap " +
|
|
"(DailyPrepMaxTasks open MyDay tasks) would be exceeded; clearing is always allowed." +
|
|
McpToolDocs.LeanTaskRef)]
|
|
public async Task<TaskRefDto> SetMyDay(
|
|
string taskId,
|
|
bool isMyDay,
|
|
[Description("Position in the MyDay list; use consecutive values to keep related tasks together.")]
|
|
int? sortOrder = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
|
|
|
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.Number, 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, bool blocked = false, string? blockedReason = null) => new(
|
|
t.Id,
|
|
t.Number,
|
|
t.ListId,
|
|
t.Title,
|
|
t.Description,
|
|
t.Status.ToString(),
|
|
t.Result,
|
|
t.CreatedBy,
|
|
t.CreatedAt,
|
|
t.StartedAt,
|
|
t.FinishedAt,
|
|
t.IsMyDay,
|
|
t.SortOrder,
|
|
t.RoadblockCount,
|
|
FailureReasonOf(t),
|
|
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
|
|
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
|
|
t.DependsOnTaskId,
|
|
blocked,
|
|
blockedReason);
|
|
|
|
private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
|
|
t.Id,
|
|
t.Number,
|
|
t.ListId,
|
|
t.Title,
|
|
t.Status.ToString(),
|
|
t.SortOrder,
|
|
t.IsMyDay,
|
|
t.RoadblockCount,
|
|
FailureReasonOf(t),
|
|
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
|
|
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
|
|
t.DependsOnTaskId,
|
|
blocked,
|
|
blockedReason);
|
|
|
|
// "unknown" covers a Failed task that predates this field (never got a classified reason
|
|
// stamped) — a defined value rather than null so callers don't have to special-case it.
|
|
private static string? FailureReasonOf(TaskEntity t) =>
|
|
t.Status == TaskStatus.Failed ? (t.FailureReason ?? "unknown") : null;
|
|
}
|
|
|
|
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('\\');
|
|
}
|