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; namespace ClaudeDo.Worker.External;
public sealed record AttachmentDto(string FileName, long ByteSize, DateTime CreatedAt); 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] [McpServerToolType]
public sealed class AttachmentMcpTools public sealed class AttachmentMcpTools
@@ -115,6 +115,6 @@ public sealed class AttachmentMcpTools
_store.DeleteFile(taskId, fileName); _store.DeleteFile(taskId, fileName);
await _attachments.DeleteAsync(taskId, fileName, ct); await _attachments.DeleteAsync(taskId, fileName, ct);
await _broadcaster.TaskUpdated(taskId); 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. // run reported, without pulling in the rest of Result/Description to get them.
public sealed record BatchTaskDetailDto( public sealed record BatchTaskDetailDto(
string Id, string Id,
int Number,
string? ListId = null, string? ListId = null,
string? Title = null, string? Title = null,
string? Description = null, string? Description = null,
@@ -51,9 +52,9 @@ public sealed record BatchAddTaskResult(
IReadOnlyList<PossibleDuplicateDto>? PossibleDuplicates, string? Error); IReadOnlyList<PossibleDuplicateDto>? PossibleDuplicates, string? Error);
// BaseDirty mirrors TaskRefDto.BaseDirty: populated only for BatchUpdateTaskStatus items that // BaseDirty mirrors TaskRefDto.BaseDirty: populated only for BatchUpdateTaskStatus items that
// just transitioned to Queued against a list whose working dir has uncommitted changes. // 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 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); 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); public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error, int? Number = null);
/// <summary> /// <summary>
/// Batch variants of the single-entity tools on <see cref="ExternalMcpService"/>. /// Batch variants of the single-entity tools on <see cref="ExternalMcpService"/>.
@@ -166,6 +167,7 @@ public sealed class BatchMcpTools
return new BatchTaskDetailDto( return new BatchTaskDetailDto(
Id: t.Id, Id: t.Id,
Number: t.Number,
ListId: Want("listId") ? t.ListId : null, ListId: Want("listId") ? t.ListId : null,
Title: Want("title") ? t.Title : null, Title: Want("title") ? t.Title : null,
Description: description, Description: description,
@@ -252,7 +254,7 @@ public sealed class BatchMcpTools
try try
{ {
var task = await _svc.UpdateTaskStatus(id, status, cancellationToken); 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 (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
@@ -277,7 +279,7 @@ public sealed class BatchMcpTools
try try
{ {
var r = await _svc.CancelTask(id, cancellationToken); 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 (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
@@ -296,7 +298,7 @@ public sealed class BatchMcpTools
{ {
EnsureWithinCap(taskIds, nameof(taskIds)); EnsureWithinCap(taskIds, nameof(taskIds));
return await RunPerTaskAsync(taskIds, return await RunPerTaskAsync(taskIds,
(id, ct) => _svc.DeleteTask(id, ct), cancellationToken); async (id, ct) => (await _svc.DeleteTask(id, ct)).Number, cancellationToken);
} }
[McpServerTool, Description( [McpServerTool, Description(
@@ -313,8 +315,8 @@ public sealed class BatchMcpTools
{ {
try try
{ {
await _svc.SetMyDay(item.TaskId, item.IsMyDay, item.SortOrder, cancellationToken); var task = await _svc.SetMyDay(item.TaskId, item.IsMyDay, item.SortOrder, cancellationToken);
results.Add(new BatchTaskResult(item.TaskId, true, null)); results.Add(new BatchTaskResult(item.TaskId, true, null, Number: task.Number));
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
@@ -342,7 +344,7 @@ public sealed class BatchMcpTools
try try
{ {
var r = await _svc.CleanupTaskWorktree(id, force, cancellationToken); 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 (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
@@ -354,15 +356,15 @@ public sealed class BatchMcpTools
} }
private static async Task<IReadOnlyList<BatchTaskResult>> RunPerTaskAsync( 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); var results = new List<BatchTaskResult>(taskIds.Length);
foreach (var id in taskIds) foreach (var id in taskIds)
{ {
try try
{ {
await op(id, cancellationToken); var number = await op(id, cancellationToken);
results.Add(new BatchTaskResult(id, true, null)); results.Add(new BatchTaskResult(id, true, null, Number: number));
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (Exception ex) 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 TaskConfigResult(bool Found, TaskConfigDto? Config);
public sealed record ListConfigResult(bool Found, ListConfigDto? Config); public sealed record ListConfigResult(bool Found, ListConfigDto? Config);
public sealed record SetListConfigResult(bool Ok, string ListId, 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 EffectiveModelDto(string Value, string Source);
public sealed record EffectiveMaxTurnsDto(int Effective, string Source, int Requested, bool Clamped); 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 EffectiveSystemPromptDto(bool Set, IReadOnlyList<string> Sources);
public sealed record EffectiveRunConfigDto( public sealed record EffectiveRunConfigDto(
string TaskId, string TaskId,
int Number,
EffectiveModelDto Model, EffectiveModelDto Model,
EffectiveMaxTurnsDto MaxTurns, EffectiveMaxTurnsDto MaxTurns,
string Effort, string Effort,
@@ -158,7 +159,7 @@ public sealed class ConfigMcpTools
await _tasks.UpdateAgentSettingsAsync(taskId, m, sp, ap, mt, task.SessionSkills, cancellationToken); await _tasks.UpdateAgentSettingsAsync(taskId, m, sp, ap, mt, task.SessionSkills, cancellationToken);
await _broadcaster.TaskUpdated(taskId); 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.")] [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( return new EffectiveRunConfigDto(
taskId, taskId,
task.Number,
new EffectiveModelDto(effective.Model, effective.ModelSource), new EffectiveModelDto(effective.Model, effective.ModelSource),
new EffectiveMaxTurnsDto(effective.MaxTurns, effective.MaxTurnsSource, effective.RequestedMaxTurns, effective.MaxTurnsClamped), new EffectiveMaxTurnsDto(effective.MaxTurns, effective.MaxTurnsSource, effective.RequestedMaxTurns, effective.MaxTurnsClamped),
effective.Effort, effective.Effort,
+42 -28
View File
@@ -21,16 +21,17 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External; namespace ClaudeDo.Worker.External;
public sealed record TaskListDto(string Id, string Name, string? WorkingDir); public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
public sealed record DeleteTaskResult(bool Deleted, string Id); public sealed record DeleteTaskResult(bool Deleted, string Id, int Number);
public sealed record CancelTaskResult(bool Cancelled, string Id); 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 // 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 // review range (worktree ahead, or HandlerBaseCommit..HandlerHeadCommit for a worktree-less
// child) contributed nothing, so a reviewer sees them before approving instead of after. // 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 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( public sealed record TaskDto(
string Id, string Id,
int Number,
string ListId, string ListId,
string Title, string Title,
string? Description, string? Description,
@@ -68,6 +69,7 @@ public sealed record TaskDto(
// ClaudeDo.Worker.Git.BaseDirtyChecker. Every other caller leaves it null. // ClaudeDo.Worker.Git.BaseDirtyChecker. Every other caller leaves it null.
public sealed record TaskRefDto( public sealed record TaskRefDto(
string Id, string Id,
int Number,
string ListId, string ListId,
string Title, string Title,
string Status, string Status,
@@ -92,7 +94,7 @@ public sealed record ListTasksResult(
IReadOnlyList<TaskDto>? TasksFull); IReadOnlyList<TaskDto>? TasksFull);
// Deliberately small: no descriptions, capped candidate count (see AddTask's duplicate check). // 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); public sealed record AddTaskResult(TaskRefDto Task, IReadOnlyList<PossibleDuplicateDto> PossibleDuplicates);
@@ -145,32 +147,32 @@ public sealed record MergePreviewToolDto(
public sealed record MergePreviewSetEntryDto( public sealed record MergePreviewSetEntryDto(
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false, 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, 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 // 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 // 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 // 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. // 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( public sealed record MergePreviewSetResultDto(
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps, IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
IReadOnlyList<SubsetRelationDto> Subsets); IReadOnlyList<SubsetRelationDto> Subsets);
public sealed record WorktreeListItemDto( public sealed record WorktreeListItemDto(
string? TaskId, string Path, string Branch, string? TaskId, int? Number, string Path, string Branch,
string HeadCommit, bool IsDirty, bool MergedIntoMain); string HeadCommit, bool IsDirty, bool MergedIntoMain);
public sealed record CleanupWorktreeResult( public sealed record CleanupWorktreeResult(
bool Removed, string WorktreePath, bool BranchDeleted); bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null);
public sealed record RevertMergeResultDto( public sealed record RevertMergeResultDto(
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message); bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
public sealed record DailyPrepCandidateDto( 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); bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);
public sealed record DailyPrepDataDto( public sealed record DailyPrepDataDto(
@@ -444,7 +446,7 @@ public sealed class ExternalMcpService
.OrderByDescending(x => (double)x.Shared / Math.Min(newWords.Count, x.Words.Count)) .OrderByDescending(x => (double)x.Shared / Math.Min(newWords.Count, x.Words.Count))
.ThenByDescending(x => x.Shared) .ThenByDescending(x => x.Shared)
.Take(MaxPossibleDuplicates) .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(); .ToList();
} }
@@ -800,7 +802,7 @@ public sealed class ExternalMcpService
var list = task is not null ? await _lists.GetByIdAsync(task.ListId, cancellationToken) : null; var list = task is not null ? await _lists.GetByIdAsync(task.ListId, cancellationToken) : null;
var baseDirty = await _baseDirtyChecker.CheckAsync(list?.WorkingDir, cancellationToken); var baseDirty = await _baseDirtyChecker.CheckAsync(list?.WorkingDir, cancellationToken);
return new RunTaskNowResult(true, taskId, baseDirty); return new RunTaskNowResult(true, taskId, baseDirty, task?.Number);
} }
[McpServerTool, Description( [McpServerTool, Description(
@@ -809,7 +811,8 @@ public sealed class ExternalMcpService
{ {
var cancelled = _queue.CancelTask(taskId); var cancelled = _queue.CancelTask(taskId);
if (cancelled) await _broadcaster.TaskUpdated(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( [McpServerTool, Description(
@@ -826,7 +829,7 @@ public sealed class ExternalMcpService
if (task.ParentTaskId is not null) if (task.ParentTaskId is not null)
await _state.TryAdvanceParentAsync(task.ParentTaskId); await _state.TryAdvanceParentAsync(task.ParentTaskId);
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
return new DeleteTaskResult(true, taskId); return new DeleteTaskResult(true, taskId, task.Number);
} }
// ── Worktree / git tools ────────────────────────────────────────────────── // ── Worktree / git tools ──────────────────────────────────────────────────
@@ -1204,7 +1207,7 @@ public sealed class ExternalMcpService
string? targetBranch = null, string? targetBranch = null,
CancellationToken cancellationToken = default) 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, return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles); preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
} }
@@ -1236,21 +1239,25 @@ public sealed class ExternalMcpService
var entries = new List<MergePreviewSetEntryDto>(); var entries = new List<MergePreviewSetEntryDto>();
var filesByTask = new Dictionary<string, IReadOnlyList<string>>(); var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
var numbersByTask = new Dictionary<string, int>();
foreach (var taskId in taskIds) foreach (var taskId in taskIds)
{ {
try 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( entries.Add(new MergePreviewSetEntryDto(
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty, 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; filesByTask[taskId] = changedFiles;
numbersByTask[taskId] = number;
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
entries.Add(new MergePreviewSetEntryDto( 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))) .SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase) .GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Select(x => x.TaskId).Distinct().Count() > 1) .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) .OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
var subsets = FindSubsetRelations(filesByTask); var subsets = FindSubsetRelations(filesByTask, numbersByTask);
return new MergePreviewSetResultDto(entries, overlaps, subsets); 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 // 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 // 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. // 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( var fileSets = filesByTask.ToDictionary(
kv => kv.Key, kv => kv.Key,
@@ -1284,7 +1296,7 @@ public sealed class ExternalMcpService
{ {
if (otherTaskId == taskId || otherFileSet.Count <= fileSet.Count) continue; if (otherTaskId == taskId || otherFileSet.Count <= fileSet.Count) continue;
if (fileSet.IsSubsetOf(otherFileSet)) 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 // (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 // HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
// range's own diff-stat instead of throwing "has no worktree". // 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) string taskId, string? targetBranch, bool runVerify, CancellationToken ct)
{ {
using var ctx = _dbFactory.CreateDbContext(); using var ctx = _dbFactory.CreateDbContext();
@@ -1342,7 +1354,7 @@ public sealed class ExternalMcpService
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase) .Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
.ToList(); .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) 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 // 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 // working dir instead of a branch, so there is nothing else that could have "changed
// in the target since the fork". // 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."); throw new InvalidOperationException($"Task {taskId} has no worktree.");
@@ -1405,7 +1417,7 @@ public sealed class ExternalMcpService
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "") ? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
: ""; : "";
return new WorktreeListItemDto( return new WorktreeListItemDto(
row.TaskId, row.Path, row.BranchName, headCommit, row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit,
isDirty, row.State == WorktreeState.Merged); isDirty, row.State == WorktreeState.Merged);
})); }));
return results; return results;
@@ -1440,7 +1452,7 @@ public sealed class ExternalMcpService
var path = wt.Path; var path = wt.Path;
var result = await _maintenance.ForceRemoveAsync(taskId, cancellationToken); 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( [McpServerTool, Description(
@@ -1548,7 +1560,7 @@ public sealed class ExternalMcpService
} }
private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new( 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); t.IsStarred, t.ScheduledFor, t.CreatedAt);
// ── Private helpers ─────────────────────────────────────────────────────── // ── Private helpers ───────────────────────────────────────────────────────
@@ -1628,6 +1640,7 @@ public sealed class ExternalMcpService
private static TaskDto ToDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new( private static TaskDto ToDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
t.Id, t.Id,
t.Number,
t.ListId, t.ListId,
t.Title, t.Title,
t.Description, t.Description,
@@ -1649,6 +1662,7 @@ public sealed class ExternalMcpService
private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new( private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
t.Id, t.Id,
t.Number,
t.ListId, t.ListId,
t.Title, t.Title,
t.Status.ToString(), t.Status.ToString(),
+3 -3
View File
@@ -6,7 +6,7 @@ using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External; 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] [McpServerToolType]
public sealed class HandoffMcpTools public sealed class HandoffMcpTools
@@ -40,7 +40,7 @@ public sealed class HandoffMcpTools
if (survivingTaskIds.Count == 0) if (survivingTaskIds.Count == 0)
throw new InvalidOperationException("survivingTaskIds must contain at least one task id."); 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."); ?? throw new InvalidOperationException($"Task {taskId} not found.");
foreach (var id in survivingTaskIds) foreach (var id in survivingTaskIds)
@@ -48,6 +48,6 @@ public sealed class HandoffMcpTools
?? throw new InvalidOperationException($"Task {id} not found."); ?? throw new InvalidOperationException($"Task {id} not found.");
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, nextPhase); 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; namespace ClaudeDo.Worker.External;
public sealed record ResetFailedTaskResult(bool Reset, string TaskId); public sealed record ResetFailedTaskResult(bool Reset, string TaskId, int Number);
[McpServerToolType] [McpServerToolType]
public sealed class LifecycleMcpTools 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."); throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool.");
await _reset.ResetAsync(taskId, cancellationToken); 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; 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( public sealed record GetQueueStateResult(
int ConfiguredSlots, int ConfiguredSlots,
int EffectiveSlots, int EffectiveSlots,
IReadOnlyList<QueueSlotDto> ActiveSlots, IReadOnlyList<QueueSlotDto> ActiveSlots,
IReadOnlyList<string> WaitingTaskIds, IReadOnlyList<string> WaitingTaskIds,
IReadOnlyList<QueueWaitReasonDto> ScopeBlockedTasks); IReadOnlyList<QueueWaitReasonDto> ScopeBlockedTasks,
IReadOnlyList<int> WaitingTaskNumbers);
[McpServerToolType] [McpServerToolType]
public sealed class QueueStateMcpTools public sealed class QueueStateMcpTools
@@ -44,13 +45,21 @@ public sealed class QueueStateMcpTools
public async Task<GetQueueStateResult> GetQueueState(CancellationToken cancellationToken = default) public async Task<GetQueueStateResult> GetQueueState(CancellationToken cancellationToken = default)
{ {
var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken); var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken);
var active = _queue.GetActive();
var activeSlots = _queue.GetActive()
.Select(a => new QueueSlotDto(a.slot, a.taskId, a.startedAt))
.ToList();
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var now = DateTime.UtcNow; 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 var waiting = await ctx.Tasks
.Where(t => t.Status == TaskStatus.Queued .Where(t => t.Status == TaskStatus.Queued
&& t.BlockedByTaskId == null && t.BlockedByTaskId == null
@@ -73,10 +82,18 @@ public sealed class QueueStateMcpTools
if (!serializingListIds.Contains(t.ListId)) continue; if (!serializingListIds.Contains(t.ListId)) continue;
var blockerId = await ScopeOverlap.FindBlockingSiblingAsync(ctx, t, cancellationToken); var blockerId = await ScopeOverlap.FindBlockingSiblingAsync(ctx, t, cancellationToken);
if (blockerId is not null) 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 // 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. // 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); public sealed record WaitForTaskChangeResult(IReadOnlyList<TaskStatusChangeDto> Changed, bool TimedOut);
[McpServerToolType] [McpServerToolType]
@@ -107,7 +107,7 @@ public sealed class TaskWaitMcpTools
var rows = await ctx.Tasks var rows = await ctx.Tasks
.AsNoTracking() .AsNoTracking()
.Where(t => taskIds.Contains(t.Id)) .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); .ToListAsync(ct);
var byId = rows.ToDictionary(r => r.Id, r => r); var byId = rows.ToDictionary(r => r.Id, r => r);
@@ -150,7 +150,7 @@ public sealed class TaskWaitMcpTools
{ {
result.Add(new TaskStatusChangeDto(id, "Blocked", result.Add(new TaskStatusChangeDto(id, "Blocked",
$"Blocked: depends on task {row.DependsOnTaskId} (status: " + $"Blocked: depends on task {row.DependsOnTaskId} (status: " +
(known ? depStatus.ToString() : "not found") + ").")); (known ? depStatus.ToString() : "not found") + ").", row.Number));
continue; continue;
} }
} }
@@ -159,7 +159,7 @@ public sealed class TaskWaitMcpTools
var busy = row.Status == TaskStatus.Queued || row.Status == TaskStatus.Running var busy = row.Status == TaskStatus.Queued || row.Status == TaskStatus.Running
|| (treatWaitingForChildrenAsBusy && row.Status == TaskStatus.WaitingForChildren); || (treatWaitingForChildrenAsBusy && row.Status == TaskStatus.WaitingForChildren);
if (!busy) if (!busy)
result.Add(new TaskStatusChangeDto(id, row.Status.ToString())); result.Add(new TaskStatusChangeDto(id, row.Status.ToString(), Number: row.Number));
} }
return result; return result;
} }
@@ -91,7 +91,7 @@ public sealed class WorktreeMaintenanceService
join l in context.Lists on t.ListId equals l.Id join l in context.Lists on t.ListId equals l.Id
select new 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, 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); var rows = await query.AsNoTracking().ToListAsync(ct);
return rows.Select(x => new WorktreeOverviewRow( 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, x.Path, x.BranchName, x.BaseCommit ?? "", x.State, x.DiffStat, x.CreatedAt,
PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList(); PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList();
} }
@@ -5,6 +5,7 @@ namespace ClaudeDo.Worker.Worktrees;
public sealed record WorktreeOverviewRow( public sealed record WorktreeOverviewRow(
string TaskId, string TaskId,
int TaskNumber,
string TaskTitle, string TaskTitle,
TaskStatus TaskStatus, TaskStatus TaskStatus,
string ListId, string ListId,
@@ -201,6 +201,7 @@ public sealed class BatchMcpToolsTests : IDisposable
Assert.True(found.Found); Assert.True(found.Found);
Assert.NotNull(found.Task); Assert.NotNull(found.Task);
Assert.Equal(task.Id, found.Task!.Id); Assert.Equal(task.Id, found.Task!.Id);
Assert.Equal(task.Number, found.Task!.Number);
Assert.Null(found.TaskFull); Assert.Null(found.TaskFull);
} }
@@ -220,6 +221,7 @@ public sealed class BatchMcpToolsTests : IDisposable
Assert.Null(found.Task); Assert.Null(found.Task);
Assert.NotNull(found.TaskFull); Assert.NotNull(found.TaskFull);
Assert.Equal("the full description", found.TaskFull!.Description); Assert.Equal("the full description", found.TaskFull!.Description);
Assert.Equal(task.Number, found.TaskFull!.Number);
} }
[Fact] [Fact]
@@ -217,6 +217,18 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Equal("new title", dto.Title); 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] [Fact]
public async Task GetTask_ReturnsFullDtoIncludingDescription() public async Task GetTask_ReturnsFullDtoIncludingDescription()
{ {
@@ -1281,6 +1293,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Null(result.TasksFull); Assert.Null(result.TasksFull);
Assert.Single(result.Tasks!); Assert.Single(result.Tasks!);
Assert.Equal(task.Id, result.Tasks![0].Id); Assert.Equal(task.Id, result.Tasks![0].Id);
Assert.Equal(task.Number, result.Tasks![0].Number);
} }
[Fact] [Fact]
@@ -1299,6 +1312,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.NotNull(result.TasksFull); Assert.NotNull(result.TasksFull);
Assert.Single(result.TasksFull!); Assert.Single(result.TasksFull!);
Assert.Equal("the full description", result.TasksFull![0].Description); Assert.Equal("the full description", result.TasksFull![0].Description);
Assert.Equal(task.Number, result.TasksFull![0].Number);
} }
// ── MergeTask allowWaitingForReview ─────────────────────────────────────── // ── MergeTask allowWaitingForReview ───────────────────────────────────────
@@ -2341,6 +2355,19 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Equal("dotnet build", fakeVerify.CapturedCommand); 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 ──────────────────────────────────────────────── // ── AddTask model override ────────────────────────────────────────────────
[Fact] [Fact]