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:
+2
-2
@@ -10,7 +10,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record AttachmentDto(string FileName, long ByteSize, DateTime CreatedAt);
|
||||
public sealed record RemoveAttachmentResult(bool Removed, string TaskId, string FileName);
|
||||
public sealed record RemoveAttachmentResult(bool Removed, string TaskId, int Number, string FileName);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class AttachmentMcpTools
|
||||
@@ -115,6 +115,6 @@ public sealed class AttachmentMcpTools
|
||||
_store.DeleteFile(taskId, fileName);
|
||||
await _attachments.DeleteAsync(taskId, fileName, ct);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new RemoveAttachmentResult(true, taskId, fileName);
|
||||
return new RemoveAttachmentResult(true, taskId, task.Number, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-12
@@ -29,6 +29,7 @@ public sealed record BatchGetTaskResult(string Id, bool Found, TaskRefDto? Task,
|
||||
// run reported, without pulling in the rest of Result/Description to get them.
|
||||
public sealed record BatchTaskDetailDto(
|
||||
string Id,
|
||||
int Number,
|
||||
string? ListId = null,
|
||||
string? Title = null,
|
||||
string? Description = null,
|
||||
@@ -51,9 +52,9 @@ public sealed record BatchAddTaskResult(
|
||||
IReadOnlyList<PossibleDuplicateDto>? PossibleDuplicates, string? Error);
|
||||
// BaseDirty mirrors TaskRefDto.BaseDirty: populated only for BatchUpdateTaskStatus items that
|
||||
// just transitioned to Queued against a list whose working dir has uncommitted changes.
|
||||
public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error, DirtyBaseWarning? BaseDirty = null);
|
||||
public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error);
|
||||
public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error);
|
||||
public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error, DirtyBaseWarning? BaseDirty = null, int? Number = null);
|
||||
public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error, int? Number = null);
|
||||
public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error, int? Number = null);
|
||||
|
||||
/// <summary>
|
||||
/// Batch variants of the single-entity tools on <see cref="ExternalMcpService"/>.
|
||||
@@ -166,6 +167,7 @@ public sealed class BatchMcpTools
|
||||
|
||||
return new BatchTaskDetailDto(
|
||||
Id: t.Id,
|
||||
Number: t.Number,
|
||||
ListId: Want("listId") ? t.ListId : null,
|
||||
Title: Want("title") ? t.Title : null,
|
||||
Description: description,
|
||||
@@ -252,7 +254,7 @@ public sealed class BatchMcpTools
|
||||
try
|
||||
{
|
||||
var task = await _svc.UpdateTaskStatus(id, status, cancellationToken);
|
||||
results.Add(new BatchTaskResult(id, true, null, task.BaseDirty));
|
||||
results.Add(new BatchTaskResult(id, true, null, task.BaseDirty, task.Number));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
@@ -277,7 +279,7 @@ public sealed class BatchMcpTools
|
||||
try
|
||||
{
|
||||
var r = await _svc.CancelTask(id, cancellationToken);
|
||||
results.Add(new BatchCancelResult(id, true, r.Cancelled, null));
|
||||
results.Add(new BatchCancelResult(id, true, r.Cancelled, null, r.Number));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
@@ -296,7 +298,7 @@ public sealed class BatchMcpTools
|
||||
{
|
||||
EnsureWithinCap(taskIds, nameof(taskIds));
|
||||
return await RunPerTaskAsync(taskIds,
|
||||
(id, ct) => _svc.DeleteTask(id, ct), cancellationToken);
|
||||
async (id, ct) => (await _svc.DeleteTask(id, ct)).Number, cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -313,8 +315,8 @@ public sealed class BatchMcpTools
|
||||
{
|
||||
try
|
||||
{
|
||||
await _svc.SetMyDay(item.TaskId, item.IsMyDay, item.SortOrder, cancellationToken);
|
||||
results.Add(new BatchTaskResult(item.TaskId, true, null));
|
||||
var task = await _svc.SetMyDay(item.TaskId, item.IsMyDay, item.SortOrder, cancellationToken);
|
||||
results.Add(new BatchTaskResult(item.TaskId, true, null, Number: task.Number));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
@@ -342,7 +344,7 @@ public sealed class BatchMcpTools
|
||||
try
|
||||
{
|
||||
var r = await _svc.CleanupTaskWorktree(id, force, cancellationToken);
|
||||
results.Add(new BatchCleanupResult(id, true, r.Removed, r.BranchDeleted, null));
|
||||
results.Add(new BatchCleanupResult(id, true, r.Removed, r.BranchDeleted, null, r.Number));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
@@ -354,15 +356,15 @@ public sealed class BatchMcpTools
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<BatchTaskResult>> RunPerTaskAsync(
|
||||
string[] taskIds, Func<string, CancellationToken, Task> op, CancellationToken cancellationToken)
|
||||
string[] taskIds, Func<string, CancellationToken, Task<int?>> op, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<BatchTaskResult>(taskIds.Length);
|
||||
foreach (var id in taskIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
await op(id, cancellationToken);
|
||||
results.Add(new BatchTaskResult(id, true, null));
|
||||
var number = await op(id, cancellationToken);
|
||||
results.Add(new BatchTaskResult(id, true, null, Number: number));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
|
||||
+4
-2
@@ -14,7 +14,7 @@ public sealed record ListConfigDto(string? Model, string? SystemPrompt, string?
|
||||
public sealed record TaskConfigResult(bool Found, TaskConfigDto? Config);
|
||||
public sealed record ListConfigResult(bool Found, ListConfigDto? Config);
|
||||
public sealed record SetListConfigResult(bool Ok, string ListId, ListConfigDto? Config);
|
||||
public sealed record SetTaskConfigResult(bool Ok, string TaskId, TaskConfigDto? Config);
|
||||
public sealed record SetTaskConfigResult(bool Ok, string TaskId, int Number, TaskConfigDto? Config);
|
||||
|
||||
public sealed record EffectiveModelDto(string Value, string Source);
|
||||
public sealed record EffectiveMaxTurnsDto(int Effective, string Source, int Requested, bool Clamped);
|
||||
@@ -22,6 +22,7 @@ public sealed record EffectiveAgentPathDto(string? Value, string? Source);
|
||||
public sealed record EffectiveSystemPromptDto(bool Set, IReadOnlyList<string> Sources);
|
||||
public sealed record EffectiveRunConfigDto(
|
||||
string TaskId,
|
||||
int Number,
|
||||
EffectiveModelDto Model,
|
||||
EffectiveMaxTurnsDto MaxTurns,
|
||||
string Effort,
|
||||
@@ -158,7 +159,7 @@ public sealed class ConfigMcpTools
|
||||
|
||||
await _tasks.UpdateAgentSettingsAsync(taskId, m, sp, ap, mt, task.SessionSkills, cancellationToken);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new SetTaskConfigResult(true, taskId, new TaskConfigDto(m, sp, ap, mt));
|
||||
return new SetTaskConfigResult(true, taskId, task.Number, new TaskConfigDto(m, sp, ap, mt));
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Read this task's per-task overrides (model/system prompt/agent path/max turns), which take precedence over the list's default config. Returns { found: false, config: null } if none is set.")]
|
||||
@@ -206,6 +207,7 @@ public sealed class ConfigMcpTools
|
||||
|
||||
return new EffectiveRunConfigDto(
|
||||
taskId,
|
||||
task.Number,
|
||||
new EffectiveModelDto(effective.Model, effective.ModelSource),
|
||||
new EffectiveMaxTurnsDto(effective.MaxTurns, effective.MaxTurnsSource, effective.RequestedMaxTurns, effective.MaxTurnsClamped),
|
||||
effective.Effort,
|
||||
|
||||
+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(),
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int SurvivingCount, string NextPhase);
|
||||
public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int Number, int SurvivingCount, string NextPhase);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class HandoffMcpTools
|
||||
@@ -40,7 +40,7 @@ public sealed class HandoffMcpTools
|
||||
if (survivingTaskIds.Count == 0)
|
||||
throw new InvalidOperationException("survivingTaskIds must contain at least one task id.");
|
||||
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
foreach (var id in survivingTaskIds)
|
||||
@@ -48,6 +48,6 @@ public sealed class HandoffMcpTools
|
||||
?? throw new InvalidOperationException($"Task {id} not found.");
|
||||
|
||||
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, nextPhase);
|
||||
return new HandoffListHandlerResult(true, taskId, survivingTaskIds.Count, nextPhase);
|
||||
return new HandoffListHandlerResult(true, taskId, task.Number, survivingTaskIds.Count, nextPhase);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record ResetFailedTaskResult(bool Reset, string TaskId);
|
||||
public sealed record ResetFailedTaskResult(bool Reset, string TaskId, int Number);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class LifecycleMcpTools
|
||||
@@ -34,6 +34,6 @@ public sealed class LifecycleMcpTools
|
||||
throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool.");
|
||||
|
||||
await _reset.ResetAsync(taskId, cancellationToken);
|
||||
return new ResetFailedTaskResult(true, taskId);
|
||||
return new ResetFailedTaskResult(true, taskId, task.Number);
|
||||
}
|
||||
}
|
||||
|
||||
+26
-9
@@ -7,16 +7,17 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record QueueSlotDto(string Slot, string TaskId, DateTime StartedAt);
|
||||
public sealed record QueueSlotDto(string Slot, string TaskId, int? Number, DateTime StartedAt);
|
||||
|
||||
public sealed record QueueWaitReasonDto(string TaskId, string Reason, string BlockedByTaskId);
|
||||
public sealed record QueueWaitReasonDto(string TaskId, int Number, string Reason, string BlockedByTaskId, int? BlockedByNumber);
|
||||
|
||||
public sealed record GetQueueStateResult(
|
||||
int ConfiguredSlots,
|
||||
int EffectiveSlots,
|
||||
IReadOnlyList<QueueSlotDto> ActiveSlots,
|
||||
IReadOnlyList<string> WaitingTaskIds,
|
||||
IReadOnlyList<QueueWaitReasonDto> ScopeBlockedTasks);
|
||||
IReadOnlyList<QueueWaitReasonDto> ScopeBlockedTasks,
|
||||
IReadOnlyList<int> WaitingTaskNumbers);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class QueueStateMcpTools
|
||||
@@ -44,13 +45,21 @@ public sealed class QueueStateMcpTools
|
||||
public async Task<GetQueueStateResult> GetQueueState(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken);
|
||||
|
||||
var activeSlots = _queue.GetActive()
|
||||
.Select(a => new QueueSlotDto(a.slot, a.taskId, a.startedAt))
|
||||
.ToList();
|
||||
var active = _queue.GetActive();
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var activeTaskIds = active.Select(a => a.taskId).ToList();
|
||||
var activeNumbers = await ctx.Tasks
|
||||
.Where(t => activeTaskIds.Contains(t.Id))
|
||||
.Select(t => new { t.Id, t.Number })
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Number, cancellationToken);
|
||||
|
||||
var activeSlots = active
|
||||
.Select(a => new QueueSlotDto(a.slot, a.taskId, activeNumbers.TryGetValue(a.taskId, out var n) ? n : null, a.startedAt))
|
||||
.ToList();
|
||||
|
||||
var waiting = await ctx.Tasks
|
||||
.Where(t => t.Status == TaskStatus.Queued
|
||||
&& t.BlockedByTaskId == null
|
||||
@@ -73,10 +82,18 @@ public sealed class QueueStateMcpTools
|
||||
if (!serializingListIds.Contains(t.ListId)) continue;
|
||||
var blockerId = await ScopeOverlap.FindBlockingSiblingAsync(ctx, t, cancellationToken);
|
||||
if (blockerId is not null)
|
||||
scopeBlocked.Add(new QueueWaitReasonDto(t.Id, "scope_overlap", blockerId));
|
||||
{
|
||||
var blockerNumber = await ctx.Tasks
|
||||
.Where(b => b.Id == blockerId)
|
||||
.Select(b => (int?)b.Number)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
scopeBlocked.Add(new QueueWaitReasonDto(t.Id, t.Number, "scope_overlap", blockerId, blockerNumber));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new GetQueueStateResult(configured, effective, activeSlots, waiting.Select(t => t.Id).ToList(), scopeBlocked);
|
||||
return new GetQueueStateResult(
|
||||
configured, effective, activeSlots, waiting.Select(t => t.Id).ToList(), scopeBlocked,
|
||||
waiting.Select(t => t.Number).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -9,7 +9,7 @@ namespace ClaudeDo.Worker.External;
|
||||
|
||||
// BlockedReason is set only when Status is "Blocked" -- a Queued task the picker will not
|
||||
// claim yet, either because of a planning-chain predecessor or an unmet depends-on link.
|
||||
public sealed record TaskStatusChangeDto(string TaskId, string Status, string? BlockedReason = null);
|
||||
public sealed record TaskStatusChangeDto(string TaskId, string Status, string? BlockedReason = null, int? Number = null);
|
||||
public sealed record WaitForTaskChangeResult(IReadOnlyList<TaskStatusChangeDto> Changed, bool TimedOut);
|
||||
|
||||
[McpServerToolType]
|
||||
@@ -107,7 +107,7 @@ public sealed class TaskWaitMcpTools
|
||||
var rows = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Where(t => taskIds.Contains(t.Id))
|
||||
.Select(t => new { t.Id, t.Status, t.BlockedByTaskId, t.DependsOnTaskId })
|
||||
.Select(t => new { t.Id, t.Number, t.Status, t.BlockedByTaskId, t.DependsOnTaskId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var byId = rows.ToDictionary(r => r.Id, r => r);
|
||||
@@ -150,7 +150,7 @@ public sealed class TaskWaitMcpTools
|
||||
{
|
||||
result.Add(new TaskStatusChangeDto(id, "Blocked",
|
||||
$"Blocked: depends on task {row.DependsOnTaskId} (status: " +
|
||||
(known ? depStatus.ToString() : "not found") + ")."));
|
||||
(known ? depStatus.ToString() : "not found") + ").", row.Number));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ public sealed class TaskWaitMcpTools
|
||||
var busy = row.Status == TaskStatus.Queued || row.Status == TaskStatus.Running
|
||||
|| (treatWaitingForChildrenAsBusy && row.Status == TaskStatus.WaitingForChildren);
|
||||
if (!busy)
|
||||
result.Add(new TaskStatusChangeDto(id, row.Status.ToString()));
|
||||
result.Add(new TaskStatusChangeDto(id, row.Status.ToString(), Number: row.Number));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ public sealed class WorktreeMaintenanceService
|
||||
join l in context.Lists on t.ListId equals l.Id
|
||||
select new
|
||||
{
|
||||
w.TaskId, t.Title, t.Status, ListId = l.Id, ListName = l.Name,
|
||||
w.TaskId, t.Number, t.Title, t.Status, ListId = l.Id, ListName = l.Name,
|
||||
w.Path, w.BranchName, w.BaseCommit, w.State, w.DiffStat, w.CreatedAt,
|
||||
};
|
||||
|
||||
@@ -101,7 +101,7 @@ public sealed class WorktreeMaintenanceService
|
||||
var rows = await query.AsNoTracking().ToListAsync(ct);
|
||||
|
||||
return rows.Select(x => new WorktreeOverviewRow(
|
||||
x.TaskId, x.Title, x.Status, x.ListId, x.ListName,
|
||||
x.TaskId, x.Number, x.Title, x.Status, x.ListId, x.ListName,
|
||||
x.Path, x.BranchName, x.BaseCommit ?? "", x.State, x.DiffStat, x.CreatedAt,
|
||||
PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace ClaudeDo.Worker.Worktrees;
|
||||
|
||||
public sealed record WorktreeOverviewRow(
|
||||
string TaskId,
|
||||
int TaskNumber,
|
||||
string TaskTitle,
|
||||
TaskStatus TaskStatus,
|
||||
string ListId,
|
||||
|
||||
@@ -201,6 +201,7 @@ public sealed class BatchMcpToolsTests : IDisposable
|
||||
Assert.True(found.Found);
|
||||
Assert.NotNull(found.Task);
|
||||
Assert.Equal(task.Id, found.Task!.Id);
|
||||
Assert.Equal(task.Number, found.Task!.Number);
|
||||
Assert.Null(found.TaskFull);
|
||||
}
|
||||
|
||||
@@ -220,6 +221,7 @@ public sealed class BatchMcpToolsTests : IDisposable
|
||||
Assert.Null(found.Task);
|
||||
Assert.NotNull(found.TaskFull);
|
||||
Assert.Equal("the full description", found.TaskFull!.Description);
|
||||
Assert.Equal(task.Number, found.TaskFull!.Number);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -217,6 +217,18 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Equal("new title", dto.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTask_ReturnsTaskNumber()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(task.Number, dto.Number);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTask_ReturnsFullDtoIncludingDescription()
|
||||
{
|
||||
@@ -1281,6 +1293,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Null(result.TasksFull);
|
||||
Assert.Single(result.Tasks!);
|
||||
Assert.Equal(task.Id, result.Tasks![0].Id);
|
||||
Assert.Equal(task.Number, result.Tasks![0].Number);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1299,6 +1312,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.NotNull(result.TasksFull);
|
||||
Assert.Single(result.TasksFull!);
|
||||
Assert.Equal("the full description", result.TasksFull![0].Description);
|
||||
Assert.Equal(task.Number, result.TasksFull![0].Number);
|
||||
}
|
||||
|
||||
// ── MergeTask allowWaitingForReview ───────────────────────────────────────
|
||||
@@ -2341,6 +2355,19 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTask_ReturnsAllocatedTaskNumber()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var sut = NewService();
|
||||
|
||||
var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None);
|
||||
|
||||
var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
|
||||
Assert.True(dto.Task.Number > 0);
|
||||
Assert.Equal(loaded!.Number, dto.Task.Number);
|
||||
}
|
||||
|
||||
// ── AddTask model override ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user