feat(worker): surface task numbers in MCP tool return payloads

Adds Number alongside every task id in External/'s DTOs -- the two
central mappers (ToDto/ToRefDto -> TaskDto/TaskRefDto) plus every
DTO that carries a bare task id and bypasses them (batch results,
queue state, wait-for-change, config, attachments, handoff,
lifecycle, merge-preview-set, worktree list). Input resolution
(#123 as an argument) stays for slice 3.
This commit is contained in:
mika kuns
2026-08-11 11:59:51 +02:00
parent 8da1a12389
commit 106c964410
12 changed files with 129 additions and 64 deletions
+42 -28
View File
@@ -21,16 +21,17 @@ 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);
public sealed record CancelTaskResult(bool Cancelled, string Id);
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);
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,
@@ -68,6 +69,7 @@ public sealed record TaskDto(
// ClaudeDo.Worker.Git.BaseDirtyChecker. Every other caller leaves it null.
public sealed record TaskRefDto(
string Id,
int Number,
string ListId,
string Title,
string Status,
@@ -92,7 +94,7 @@ public sealed record ListTasksResult(
IReadOnlyList<TaskDto>? TasksFull);
// Deliberately small: no descriptions, capped candidate count (see AddTask's duplicate check).
public sealed record PossibleDuplicateDto(string TaskId, string Title, string Status);
public sealed record PossibleDuplicateDto(string TaskId, int Number, string Title, string Status);
public sealed record AddTaskResult(TaskRefDto Task, IReadOnlyList<PossibleDuplicateDto> PossibleDuplicates);
@@ -145,32 +147,32 @@ public sealed record MergePreviewToolDto(
public sealed record MergePreviewSetEntryDto(
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false,
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
IReadOnlyList<string>? StaleFiles = null);
IReadOnlyList<string>? StaleFiles = null, int? Number = null);
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds);
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds, IReadOnlyList<int> Numbers);
// TaskId's changed-file list is a non-empty PROPER subset of SupersetTaskId's -- e.g. one task
// deletes two files and another deletes just one of those same two. Stronger than an Overlaps
// entry (which only says some files are shared): a real subset is the best available machine
// signal that TaskId's work may already be entirely covered by SupersetTaskId.
public sealed record SubsetRelationDto(string TaskId, string SupersetTaskId);
public sealed record SubsetRelationDto(string TaskId, int Number, string SupersetTaskId, int SupersetNumber);
public sealed record MergePreviewSetResultDto(
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
IReadOnlyList<SubsetRelationDto> Subsets);
public sealed record WorktreeListItemDto(
string? TaskId, string Path, string Branch,
string? TaskId, int? Number, string Path, string Branch,
string HeadCommit, bool IsDirty, bool MergedIntoMain);
public sealed record CleanupWorktreeResult(
bool Removed, string WorktreePath, bool BranchDeleted);
bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null);
public sealed record RevertMergeResultDto(
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
public sealed record DailyPrepCandidateDto(
string Id, string ListId, string ListName, string Title, string? Description,
string Id, int Number, string ListId, string ListName, string Title, string? Description,
bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);
public sealed record DailyPrepDataDto(
@@ -444,7 +446,7 @@ public sealed class ExternalMcpService
.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.Title, x.Task.Status.ToString()))
.Select(x => new PossibleDuplicateDto(x.Task.Id, x.Task.Number, x.Task.Title, x.Task.Status.ToString()))
.ToList();
}
@@ -800,7 +802,7 @@ public sealed class ExternalMcpService
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);
return new RunTaskNowResult(true, taskId, baseDirty, task?.Number);
}
[McpServerTool, Description(
@@ -809,7 +811,8 @@ public sealed class ExternalMcpService
{
var cancelled = _queue.CancelTask(taskId);
if (cancelled) await _broadcaster.TaskUpdated(taskId);
return new CancelTaskResult(cancelled, taskId);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
return new CancelTaskResult(cancelled, taskId, task?.Number);
}
[McpServerTool, Description(
@@ -826,7 +829,7 @@ public sealed class ExternalMcpService
if (task.ParentTaskId is not null)
await _state.TryAdvanceParentAsync(task.ParentTaskId);
await _broadcaster.TaskUpdated(taskId);
return new DeleteTaskResult(true, taskId);
return new DeleteTaskResult(true, taskId, task.Number);
}
// ── Worktree / git tools ──────────────────────────────────────────────────
@@ -1204,7 +1207,7 @@ public sealed class ExternalMcpService
string? targetBranch = null,
CancellationToken cancellationToken = default)
{
var (preview, behind, _, isEmpty, staleFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, 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);
}
@@ -1236,21 +1239,25 @@ public sealed class ExternalMcpService
var entries = new List<MergePreviewSetEntryDto>();
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
var numbersByTask = new Dictionary<string, int>();
foreach (var taskId in taskIds)
{
try
{
var (preview, behind, changedFiles, isEmpty, staleFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken);
var (preview, behind, changedFiles, isEmpty, staleFiles, number) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken);
entries.Add(new MergePreviewSetEntryDto(
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles));
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number));
filesByTask[taskId] = changedFiles;
numbersByTask[taskId] = number;
}
catch (InvalidOperationException ex)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
entries.Add(new MergePreviewSetEntryDto(
taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message));
taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message,
Number: task?.Number));
}
}
@@ -1258,11 +1265,15 @@ public sealed class ExternalMcpService
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Select(x => x.TaskId).Distinct().Count() > 1)
.Select(g => new FileOverlapDto(g.Key, g.Select(x => x.TaskId).Distinct().ToList()))
.Select(g =>
{
var ids = g.Select(x => x.TaskId).Distinct().ToList();
return new FileOverlapDto(g.Key, ids, ids.Select(id => numbersByTask[id]).ToList());
})
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
.ToList();
var subsets = FindSubsetRelations(filesByTask);
var subsets = FindSubsetRelations(filesByTask, numbersByTask);
return new MergePreviewSetResultDto(entries, overlaps, subsets);
}
@@ -1270,7 +1281,8 @@ public sealed class ExternalMcpService
// A task's changed-file set is flagged only against another task's STRICTLY LARGER set that
// contains every one of its files -- an empty set (isEmpty task) never qualifies, since that
// case is already surfaced via IsEmpty and would otherwise match every other task trivially.
private static List<SubsetRelationDto> FindSubsetRelations(Dictionary<string, IReadOnlyList<string>> filesByTask)
private static List<SubsetRelationDto> FindSubsetRelations(
Dictionary<string, IReadOnlyList<string>> filesByTask, Dictionary<string, int> numbersByTask)
{
var fileSets = filesByTask.ToDictionary(
kv => kv.Key,
@@ -1284,7 +1296,7 @@ public sealed class ExternalMcpService
{
if (otherTaskId == taskId || otherFileSet.Count <= fileSet.Count) continue;
if (fileSet.IsSubsetOf(otherFileSet))
subsets.Add(new SubsetRelationDto(taskId, otherTaskId));
subsets.Add(new SubsetRelationDto(taskId, numbersByTask[taskId], otherTaskId, numbersByTask[otherTaskId]));
}
}
@@ -1301,7 +1313,7 @@ public sealed class ExternalMcpService
// (its commits already sit on the list's working dir) — falls back to the fixed
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
// range's own diff-stat instead of throwing "has no worktree".
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles)> PreviewMergeCoreAsync(
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number)> PreviewMergeCoreAsync(
string taskId, string? targetBranch, bool runVerify, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
@@ -1342,7 +1354,7 @@ public sealed class ExternalMcpService
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
.ToList();
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles);
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number);
}
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
@@ -1359,7 +1371,7 @@ public sealed class ExternalMcpService
// No fork point to diff against: a handler task commits straight onto the list's
// working dir instead of a branch, so there is nothing else that could have "changed
// in the target since the fork".
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>());
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number);
}
throw new InvalidOperationException($"Task {taskId} has no worktree.");
@@ -1405,7 +1417,7 @@ public sealed class ExternalMcpService
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
: "";
return new WorktreeListItemDto(
row.TaskId, row.Path, row.BranchName, headCommit,
row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit,
isDirty, row.State == WorktreeState.Merged);
}));
return results;
@@ -1440,7 +1452,7 @@ public sealed class ExternalMcpService
var path = wt.Path;
var result = await _maintenance.ForceRemoveAsync(taskId, cancellationToken);
return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted);
return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted, task.Number);
}
[McpServerTool, Description(
@@ -1548,7 +1560,7 @@ public sealed class ExternalMcpService
}
private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
t.Id, t.Number, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
t.IsStarred, t.ScheduledFor, t.CreatedAt);
// ── Private helpers ───────────────────────────────────────────────────────
@@ -1628,6 +1640,7 @@ public sealed class ExternalMcpService
private static TaskDto ToDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
t.Id,
t.Number,
t.ListId,
t.Title,
t.Description,
@@ -1649,6 +1662,7 @@ public sealed class ExternalMcpService
private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
t.Id,
t.Number,
t.ListId,
t.Title,
t.Status.ToString(),