Ein Task kann jetzt auf beliebig viele Vorgaenger warten. Die Einzelspalte tasks.depends_on_task_id wird durch die Beziehungstabelle task_dependencies ersetzt (Composite-PK, beide FKs ON DELETE CASCADE -- das reproduziert das alte ON DELETE SET NULL: geloeschter Vorgaenger = Kante weg = Task wieder pickbar). BlockedByTaskId bleibt unberuehrt. - Migration AddTaskDependencies kopiert jede bestehende depends_on_task_id verlustfrei in die Tabelle, bevor die Spalte fallengelassen wird - QueuePicker gatet per doppelt negiertem NOT EXISTS: kein Task mit 0 Kanten wird blockiert, alle Kanten muessen done sein, eine Kante auf einen verschwundenen Task blockiert weiter - SetDependsOnAsync ersetzt die ganze Menge; Zykluspruefung ist jetzt eine BFS ueber den Graphen, faengt also auch A->B->C->A - blockedReason nennt alle offenen Vorgaenger (External/TaskBlocking, von ExternalMcpService und TaskWaitMcpTools gemeinsam genutzt) - add_task/update_task nehmen dependsOnTaskIds; die alte Skalarform bleibt als Ein-Element-Menge inkl. ""-Clear-Sentinel gueltig - UI: Fan-in-Zeile bleibt flach und listet alle Vorgaenger im After-Label, die lineare Kette rendert unveraendert
1081 lines
54 KiB
C#
1081 lines
54 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,
|
|
// 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<string>? 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<string>? 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<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 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 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<ClaudeDoDbContext> 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<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 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<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)
|
|
.SelectMany(t => t.DependsOnTaskIds)
|
|
.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
|
|
{
|
|
var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, dependencyStatuses);
|
|
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<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. 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 that must reach Done 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<IReadOnlyList<string>?> ResolveDependsOnAsync(
|
|
string? dependsOnTaskId, string[]? dependsOnTaskIds, CancellationToken ct)
|
|
{
|
|
if (dependsOnTaskIds is not null)
|
|
{
|
|
var resolved = new List<string>();
|
|
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<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, dependsOn link, and/or manual/expects-no-diff " +
|
|
"flags. 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. 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 that must reach Done 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<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). '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<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:
|
|
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<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,
|
|
[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<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.
|
|
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);
|
|
if (r.Status == TaskMergeService.StatusBlocked)
|
|
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<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)
|
|
.Include(t => t.Dependencies)
|
|
.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.");
|
|
|
|
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<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.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<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.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<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('\\');
|
|
}
|