diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 39cc6b86..bb466213 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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}" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index dfa5f847..39a21f28 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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}" }, diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 221dbcf0..4fafb035 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -75,7 +75,10 @@ public interface IWorkerClient : INotifyPropertyChanged Task> InstallSessionSkillAsync(string url); Task UpdateSessionSkillAsync(string sourceUrl); Task RemoveSessionSkillAsync(string sourceUrl); - Task SetTaskStatusAsync(string taskId, TaskStatus status); + /// 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. + Task SetTaskStatusAsync(string taskId, TaskStatus status); Task ApproveReviewAsync(string taskId, string targetBranch); Task PreviewMergeAsync(string taskId, string targetBranch); Task MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 60787e04..cf5453e4 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -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 SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status) { - await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); + var result = await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); + return result?.BaseDirty; } public async Task 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 ConflictFiles, string? ErrorMessage); +public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount); +public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty); public record MergePreviewDto(string Status, IReadOnlyList ConflictFiles, int ChangedFileCount); public record MergeTargetsDto(string DefaultBranch, IReadOnlyList LocalBranches); public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Files); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index fcd4ccf5..8d7cb7ca 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -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 */ } } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index c83cbc76..e446fac2 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -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)); } } diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index cf078f66..b8b5da2e 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -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(); } diff --git a/src/ClaudeDo.Worker/External/BatchMcpTools.cs b/src/ClaudeDo.Worker/External/BatchMcpTools.cs index 74310829..af944603 100644 --- a/src/ClaudeDo.Worker/External/BatchMcpTools.cs +++ b/src/ClaudeDo.Worker/External/BatchMcpTools.cs @@ -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? PossibleDuplicates, string? Error); -public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error); +// BaseDirty mirrors TaskRefDto.BaseDirty: populated only for BatchUpdateTaskStatus items that +// just transitioned to Queued against a list whose working dir has uncommitted changes. +public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error, DirtyBaseWarning? BaseDirty = null); public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error); public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error); @@ -231,7 +234,10 @@ public sealed class BatchMcpTools [McpServerTool, Description( "Set the status of many tasks at once — use for bulk queue/cancel/done actions instead of calling " + - "update_task_status per task. 'Done' is refused per-item for a task with an active worktree." + + "update_task_status per task. 'Done' is refused per-item for a task with an active worktree. " + + "baseDirty on a 'Queued' item means that task's list has uncommitted changes in its working dir right " + + "now — a new worktree forks from the last commit and won't include them; non-blocking, but worth " + + "checking before assuming a fresh worktree starts from what's on disk." + McpToolDocs.MaxBatch)] public async Task> 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(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( diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 34428ca5..6438b083 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -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 MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList? 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 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( diff --git a/src/ClaudeDo.Worker/Git/BaseDirtyChecker.cs b/src/ClaudeDo.Worker/Git/BaseDirtyChecker.cs new file mode 100644 index 00000000..9b7744a1 --- /dev/null +++ b/src/ClaudeDo.Worker/Git/BaseDirtyChecker.cs @@ -0,0 +1,88 @@ +using System.Collections.Concurrent; +using ClaudeDo.Data.Git; + +namespace ClaudeDo.Worker.Git; + +public sealed record DirtyBaseWarning(int ModifiedCount, int UntrackedCount); + +/// +/// 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. +/// +public interface IBaseDirtyChecker +{ + Task 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 _logger; + private readonly ConcurrentDictionary _cache = new(); + + public BaseDirtyChecker(GitService git, ILogger logger) + { + _git = git; + _logger = logger; + } + + public async Task 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 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); + } +} diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index bc646d97..8f2ed712 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -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 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 SetTaskStatus(string taskId, string status) { if (!Enum.TryParse(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 ApproveReview(string taskId, string targetBranch) diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 5dec1a2b..43a3b94d 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -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>(sp => () => sp.GetRequire // PlanningMergeOrchestrator itself depends on ITaskStateService, so TaskStateService can only // reach it lazily (Func) — same cycle-breaking shape as the Func above. builder.Services.AddSingleton>(sp => () => sp.GetRequiredService()); +builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new TaskStateService( sp.GetRequiredService>(), sp.GetRequiredService(), @@ -115,6 +117,7 @@ builder.Services.AddSingleton(sp => new TaskStateService( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), + sp.GetRequiredService(), sp.GetRequiredService>())); // Agent file management. @@ -303,6 +306,7 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index 24429b55..4040b2d1 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -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 _mergeState; + private readonly IBaseDirtyChecker _baseDirtyChecker; private readonly ILogger _logger; public TaskStateService( @@ -26,6 +28,7 @@ public sealed class TaskStateService : ITaskStateService PlanningChainCoordinator chain, RunCancellationRegistry runCancels, Func mergeState, + IBaseDirtyChecker baseDirtyChecker, ILogger 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 StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct) diff --git a/src/ClaudeDo.Worker/State/TransitionResult.cs b/src/ClaudeDo.Worker/State/TransitionResult.cs index fc7d66b8..28c30d25 100644 --- a/src/ClaudeDo.Worker/State/TransitionResult.cs +++ b/src/ClaudeDo.Worker/State/TransitionResult.cs @@ -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); diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 8e16e961..689601e6 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -92,7 +92,7 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task> InstallSessionSkillAsync(string url) => Task.FromResult(new List()); 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 SetTaskStatusAsync(string taskId, TaskStatus status) => Task.FromResult(null); public virtual Task ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult(null); public virtual Task PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult(null); public virtual Task MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty(), null)); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs index 35a91d5b..98643bbf 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -228,14 +228,15 @@ public class MissionControlViewModelTests : IDisposable public QueueingWorkerClient(Func newContext) => _newContext = newContext; - public override async Task SetTaskStatusAsync(string taskId, TaskStatus status) + public override async Task 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; } } diff --git a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs index 07374156..0c445d5b 100644 --- a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs @@ -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.Instance)); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index 6ae26e6b..25b1dac1 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -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 _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 SeedListAsync() + private async Task 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.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() { diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index fdc5b717..33fe127d 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -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.Instance)); } private QueueService CreateQueue() diff --git a/tests/ClaudeDo.Worker.Tests/Git/BaseDirtyCheckerTests.cs b/tests/ClaudeDo.Worker.Tests/Git/BaseDirtyCheckerTests.cs new file mode 100644 index 00000000..d8f562b6 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Git/BaseDirtyCheckerTests.cs @@ -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 _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.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); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs index e9caabad..845356f3 100644 --- a/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs @@ -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 dbFactory, Func? mergeState = null) + IDbContextFactory dbFactory, + Func? 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.Instance), NullLogger.Instance); return new Built(state, chain, hub, () => waker.Count, waker, runCancels); diff --git a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs index 1df64ae4..febfa831 100644 --- a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs @@ -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 _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 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() { diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index f7b2d18e..2f83dd9a 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -60,10 +60,10 @@ sealed class FakeWorkerClient : IWorkerClient public Task> InstallSessionSkillAsync(string url) => Task.FromResult(new List()); public Task UpdateSessionSkillAsync(string sourceUrl) => Task.CompletedTask; public Task RemoveSessionSkillAsync(string sourceUrl) => Task.CompletedTask; - public Task SetTaskStatusAsync(string taskId, TaskStatus status) + public Task SetTaskStatusAsync(string taskId, TaskStatus status) { SetTaskStatusCalls.Add((taskId, status)); - return Task.CompletedTask; + return Task.FromResult(null); } public Task ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult(null); public Task PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult(null);