feat(worker): accept #123 task numbers as MCP tool input
TaskIdResolver resolves a #123/bare-123 taskId parameter to its GUID before any lookup, across every External/ MCP tool that takes a task id, including the batch tools' id arrays (via delegation to the already-resolving single-entity methods) and update_task's dependsOnTaskId (empty string still passes through unchanged as the clear-link sentinel). An unknown number throws a clear error instead of a silent null. McpToolDocs.TaskNumberHint tells the agent to refer to tasks as #<number> when reporting to the user, added to the description of get_task, list_tasks, add_task, update_task_status and review_task.
This commit is contained in:
+41
-5
@@ -230,7 +230,8 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"List the tasks in one list — the usual way to find a taskId. Optionally filter by creator and/or status.")]
|
||||
"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.")]
|
||||
@@ -323,9 +324,11 @@ public sealed class ExternalMcpService
|
||||
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution. A Queued task with a blocker waits " +
|
||||
"for its predecessor before the picker will claim it, and WaitingForChildren is a parent whose own work " +
|
||||
"is done but whose children are still running. For Status=Failed, failureReason (max_turns|timeout|" +
|
||||
"error|cancelled|unknown) plus failureTurnsUsed/failureMaxTurns say why without pulling get_task_log.")]
|
||||
"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);
|
||||
@@ -336,6 +339,7 @@ public sealed class ExternalMcpService
|
||||
// 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);
|
||||
@@ -345,7 +349,7 @@ public sealed class ExternalMcpService
|
||||
[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)]
|
||||
"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,
|
||||
@@ -372,6 +376,8 @@ public sealed class ExternalMcpService
|
||||
var list = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
|
||||
dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken);
|
||||
|
||||
var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken);
|
||||
|
||||
var entity = new TaskEntity
|
||||
@@ -495,6 +501,7 @@ public sealed class ExternalMcpService
|
||||
string? dependsOnTaskId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status == TaskStatus.Running)
|
||||
@@ -507,6 +514,10 @@ public sealed class ExternalMcpService
|
||||
|
||||
if (dependsOnTaskId is not null)
|
||||
{
|
||||
// Empty string is a deliberate sentinel (see the dependsOnTaskId parameter doc) to
|
||||
// clear the link — TaskIdResolver passes it through untouched, then NullIfBlank
|
||||
// below still turns it into the clear signal SetDependsOnAsync expects.
|
||||
dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken);
|
||||
var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOnTaskId.NullIfBlank(), cancellationToken);
|
||||
if (!dependsResult.Ok)
|
||||
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
|
||||
@@ -531,6 +542,8 @@ public sealed class ExternalMcpService
|
||||
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);
|
||||
@@ -559,7 +572,8 @@ public sealed class ExternalMcpService
|
||||
|
||||
[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)]
|
||||
"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 " +
|
||||
@@ -573,6 +587,7 @@ public sealed class ExternalMcpService
|
||||
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.");
|
||||
|
||||
@@ -629,7 +644,7 @@ public sealed class ExternalMcpService
|
||||
"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)]
|
||||
"delivered something." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)]
|
||||
public async Task<ReviewTaskResult> ReviewTask(
|
||||
string taskId,
|
||||
[Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")]
|
||||
@@ -647,6 +662,7 @@ public sealed class ExternalMcpService
|
||||
bool leaveConflictsInTree = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
@@ -781,6 +797,7 @@ public sealed class ExternalMcpService
|
||||
"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);
|
||||
@@ -809,6 +826,7 @@ public sealed class ExternalMcpService
|
||||
"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);
|
||||
@@ -820,6 +838,7 @@ public sealed class ExternalMcpService
|
||||
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)
|
||||
@@ -841,6 +860,7 @@ public sealed class ExternalMcpService
|
||||
"Throws if the task or its worktree does not exist.")]
|
||||
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
|
||||
|
||||
var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit)
|
||||
@@ -871,6 +891,7 @@ public sealed class ExternalMcpService
|
||||
IReadOnlyList<string>? paths = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
|
||||
|
||||
const int maxBytes = 200 * 1024;
|
||||
@@ -942,6 +963,7 @@ public sealed class ExternalMcpService
|
||||
bool leaveConflictsInTree = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
var canMerge = task.Status == TaskStatus.Done ||
|
||||
@@ -996,6 +1018,7 @@ public sealed class ExternalMcpService
|
||||
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")]
|
||||
public async Task<MergeContinuationResultDto> ContinueMerge(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
||||
@@ -1064,6 +1087,7 @@ public sealed class ExternalMcpService
|
||||
"(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)]
|
||||
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
@@ -1090,6 +1114,7 @@ public sealed class ExternalMcpService
|
||||
"it reaches 0, or abort_merge to cancel. Throws if the task has no in-progress merge." + " " + McpToolDocs.Diff3Note)]
|
||||
public async Task<GetMergeConflictsResultDto> GetMergeConflicts(string taskId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
@@ -1121,6 +1146,7 @@ public sealed class ExternalMcpService
|
||||
if (string.IsNullOrEmpty(resolution))
|
||||
throw new InvalidOperationException("resolution is required.");
|
||||
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
|
||||
var doc = docs.Files.FirstOrDefault(f => string.Equals(f.Path, file, StringComparison.Ordinal))
|
||||
?? throw new InvalidOperationException($"File '{file}' has no conflict for task {taskId}.");
|
||||
@@ -1207,6 +1233,7 @@ public sealed class ExternalMcpService
|
||||
string? targetBranch = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
var (preview, behind, _, isEmpty, staleFiles, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken);
|
||||
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
|
||||
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
|
||||
@@ -1237,6 +1264,8 @@ public sealed class ExternalMcpService
|
||||
if (taskIds is null || taskIds.Count == 0)
|
||||
throw new InvalidOperationException("taskIds must contain at least one task id.");
|
||||
|
||||
taskIds = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken);
|
||||
|
||||
var entries = new List<MergePreviewSetEntryDto>();
|
||||
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
|
||||
var numbersByTask = new Dictionary<string, int>();
|
||||
@@ -1390,6 +1419,7 @@ public sealed class ExternalMcpService
|
||||
string targetBranch = "main",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken);
|
||||
|
||||
if (result.Status == TaskMergeService.StatusReverted)
|
||||
@@ -1433,6 +1463,8 @@ public sealed class ExternalMcpService
|
||||
bool force = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
@@ -1467,6 +1499,8 @@ public sealed class ExternalMcpService
|
||||
if (string.IsNullOrWhiteSpace(followUpPrompt))
|
||||
throw new InvalidOperationException("followUpPrompt is required.");
|
||||
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
|
||||
string result;
|
||||
try
|
||||
{
|
||||
@@ -1535,6 +1569,8 @@ public sealed class ExternalMcpService
|
||||
int? sortOrder = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
|
||||
|
||||
Reference in New Issue
Block a user