diff --git a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs index dd1096c7..e76f75c2 100644 --- a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs +++ b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs @@ -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); } } diff --git a/src/ClaudeDo.Worker/External/BatchMcpTools.cs b/src/ClaudeDo.Worker/External/BatchMcpTools.cs index af944603..9392b4d9 100644 --- a/src/ClaudeDo.Worker/External/BatchMcpTools.cs +++ b/src/ClaudeDo.Worker/External/BatchMcpTools.cs @@ -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? 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); /// /// Batch variants of the single-entity tools on . @@ -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> RunPerTaskAsync( - string[] taskIds, Func op, CancellationToken cancellationToken) + string[] taskIds, Func> op, CancellationToken cancellationToken) { var results = new List(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) diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index 33684272..c38ea56e 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -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 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, diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 6438b083..3a9f4ba6 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -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 MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList? 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? 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 PossibleDuplicates); @@ -145,32 +147,32 @@ public sealed record MergePreviewToolDto( public sealed record MergePreviewSetEntryDto( string TaskId, string Status, IReadOnlyList ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false, int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null, - IReadOnlyList? StaleFiles = null); + IReadOnlyList? StaleFiles = null, int? Number = null); -public sealed record FileOverlapDto(string File, IReadOnlyList TaskIds); +public sealed record FileOverlapDto(string File, IReadOnlyList TaskIds, IReadOnlyList 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 Tasks, IReadOnlyList Overlaps, IReadOnlyList 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 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(); var filesByTask = new Dictionary>(); + var numbersByTask = new Dictionary(); 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(), 0, 0, ex.Message)); + taskId, TaskMergeService.PreviewUnavailable, Array.Empty(), 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 FindSubsetRelations(Dictionary> filesByTask) + private static List FindSubsetRelations( + Dictionary> filesByTask, Dictionary 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 ChangedFiles, bool IsEmpty, IReadOnlyList StaleFiles)> PreviewMergeCoreAsync( + private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList ChangedFiles, bool IsEmpty, IReadOnlyList 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()); + return (preview, 0, changedFiles, isEmpty, Array.Empty(), 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(), diff --git a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs index b77ebb29..a4545440 100644 --- a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs +++ b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs @@ -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); } } diff --git a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs index 68d20918..4aba973d 100644 --- a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs +++ b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs @@ -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); } } diff --git a/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs b/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs index e5d92231..57c1cad8 100644 --- a/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs +++ b/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs @@ -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 ActiveSlots, IReadOnlyList WaitingTaskIds, - IReadOnlyList ScopeBlockedTasks); + IReadOnlyList ScopeBlockedTasks, + IReadOnlyList WaitingTaskNumbers); [McpServerToolType] public sealed class QueueStateMcpTools @@ -44,13 +45,21 @@ public sealed class QueueStateMcpTools public async Task 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()); } } diff --git a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs index 0f6f91fe..47a9f7d9 100644 --- a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs +++ b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs @@ -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 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; } diff --git a/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs b/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs index 418eb3d4..a36aa4d1 100644 --- a/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs +++ b/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs @@ -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(); } diff --git a/src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs b/src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs index 655f3d77..698792ab 100644 --- a/src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs +++ b/src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs @@ -5,6 +5,7 @@ namespace ClaudeDo.Worker.Worktrees; public sealed record WorktreeOverviewRow( string TaskId, + int TaskNumber, string TaskTitle, TaskStatus TaskStatus, string ListId, diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index 25b1dac1..c4cad937 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -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] diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 769eff87..228a5620 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -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]