feat(queue): warn when the base branch has uncommitted changes

Queuing (update_task_status, batch_update_task_status) and run_task_now
now surface a non-blocking baseDirty warning (separate modified/untracked
counts) when the list's working dir has uncommitted changes at enqueue
time, since a new worktree forks from the commit tip and silently misses
them. BaseDirtyChecker caches per working dir for a few seconds so a
batch queue over many tasks in one list only shells out to git once. The
UI surfaces the same warning via the footer error strip on queue actions.
This commit is contained in:
mika kuns
2026-08-10 14:30:33 +02:00
parent 6a2a19cc9e
commit d3155e6868
23 changed files with 481 additions and 32 deletions
@@ -659,6 +659,7 @@
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
"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}" },
@@ -659,6 +659,7 @@
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
"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 */ }
}
@@ -860,10 +860,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)
{
@@ -890,7 +901,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();
}
+24 -4
View File
@@ -1,4 +1,5 @@
using System.ComponentModel;
using ClaudeDo.Worker.Git;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
@@ -19,7 +20,9 @@ public sealed record BatchGetTaskResult(string Id, bool Found, TaskRefDto? Task,
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);
@@ -116,7 +119,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,
@@ -124,8 +130,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,
@@ -49,6 +50,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,
@@ -56,7 +60,8 @@ public sealed record TaskRefDto(
string Status,
int SortOrder,
bool IsMyDay,
int RoadblockCount = 0);
int RoadblockCount = 0,
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.
@@ -135,6 +140,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,
@@ -146,7 +152,8 @@ public sealed class ExternalMcpService
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService maintenance,
TaskMergeService merge,
PlanningMergeOrchestrator planningMerge)
PlanningMergeOrchestrator planningMerge,
IBaseDirtyChecker baseDirtyChecker)
{
_tasks = tasks;
_lists = lists;
@@ -158,6 +165,7 @@ public sealed class ExternalMcpService
_maintenance = maintenance;
_merge = merge;
_planningMerge = planningMerge;
_baseDirtyChecker = baseDirtyChecker;
}
[McpServerTool, Description(
@@ -436,6 +444,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:
@@ -447,6 +456,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:
@@ -475,7 +485,7 @@ public sealed class ExternalMcpService
}
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
return ToRefDto(reload);
return ToRefDto(reload) with { BaseDirty = baseDirty };
}
[McpServerTool, Description(
@@ -652,7 +662,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);
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);
@@ -706,7 +708,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}");
@@ -716,6 +718,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;
@@ -107,6 +108,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>(),
@@ -114,6 +116,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.
@@ -302,6 +305,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);