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:
+42
-28
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user