Merge branch 'claudedo/9f963a215bb34ca4a28050d4f39178d3'

This commit is contained in:
mika kuns
2026-08-10 15:19:57 +02:00
23 changed files with 481 additions and 32 deletions
+24 -4
View File
@@ -1,5 +1,6 @@
using System.ComponentModel;
using System.Text.Json;
using ClaudeDo.Worker.Git;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
@@ -48,7 +49,9 @@ public sealed record BatchTaskDetailDto(
public sealed record BatchAddTaskResult(
int Index, string Title, bool Ok, TaskRefDto? Task,
IReadOnlyList<PossibleDuplicateDto>? PossibleDuplicates, string? Error);
public sealed record BatchTaskResult(string TaskId, bool Ok, 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);
@@ -231,7 +234,10 @@ public sealed class BatchMcpTools
[McpServerTool, Description(
"Set the status of many tasks at once — use for bulk queue/cancel/done actions instead of calling " +
"update_task_status per task. 'Done' is refused per-item for a task with an active worktree." +
"update_task_status per task. 'Done' is refused per-item for a task with an active worktree. " +
"baseDirty on a 'Queued' item means that task's list has uncommitted changes in its working dir right " +
"now — a new worktree forks from the last commit and won't include them; non-blocking, but worth " +
"checking before assuming a fresh worktree starts from what's on disk." +
McpToolDocs.MaxBatch)]
public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus(
string[] taskIds,
@@ -239,8 +245,22 @@ public sealed class BatchMcpTools
CancellationToken cancellationToken)
{
EnsureWithinCap(taskIds, nameof(taskIds));
return await RunPerTaskAsync(taskIds,
(id, ct) => _svc.UpdateTaskStatus(id, status, ct), cancellationToken);
var results = new List<BatchTaskResult>(taskIds.Length);
foreach (var id in taskIds)
{
try
{
var task = await _svc.UpdateTaskStatus(id, status, cancellationToken);
results.Add(new BatchTaskResult(id, true, null, task.BaseDirty));
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
results.Add(new BatchTaskResult(id, false, ex.Message));
}
}
return results;
}
[McpServerTool, Description(
+23 -5
View File
@@ -7,6 +7,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
@@ -26,7 +27,7 @@ public sealed record CancelTaskResult(bool Cancelled, string Id);
// 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);
public sealed record RunTaskNowResult(bool Started, string TaskId, DirtyBaseWarning? BaseDirty = null);
public sealed record TaskDto(
string Id,
@@ -62,6 +63,9 @@ public sealed record TaskDto(
// Lean counterpart to TaskDto for writing/status-changing tools: echoes back what changed
// without re-sending Description/Result, which the caller just sent or already has.
// BaseDirty is populated only where the task just transitioned to Queued (or ran immediately
// via run_task_now) and its list's working directory has uncommitted changes -- see
// ClaudeDo.Worker.Git.BaseDirtyChecker. Every other caller leaves it null.
public sealed record TaskRefDto(
string Id,
string ListId,
@@ -75,7 +79,8 @@ public sealed record TaskRefDto(
int? FailureMaxTurns = null,
string? DependsOnTaskId = null,
bool Blocked = false,
string? BlockedReason = null);
string? BlockedReason = null,
DirtyBaseWarning? BaseDirty = null);
// tasks is populated when includeDescription=false (the default): lean references, no
// Description/Result. tasksFull is populated when includeDescription=true: full tasks incl.
@@ -186,6 +191,7 @@ public sealed class ExternalMcpService
private readonly WorktreeMaintenanceService _maintenance;
private readonly TaskMergeService _merge;
private readonly PlanningMergeOrchestrator _planningMerge;
private readonly IBaseDirtyChecker _baseDirtyChecker;
public ExternalMcpService(
TaskRepository tasks,
@@ -197,7 +203,8 @@ public sealed class ExternalMcpService
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService maintenance,
TaskMergeService merge,
PlanningMergeOrchestrator planningMerge)
PlanningMergeOrchestrator planningMerge,
IBaseDirtyChecker baseDirtyChecker)
{
_tasks = tasks;
_lists = lists;
@@ -209,6 +216,7 @@ public sealed class ExternalMcpService
_maintenance = maintenance;
_merge = merge;
_planningMerge = planningMerge;
_baseDirtyChecker = baseDirtyChecker;
}
[McpServerTool, Description(
@@ -566,6 +574,7 @@ public sealed class ExternalMcpService
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
DirtyBaseWarning? baseDirty = null;
switch (target)
{
case TaskStatus.Idle:
@@ -577,6 +586,7 @@ public sealed class ExternalMcpService
var enqueueResult = await _state.EnqueueAsync(taskId, cancellationToken);
if (!enqueueResult.Ok)
throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task.");
baseDirty = enqueueResult.BaseDirty;
break;
case TaskStatus.Cancelled:
@@ -605,7 +615,7 @@ public sealed class ExternalMcpService
}
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
return ToRefDto(reload);
return ToRefDto(reload) with { BaseDirty = baseDirty };
}
[McpServerTool, Description(
@@ -782,7 +792,15 @@ public sealed class ExternalMcpService
throw new InvalidOperationException($"Task {taskId} not found.");
}
await _broadcaster.TaskUpdated(taskId);
return new RunTaskNowResult(true, taskId);
// Heads-up only, same as update_task_status's Queued path: a worktree forks from the
// commit tip, not the working tree, so uncommitted changes in the list's repo right now
// won't be included.
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
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);
}
[McpServerTool, Description(