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 MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList? 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, // User/MCP-declared predecessors (set via add_task/update_task), distinct from the planning // chain's own internal BlockedByTaskId link. Empty unless the task declares dependencies. IReadOnlyList? DependsOnTaskIds = null, // True only while Status is Queued and the picker will not claim this task yet -- either a // planning-chain predecessor or some dependsOnTaskIds entry hasn't reached Done. See BlockedReason. bool Blocked = false, string? BlockedReason = null, bool IsManual = false, bool ExpectsNoDiff = false); // 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, IReadOnlyList? DependsOnTaskIds = null, bool Blocked = false, string? BlockedReason = null, DirtyBaseWarning? BaseDirty = null, bool IsManual = false, bool ExpectsNoDiff = false); // 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? Tasks, IReadOnlyList? 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 PossibleDuplicates); 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 Candidates, IReadOnlyList 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 _dbFactory; private readonly TaskMergeService _merge; private readonly PlanningMergeOrchestrator _planningMerge; private readonly BaseDirtyChecker _baseDirtyChecker; public ExternalMcpService( TaskRepository tasks, ListRepository lists, QueueService queue, HubBroadcaster broadcaster, ITaskStateService state, GitService git, IDbContextFactory dbFactory, TaskMergeService merge, PlanningMergeOrchestrator planningMerge, BaseDirtyChecker baseDirtyChecker) { _tasks = tasks; _lists = lists; _queue = queue; _broadcaster = broadcaster; _state = state; _git = git; _dbFactory = dbFactory; _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> 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 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(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 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 dependency) 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> ComputeBlockedInfoAsync( IReadOnlyList tasks, CancellationToken ct) { var dependencyIds = tasks .Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null) .SelectMany(t => t.DependsOnTaskIds) .Distinct() .ToList(); var predecessors = new Dictionary(); if (dependencyIds.Count > 0) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); predecessors = await ctx.Tasks.AsNoTracking() .Where(t => dependencyIds.Contains(t.Id)) .Select(t => new { t.Id, t.Status, Merged = t.Worktree != null && t.Worktree.State == WorktreeState.Merged, }) .ToDictionaryAsync(t => t.Id, t => (t.Status, t.Merged), ct); } var result = new Dictionary(); 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 { var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, predecessors); result[t.Id] = unmet.Count == 0 ? (false, null) : (true, TaskBlocking.Describe(unmet)); } } 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|" + "usage_limit|error|cancelled|unknown) plus failureTurnsUsed/failureMaxTurns say why without pulling get_task_log." + McpToolDocs.TaskNumberHint)] public async Task 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 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 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 whose merge must have landed before the picker will claim this one, even once " + "queued (Done, or merged and still awaiting the batch verify). Shorthand for a " + "one-element dependsOnTaskIds; ignored when that is given. " + "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, [Description("Ids of ALL tasks whose merges must have landed before the picker will claim this one. Use this when a " + "task wires together, or verifies, the results of several independent tasks -- one waiting on " + "A and B is the normal shape of a wave-style plan. Same rejection rules as dependsOnTaskId.")] string[]? dependsOnTaskIds = null, [Description("true: this task's correct outcome may be an empty diff (verification/audit/research work " + "that doesn't change code) — the merge helper won't treat a 0-changed-files result as a red " + "flag. false (default): an empty diff is treated as suspicious, not a clean success.")] bool expectsNoDiff = false, 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 dependsOn = await ResolveDependsOnAsync(dependsOnTaskId, dependsOnTaskIds, 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, ExpectsNoDiff = expectsNoDiff, }; await _tasks.AddAsync(entity, cancellationToken); if (dependsOn is { Count: > 0 }) { var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOn, cancellationToken); if (!dependsResult.Ok) throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskIds."); } if (queueImmediately) { var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken); if (!enqueue.Ok) throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task."); } await _broadcaster.TaskUpdated(entity.Id); // Reload rather than patching `entity`: SetDependsOnAsync and EnqueueAsync both write // through their own context, and adding the edges to this still-tracked entity's nav // would queue a duplicate INSERT on the next SaveChanges. var reload = (await _tasks.GetByIdAsync(entity.Id, cancellationToken))!; var blocked = await ComputeBlockedInfoAsync([reload], cancellationToken); return new AddTaskResult(ToRefDto(reload, blocked[reload.Id].Blocked, blocked[reload.Id].Reason), possibleDuplicates); } // Folds add_task/update_task's two dependency parameters into one resolved set. // `dependsOnTaskIds` wins when given; otherwise the legacy scalar form is treated as a // one-element set, with the empty string still its documented "clear it" sentinel. // Returns null only when neither parameter was passed = leave the task's set untouched. private async Task?> ResolveDependsOnAsync( string? dependsOnTaskId, string[]? dependsOnTaskIds, CancellationToken ct) { if (dependsOnTaskIds is not null) { var resolved = new List(); foreach (var raw in dependsOnTaskIds) { var id = await TaskIdResolver.ResolveOptionalAsync(_tasks, raw, ct); if (id.NullIfBlank() is { } v) resolved.Add(v); } return resolved; } if (dependsOnTaskId is null) return null; // TaskIdResolver passes "" through untouched, so NullIfBlank still yields the clear signal. var single = (await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, ct)).NullIfBlank(); return single is null ? [] : [single]; } // 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> 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 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 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, dependsOn link, and/or manual/expects-no-diff " + "flags. Pass null to leave a field unchanged." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)] public async Task UpdateTask( string taskId, string? title = null, string? description = null, string? commitType = null, [Description("Id of a task whose merge must have landed (Done, or merged and still awaiting the batch " + "verify) before the picker will claim this one. Shorthand for a " + "one-element dependsOnTaskIds; ignored when that is given. Pass an empty " + "string to clear all existing links; null leaves them unchanged. Rejected if it doesn't exist, " + "is this task's own id, or would create a dependency cycle.")] string? dependsOnTaskId = null, [Description("Ids of ALL tasks whose merges must have landed before the picker will claim this one, replacing the " + "task's current set. Pass an empty array to clear it; null leaves it unchanged. Use this when " + "a task wires together, or verifies, the results of several independent tasks.")] string[]? dependsOnTaskIds = null, [Description("true: mark this task as a manual reminder only the user can complete — the queue picker, " + "daily prep, and the list handler all skip it (a ConPTY session may still touch it). false: " + "clear the flag. null (default): leave unchanged.")] bool? isManual = null, [Description("true: this task's correct outcome may be an empty diff (verification/audit/research work " + "that doesn't change code) — the merge helper won't treat a 0-changed-files result as a red " + "flag. false: clear the flag. null (default): leave unchanged.")] bool? expectsNoDiff = 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; if (isManual is not null) task.IsManual = isManual.Value; if (expectsNoDiff is not null) task.ExpectsNoDiff = expectsNoDiff.Value; await _tasks.UpdateAsync(task, cancellationToken); var dependsOn = await ResolveDependsOnAsync(dependsOnTaskId, dependsOnTaskIds, cancellationToken); if (dependsOn is not null) { var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOn, cancellationToken); if (!dependsResult.Ok) throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskIds."); } 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 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 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). 'Idle' and " + "'Done' are both refused while the task is Running — cancel it first. No other value is " + "settable externally.")] string status, CancellationToken cancellationToken) { if (!Enum.TryParse(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: var idleResult = await _state.ResetToIdleAsync(taskId, cancellationToken); if (!idleResult.Ok) throw new InvalidOperationException(idleResult.Reason ?? "Cannot reset task to Idle."); 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: if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot mark a running task Done — cancel it first."); 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 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, [Description("Batch mode for an approve: merge normally but defer the list's verify command instead of " + "running it after this one merge. The task then stays WaitingForReview with mergeStatus " + "merged_verify_pending; after the LAST merge of the batch call verify_merges once with every " + "pending task id — it runs the verify command a single time and promotes them all to Done. " + "No-op when the list has no verify command.")] bool skipVerify = false, CancellationToken cancellationToken = default, IProgress? 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 mergeConflicts = Array.Empty(); string? mergeMessage = null; string? repoPath = null; IReadOnlyList? 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. var startResult = await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken, externallyDriven: true, progress, skipVerify); if (startResult.Status == TaskMergeService.StatusBlocked) throw new InvalidOperationException(startResult.Reason ?? "approve failed"); mergeStatus = startResult.Status; if (startResult.Status == TaskMergeService.StatusConflict) { 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 if (startResult.Status == TaskMergeService.StatusMergedVerifyPending) { mergeMessage = "unit merged; verify deferred — the parent stays WaitingForReview until " + "verify_merges (with this parent's id) passes"; } else if (startResult.Status != TaskMergeService.StatusMerged) { mergeMessage = startResult.Reason; } } else { var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, cancellationToken, progress, skipVerify); // dirty_target is a refusal like blocked -- only the UI turns it into a stash // retry; an MCP session must not read it as an approve that went through. if (r.Status is TaskMergeService.StatusBlocked or TaskMergeService.StatusDirtyTarget) throw new InvalidOperationException(r.ErrorMessage ?? "approve failed"); mergeStatus = r.Status; mergeConflicts = r.ConflictFiles; if (r.Status == TaskMergeService.StatusMergedVerifyPending) { mergeMessage = "merged; verify deferred — the task stays WaitingForReview until " + "verify_merges (with its id) passes"; } else 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> GetEmptyDoneChildrenAsync(string parentTaskId, CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var children = await ctx.Tasks .AsNoTracking() .Include(t => t.Worktree) .Include(t => t.Dependencies) .Where(t => t.ParentTaskId == parentTaskId && t.Status == TaskStatus.Done) .ToListAsync(ct); var empty = new List(); foreach (var child in children) { if (await IsChildEmptyAsync(child, ct)) empty.Add(ToRefDto(child)); } return empty; } private async Task 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 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 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 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."); try { await _tasks.DeleteAsync(taskId, cancellationToken); } // TaskRepository.DeleteAsync uses ExecuteDeleteAsync, which bypasses SaveChanges and // surfaces provider errors directly as SqliteException rather than DbUpdateException. catch (Exception ex) when ( (ex is Microsoft.Data.Sqlite.SqliteException || ex.InnerException is Microsoft.Data.Sqlite.SqliteException) && (ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) || ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)) { throw new InvalidOperationException( "This task has child tasks. Discard the planning session or delete child tasks first."); } if (task.ParentTaskId is not null) await _state.TryAdvanceParentAsync(task.ParentTaskId); await _broadcaster.TaskUpdated(taskId); return new DeleteTaskResult(true, taskId, 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 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 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 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.Include(t => t.Dependencies) .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 ─────────────────────────────────────────────────────── // internal: also used by MergeMcpTools/WorktreeMcpTools' diff-stat parsing, which need it via // an ExternalMcpService.ParseDiffStatFileNames(...) call since a private member of one class // isn't visible from another, even in the same namespace. internal static IReadOnlyList ParseDiffStatFileNames(string stat) { var files = new List(); 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.DependsOnTaskIds, blocked, blockedReason, t.IsManual, t.ExpectsNoDiff); // internal: AbortMerge in MergeMcpTools also needs this to build its TaskRefDto result. internal 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.DependsOnTaskIds, blocked, blockedReason, BaseDirty: null, IsManual: t.IsManual, ExpectsNoDiff: t.ExpectsNoDiff); // "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>(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('\\'); }