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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -247,6 +253,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;
|
||||
@@ -136,7 +137,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);
|
||||
|
||||
Reference in New Issue
Block a user