Merge branch 'claudedo/9f963a215bb34ca4a28050d4f39178d3'
This commit is contained in:
+24
-4
@@ -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
@@ -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(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ClaudeDo.Data.Git;
|
||||
|
||||
namespace ClaudeDo.Worker.Git;
|
||||
|
||||
public sealed record DirtyBaseWarning(int ModifiedCount, int UntrackedCount);
|
||||
|
||||
/// <summary>
|
||||
/// Detects uncommitted changes in a list's working directory before a task forks a worktree
|
||||
/// from HEAD. WorktreeManager.ResolveBaseCommitAsync forks from the commit tip, never the
|
||||
/// working tree, so local edits sitting there are silently invisible to every task queued
|
||||
/// against that list -- each one starts without them and can collide re-creating the same
|
||||
/// files. Non-blocking by design: callers surface the counts as a heads-up, never refuse the
|
||||
/// transition.
|
||||
/// </summary>
|
||||
public interface IBaseDirtyChecker
|
||||
{
|
||||
Task<DirtyBaseWarning?> CheckAsync(string? workingDir, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class BaseDirtyChecker : IBaseDirtyChecker
|
||||
{
|
||||
// Long enough to collapse every task in one batch_update_task_status call over the same
|
||||
// list into a single `git status` invocation; short enough that a later, independent queue
|
||||
// action re-checks instead of trusting a stale answer.
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly GitService _git;
|
||||
private readonly ILogger<BaseDirtyChecker> _logger;
|
||||
private readonly ConcurrentDictionary<string, (DateTime ExpiresAtUtc, DirtyBaseWarning? Warning)> _cache = new();
|
||||
|
||||
public BaseDirtyChecker(GitService git, ILogger<BaseDirtyChecker> logger)
|
||||
{
|
||||
_git = git;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DirtyBaseWarning?> CheckAsync(string? workingDir, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(workingDir))
|
||||
return null;
|
||||
|
||||
string key;
|
||||
try { key = Path.GetFullPath(workingDir); }
|
||||
catch { return null; }
|
||||
|
||||
if (_cache.TryGetValue(key, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow)
|
||||
return cached.Warning;
|
||||
|
||||
var warning = await ComputeAsync(key, ct);
|
||||
_cache[key] = (DateTime.UtcNow.Add(CacheTtl), warning);
|
||||
return warning;
|
||||
}
|
||||
|
||||
private async Task<DirtyBaseWarning?> ComputeAsync(string workingDir, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await _git.IsGitRepoAsync(workingDir, ct))
|
||||
return null;
|
||||
|
||||
var porcelain = await _git.GetStatusPorcelainAsync(workingDir, ct);
|
||||
return Parse(porcelain);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never blocks a queue/run transition on a check that only exists to warn.
|
||||
_logger.LogWarning(ex, "Base-dirty check failed for {WorkingDir}", workingDir);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static DirtyBaseWarning? Parse(string porcelain)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(porcelain)) return null;
|
||||
|
||||
int modified = 0, untracked = 0;
|
||||
foreach (var raw in porcelain.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var line = raw.TrimEnd('\r');
|
||||
if (line.Length == 0) continue;
|
||||
if (line.StartsWith("??", StringComparison.Ordinal)) untracked++;
|
||||
else modified++;
|
||||
}
|
||||
return modified == 0 && untracked == 0 ? null : new DirtyBaseWarning(modified, untracked);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,8 @@ public record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
public record PlanningMergeConflictStateDto(string PlanningTaskId, string SubtaskId);
|
||||
public record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||
public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
|
||||
public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty);
|
||||
// Verify* fields are always null on this path today -- the UI's live mergeability indicator never
|
||||
// requests a verify run (that would mean an unrequested build on every preview poll); they exist so
|
||||
// MergePreviewDto matches TaskMergeService.MergePreviewResult should a caller opt in later.
|
||||
@@ -716,7 +718,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand);
|
||||
}
|
||||
|
||||
public async Task SetTaskStatus(string taskId, string status)
|
||||
public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
|
||||
{
|
||||
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
||||
throw new HubException($"unknown status: {status}");
|
||||
@@ -726,6 +728,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
? await _state.EnqueueAsync(taskId, Context.ConnectionAborted)
|
||||
: await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted);
|
||||
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
|
||||
return new SetTaskStatusResultDto(
|
||||
result.BaseDirty is { } w ? new BaseDirtyWarningDto(w.ModifiedCount, w.UntrackedCount) : null);
|
||||
}
|
||||
|
||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
||||
|
||||
@@ -6,6 +6,7 @@ using ClaudeDo.Worker.Agents;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Findings;
|
||||
using ClaudeDo.Worker.Git;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Logging;
|
||||
@@ -108,6 +109,7 @@ builder.Services.AddSingleton<Func<ITaskStateService>>(sp => () => sp.GetRequire
|
||||
// PlanningMergeOrchestrator itself depends on ITaskStateService, so TaskStateService can only
|
||||
// reach it lazily (Func<IActiveMergeState>) — same cycle-breaking shape as the Func above.
|
||||
builder.Services.AddSingleton<Func<IActiveMergeState>>(sp => () => sp.GetRequiredService<PlanningMergeOrchestrator>());
|
||||
builder.Services.AddSingleton<IBaseDirtyChecker, BaseDirtyChecker>();
|
||||
builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<HubBroadcaster>(),
|
||||
@@ -115,6 +117,7 @@ builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
||||
sp.GetRequiredService<PlanningChainCoordinator>(),
|
||||
sp.GetRequiredService<RunCancellationRegistry>(),
|
||||
sp.GetRequiredService<Func<IActiveMergeState>>(),
|
||||
sp.GetRequiredService<IBaseDirtyChecker>(),
|
||||
sp.GetRequiredService<ILogger<TaskStateService>>()));
|
||||
|
||||
// Agent file management.
|
||||
@@ -303,6 +306,7 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskResetService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<GitService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IBaseDirtyChecker>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeMaintenanceService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskMergeService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<PlanningMergeOrchestrator>());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Git;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
@@ -17,6 +18,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
private readonly PlanningChainCoordinator _chain;
|
||||
private readonly RunCancellationRegistry _runCancels;
|
||||
private readonly Func<IActiveMergeState> _mergeState;
|
||||
private readonly IBaseDirtyChecker _baseDirtyChecker;
|
||||
private readonly ILogger<TaskStateService> _logger;
|
||||
|
||||
public TaskStateService(
|
||||
@@ -26,6 +28,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
PlanningChainCoordinator chain,
|
||||
RunCancellationRegistry runCancels,
|
||||
Func<IActiveMergeState> mergeState,
|
||||
IBaseDirtyChecker baseDirtyChecker,
|
||||
ILogger<TaskStateService> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
@@ -34,6 +37,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
_chain = chain;
|
||||
_runCancels = runCancels;
|
||||
_mergeState = mergeState;
|
||||
_baseDirtyChecker = baseDirtyChecker;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -56,7 +60,17 @@ public sealed class TaskStateService : ITaskStateService
|
||||
|
||||
_waker.Wake();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
|
||||
// Heads-up only: a worktree forks from the commit tip (WorktreeManager.ResolveBaseCommitAsync),
|
||||
// not the working tree, so uncommitted changes sitting in the list's repo right now are
|
||||
// invisible to this (and every other) queued task. Never blocks the transition above.
|
||||
var workingDir = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Id == taskId)
|
||||
.Select(t => t.List.WorkingDir)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
var baseDirty = await _baseDirtyChecker.CheckAsync(workingDir, ct);
|
||||
|
||||
return new TransitionResult(true, null, baseDirty);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ClaudeDo.Worker.Git;
|
||||
|
||||
namespace ClaudeDo.Worker.State;
|
||||
|
||||
public sealed record TransitionResult(bool Ok, string? Reason);
|
||||
public sealed record TransitionResult(bool Ok, string? Reason, DirtyBaseWarning? BaseDirty = null);
|
||||
|
||||
Reference in New Issue
Block a user