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:
mika kuns
2026-08-11 13:10:49 +02:00
parent 9660a29da4
commit 2a3133efad
14 changed files with 298 additions and 7 deletions
+41 -5
View File
@@ -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)