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
@@ -667,6 +667,7 @@
"failureReasonTooltip": { "maxTurns": "Turn-Limit erreicht ({0}/{1} Turns) — der Worktree ist meist brauchbar; Task fortsetzen statt zurücksetzen." },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"queue": { "baseDirtyWarning": "Achtung: Das Repo dieser Liste hat uncommittete Änderungen ({0} geändert, {1} untracked) — ein neuer Worktree startet vom letzten Commit und enthält sie nicht." },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
@@ -667,6 +667,7 @@
"failureReasonTooltip": { "maxTurns": "Turn limit reached ({0}/{1} turns) — the worktree is usually fine; continue the task instead of resetting it." },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"queue": { "baseDirtyWarning": "Heads up: this list's repo has uncommitted changes ({0} modified, {1} untracked) — a new worktree starts from the last commit and won't include them." },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
@@ -75,7 +75,10 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<List<string>> InstallSessionSkillAsync(string url);
Task UpdateSessionSkillAsync(string sourceUrl);
Task RemoveSessionSkillAsync(string sourceUrl);
Task SetTaskStatusAsync(string taskId, TaskStatus status);
/// <summary>Returns the base-dirty heads-up when this transition just queued the task against
/// a list whose working dir has uncommitted changes (null otherwise) — a new worktree forks
/// from the last commit, not the working tree, so those changes won't be included.</summary>
Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status);
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
+5 -2
View File
@@ -483,9 +483,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task RemoveSessionSkillAsync(string sourceUrl)
=> _hub.InvokeAsync("RemoveSessionSkill", sourceUrl);
public async Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
public async Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
{
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
var result = await _hub.InvokeAsync<SetTaskStatusResultDto>("SetTaskStatus", taskId, status.ToString());
return result?.BaseDirty;
}
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
@@ -690,6 +691,8 @@ public sealed record SessionSkillDto(
public sealed record WorktreeCleanupDto(int Removed);
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty);
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
@@ -1097,12 +1097,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
if (Task == null) return;
try
{
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
var baseDirty = await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
AgentState = "queued";
ReportBaseDirty(baseDirty);
}
catch { /* offline */ }
}
private void ReportBaseDirty(BaseDirtyWarningDto? warning)
{
if (warning is null) return;
ErrorReported?.Invoke(Loc.T(
"vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount));
}
private bool CanEnqueue() =>
Task != null && _worker.IsConnected && IsIdle
&& (!Task.IsChild || Task.ParentFinalized);
@@ -1168,8 +1176,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
try
{
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
var baseDirty = await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
AgentState = "queued";
ReportBaseDirty(baseDirty);
}
catch { /* offline */ }
}
@@ -887,10 +887,21 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
{
if (_worker is null) return;
try { await _worker.SetTaskStatusAsync(row.Id, status); }
try
{
var baseDirty = await _worker.SetTaskStatusAsync(row.Id, status);
ReportBaseDirty(baseDirty);
}
catch { /* offline; broadcast won't fire */ }
}
private void ReportBaseDirty(BaseDirtyWarningDto? warning)
{
if (warning is null) return;
ErrorReported?.Invoke(Loc.T(
"vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount));
}
[RelayCommand]
private async Task SendToQueueAsync(TaskRowViewModel? row)
{
@@ -917,7 +928,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// Goes through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write
// so the manual/draft-child guards apply here too; the row refreshes from the resulting
// TaskUpdated broadcast.
try { await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued); }
try
{
var baseDirty = await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued);
ReportBaseDirty(baseDirty);
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
}
@@ -131,7 +131,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
{
if (string.IsNullOrEmpty(taskId)) return;
if (ConPtySessions.Any(s => s.TaskId == taskId)) return;
try { await _worker.SetTaskStatusAsync(taskId, ClaudeDo.Data.Models.TaskStatus.Queued); }
try
{
var baseDirty = await _worker.SetTaskStatusAsync(taskId, ClaudeDo.Data.Models.TaskStatus.Queued);
if (baseDirty is not null)
ErrorReported?.Invoke(Loc.T(
"vm.queue.baseDirtyWarning", baseDirty.ModifiedCount, baseDirty.UntrackedCount));
}
catch { /* best-effort enqueue */ }
await RefreshQueueAsync();
}
+23 -3
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);
@@ -232,6 +235,9 @@ 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. " +
"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(
@@ -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);
}
}
+5 -1
View File
@@ -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)
+4
View File
@@ -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>());
+15 -1
View File
@@ -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);
+1 -1
View File
@@ -92,7 +92,7 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task<List<string>> InstallSessionSkillAsync(string url) => Task.FromResult(new List<string>());
public virtual Task UpdateSessionSkillAsync(string sourceUrl) => Task.CompletedTask;
public virtual Task RemoveSessionSkillAsync(string sourceUrl) => Task.CompletedTask;
public virtual Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
public virtual Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status) => Task.FromResult<BaseDirtyWarningDto?>(null);
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
@@ -228,14 +228,15 @@ public class MissionControlViewModelTests : IDisposable
public QueueingWorkerClient(Func<ClaudeDoDbContext> newContext) => _newContext = newContext;
public override async Task SetTaskStatusAsync(string taskId, TaskStatus status)
public override async Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status)
{
QueuedTaskIds.Add(taskId);
await using var db = _newContext();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId);
if (entity is null) return;
if (entity is null) return null;
entity.Status = status;
await db.SaveChangesAsync();
return null;
}
}
@@ -2,6 +2,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
@@ -86,7 +87,8 @@ public sealed class AddSubtaskToolTests : IDisposable
return new ExternalMcpService(
_tasks, _lists, queue, broadcaster,
state,
git, dbFactory, maintenance, merge, planningMerge);
git, dbFactory, maintenance, merge, planningMerge,
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
}
[Fact]
+35 -3
View File
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
@@ -24,6 +25,9 @@ public sealed class BatchMcpToolsTests : IDisposable
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly HubBroadcaster _broadcaster;
private readonly List<GitRepoFixture> _repos = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
public BatchMcpToolsTests()
{
@@ -35,14 +39,15 @@ public sealed class BatchMcpToolsTests : IDisposable
public void Dispose()
{
foreach (var r in _repos) r.Dispose();
_ctx.Dispose();
_db.Dispose();
}
private async Task<string> SeedListAsync()
private async Task<string> SeedListAsync(string? workingDir = null)
{
var id = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow, WorkingDir = workingDir });
return id;
}
@@ -74,7 +79,8 @@ public sealed class BatchMcpToolsTests : IDisposable
var svc = new ExternalMcpService(
_tasks, _lists, CreateQueue(), _broadcaster,
state,
git, factory, maintenance, merge, planningMerge);
git, factory, maintenance, merge, planningMerge,
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
return new BatchMcpTools(svc);
}
@@ -414,6 +420,32 @@ public sealed class BatchMcpToolsTests : IDisposable
Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t2.Id))!.Status);
}
[Fact]
public async Task BatchUpdateTaskStatus_DirtyBaseRepo_ReportsWarningPerItem_OneListOneRepo()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
File.WriteAllText(Path.Combine(repo.RepoDir, "scratch.txt"), "new");
var listId = await SeedListAsync(repo.RepoDir);
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
var sut = BuildSut();
// Two tasks queued from the same list -- BaseDirtyChecker's TTL cache means only one
// `git status` actually runs underneath, but both items still see the warning.
var results = await sut.BatchUpdateTaskStatus(new[] { t1.Id, t2.Id }, "Queued", CancellationToken.None);
Assert.All(results, r => Assert.True(r.Ok));
Assert.All(results, r =>
{
Assert.NotNull(r.BaseDirty);
Assert.Equal(1, r.BaseDirty!.UntrackedCount);
});
}
[Fact]
public async Task BatchUpdateTaskStatus_Done_MixedWorktreeState_ReportsPerItemAndDoesNotAbort()
{
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
@@ -139,7 +140,8 @@ public sealed class ExternalMcpServiceTests : IDisposable
return new ExternalMcpService(
_tasks, _lists, queue, _broadcaster,
state,
git, factory, maintenance, merge, planningMerge);
git, factory, maintenance, merge, planningMerge,
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
}
private QueueService CreateQueue()
@@ -0,0 +1,135 @@
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
namespace ClaudeDo.Worker.Tests.Git;
public sealed class BaseDirtyCheckerTests : IDisposable
{
private readonly List<GitRepoFixture> _repos = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
public void Dispose()
{
foreach (var r in _repos) r.Dispose();
}
private GitRepoFixture CreateRepo()
{
var repo = new GitRepoFixture();
_repos.Add(repo);
return repo;
}
private static BaseDirtyChecker CreateSut() =>
new(new GitService(), NullLogger<BaseDirtyChecker>.Instance);
[Fact]
public async Task CheckAsync_NullWorkingDir_ReturnsNull()
{
var sut = CreateSut();
Assert.Null(await sut.CheckAsync(null, default));
Assert.Null(await sut.CheckAsync("", default));
Assert.Null(await sut.CheckAsync(" ", default));
}
[Fact]
public async Task CheckAsync_NotAGitRepo_ReturnsNull()
{
var dir = Path.Combine(Path.GetTempPath(), $"claudedo_notgit_{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
var sut = CreateSut();
Assert.Null(await sut.CheckAsync(dir, default));
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public async Task CheckAsync_CleanRepo_ReturnsNull()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var sut = CreateSut();
Assert.Null(await sut.CheckAsync(repo.RepoDir, default));
}
[Fact]
public async Task CheckAsync_UntrackedFile_ReturnsUntrackedCountOnly()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "scratch.txt"), "new");
var sut = CreateSut();
var warning = await sut.CheckAsync(repo.RepoDir, default);
Assert.NotNull(warning);
Assert.Equal(0, warning!.ModifiedCount);
Assert.Equal(1, warning.UntrackedCount);
}
[Fact]
public async Task CheckAsync_ModifiedTrackedFile_ReturnsModifiedCountOnly()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "edited");
var sut = CreateSut();
var warning = await sut.CheckAsync(repo.RepoDir, default);
Assert.NotNull(warning);
Assert.Equal(1, warning!.ModifiedCount);
Assert.Equal(0, warning.UntrackedCount);
}
[Fact]
public async Task CheckAsync_ModifiedAndUntracked_ReportsBothSeparately()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "edited");
File.WriteAllText(Path.Combine(repo.RepoDir, "scratch.txt"), "new");
var sut = CreateSut();
var warning = await sut.CheckAsync(repo.RepoDir, default);
Assert.NotNull(warning);
Assert.Equal(1, warning!.ModifiedCount);
Assert.Equal(1, warning.UntrackedCount);
}
// Proves the TTL cache collapses repeated checks against the same working dir into a
// single underlying `git status` call -- the acceptance requirement that a batch queue
// op over many tasks in one list must not shell out once per task. A second check
// immediately after a repo mutation still returns the first (stale) answer instead of
// re-running git.
[Fact]
public async Task CheckAsync_RepeatedCallsWithinTtl_ReuseCachedResult()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var sut = CreateSut();
var first = await sut.CheckAsync(repo.RepoDir, default);
Assert.Null(first);
File.WriteAllText(Path.Combine(repo.RepoDir, "scratch.txt"), "new");
var second = await sut.CheckAsync(repo.RepoDir, default);
Assert.Null(second);
}
}
@@ -1,4 +1,5 @@
using ClaudeDo.Data;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
@@ -21,7 +22,9 @@ public static class TaskStateServiceBuilder
RunCancellationRegistry RunCancels);
public static Built Build(
IDbContextFactory<ClaudeDoDbContext> dbFactory, Func<IActiveMergeState>? mergeState = null)
IDbContextFactory<ClaudeDoDbContext> dbFactory,
Func<IActiveMergeState>? mergeState = null,
IBaseDirtyChecker? baseDirtyChecker = null)
{
var hub = new CapturingHubContext();
var broadcaster = new HubBroadcaster(hub);
@@ -37,6 +40,7 @@ public static class TaskStateServiceBuilder
chain,
runCancels,
mergeState ?? (() => NoActiveMergeState.Instance),
baseDirtyChecker ?? new BaseDirtyChecker(new ClaudeDo.Data.Git.GitService(), NullLogger<BaseDirtyChecker>.Instance),
NullLogger<TaskStateService>.Instance);
return new Built(state, chain, hub, () => waker.Count, waker, runCancels);
@@ -1,10 +1,12 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.State;
@@ -22,6 +24,9 @@ public sealed class TaskStateServiceTests : IDisposable
private readonly TaskStateServiceBuilder.Built _built;
private readonly ITaskStateService _sut;
private readonly string _listId;
private readonly List<GitRepoFixture> _repos = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
public TaskStateServiceTests()
{
@@ -41,7 +46,19 @@ public sealed class TaskStateServiceTests : IDisposable
ctx.SaveChanges();
}
public void Dispose() => _db.Dispose();
public void Dispose()
{
foreach (var r in _repos) r.Dispose();
_db.Dispose();
}
private async Task SetListWorkingDirAsync(string workingDir)
{
await using var ctx = _factory.CreateDbContext();
var list = await ctx.Lists.FirstAsync(l => l.Id == _listId);
list.WorkingDir = workingDir;
await ctx.SaveChangesAsync();
}
private async Task<string> SeedTaskAsync(
TaskStatus status,
@@ -98,6 +115,74 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.Contains(_built.Hub.Proxy.Calls, c => c.Method == "TaskUpdated");
}
[Fact]
public async Task EnqueueAsync_NoWorkingDir_QueuesNormally_NoBaseDirtyWarning()
{
// The list created in the constructor has no WorkingDir -- covers the
// "no valid repo" acceptance case: queuing must not throw or block.
var id = await SeedTaskAsync(TaskStatus.Idle);
var result = await _sut.EnqueueAsync(id, default);
Assert.True(result.Ok);
Assert.Null(result.BaseDirty);
Assert.Equal(TaskStatus.Queued, await GetStatusAsync(id));
}
[Fact]
public async Task EnqueueAsync_CleanBaseRepo_NoBaseDirtyWarning()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
await SetListWorkingDirAsync(repo.RepoDir);
var id = await SeedTaskAsync(TaskStatus.Idle);
var result = await _sut.EnqueueAsync(id, default);
Assert.True(result.Ok);
Assert.Null(result.BaseDirty);
}
[Fact]
public async Task EnqueueAsync_UntrackedFileInBaseRepo_ReturnsBaseDirtyWarning()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
File.WriteAllText(Path.Combine(repo.RepoDir, "new-file.txt"), "not committed");
await SetListWorkingDirAsync(repo.RepoDir);
var id = await SeedTaskAsync(TaskStatus.Idle);
var result = await _sut.EnqueueAsync(id, default);
Assert.True(result.Ok);
Assert.NotNull(result.BaseDirty);
Assert.Equal(0, result.BaseDirty!.ModifiedCount);
Assert.Equal(1, result.BaseDirty!.UntrackedCount);
}
[Fact]
public async Task EnqueueAsync_ModifiedFileInBaseRepo_ReturnsBaseDirtyWarning()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "changed on disk");
await SetListWorkingDirAsync(repo.RepoDir);
var id = await SeedTaskAsync(TaskStatus.Idle);
var result = await _sut.EnqueueAsync(id, default);
Assert.True(result.Ok);
Assert.NotNull(result.BaseDirty);
Assert.Equal(1, result.BaseDirty!.ModifiedCount);
Assert.Equal(0, result.BaseDirty!.UntrackedCount);
}
[Fact]
public async Task EnqueueAsync_ManualTask_Rejected_AndStaysIdle()
{
@@ -60,10 +60,10 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<List<string>> InstallSessionSkillAsync(string url) => Task.FromResult(new List<string>());
public Task UpdateSessionSkillAsync(string sourceUrl) => Task.CompletedTask;
public Task RemoveSessionSkillAsync(string sourceUrl) => Task.CompletedTask;
public Task SetTaskStatusAsync(string taskId, TaskStatus status)
public Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status)
{
SetTaskStatusCalls.Add((taskId, status));
return Task.CompletedTask;
return Task.FromResult<BaseDirtyWarningDto?>(null);
}
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);