Merge claudedo/42d038e771e249e790f38087cbf2be6f

This commit is contained in:
mika kuns
2026-08-11 12:04:42 +02:00
12 changed files with 129 additions and 64 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
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(),
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]