Merge branch 'claudedo/9f963a215bb34ca4a28050d4f39178d3'
This commit is contained in:
@@ -667,6 +667,7 @@
|
||||
"failureReasonTooltip": { "maxTurns": "Turn-Limit erreicht ({0}/{1} Turns) — der Worktree ist meist brauchbar; Task fortsetzen statt zurücksetzen." },
|
||||
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
||||
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
||||
"queue": { "baseDirtyWarning": "Achtung: Das Repo dieser Liste hat uncommittete Änderungen ({0} geändert, {1} untracked) — ein neuer Worktree startet vom letzten Commit und enthält sie nicht." },
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}" },
|
||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
|
||||
|
||||
@@ -667,6 +667,7 @@
|
||||
"failureReasonTooltip": { "maxTurns": "Turn limit reached ({0}/{1} turns) — the worktree is usually fine; continue the task instead of resetting it." },
|
||||
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
||||
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
|
||||
"queue": { "baseDirtyWarning": "Heads up: this list's repo has uncommitted changes ({0} modified, {1} untracked) — a new worktree starts from the last commit and won't include them." },
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}" },
|
||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
||||
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
|
||||
|
||||
@@ -75,7 +75,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<List<string>> InstallSessionSkillAsync(string url);
|
||||
Task UpdateSessionSkillAsync(string sourceUrl);
|
||||
Task RemoveSessionSkillAsync(string sourceUrl);
|
||||
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||
/// <summary>Returns the base-dirty heads-up when this transition just queued the task against
|
||||
/// a list whose working dir has uncommitted changes (null otherwise) — a new worktree forks
|
||||
/// from the last commit, not the working tree, so those changes won't be included.</summary>
|
||||
Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
||||
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
||||
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
||||
|
||||
@@ -483,9 +483,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task RemoveSessionSkillAsync(string sourceUrl)
|
||||
=> _hub.InvokeAsync("RemoveSessionSkill", sourceUrl);
|
||||
|
||||
public async Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
|
||||
public async Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
|
||||
{
|
||||
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
||||
var result = await _hub.InvokeAsync<SetTaskStatusResultDto>("SetTaskStatus", taskId, status.ToString());
|
||||
return result?.BaseDirty;
|
||||
}
|
||||
|
||||
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
||||
@@ -690,6 +691,8 @@ public sealed record SessionSkillDto(
|
||||
public sealed record WorktreeCleanupDto(int Removed);
|
||||
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||
public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
|
||||
public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty);
|
||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||
|
||||
@@ -1097,12 +1097,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (Task == null) return;
|
||||
try
|
||||
{
|
||||
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||
var baseDirty = await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||
AgentState = "queued";
|
||||
ReportBaseDirty(baseDirty);
|
||||
}
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
private void ReportBaseDirty(BaseDirtyWarningDto? warning)
|
||||
{
|
||||
if (warning is null) return;
|
||||
ErrorReported?.Invoke(Loc.T(
|
||||
"vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount));
|
||||
}
|
||||
|
||||
private bool CanEnqueue() =>
|
||||
Task != null && _worker.IsConnected && IsIdle
|
||||
&& (!Task.IsChild || Task.ParentFinalized);
|
||||
@@ -1168,8 +1176,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||
var baseDirty = await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||
AgentState = "queued";
|
||||
ReportBaseDirty(baseDirty);
|
||||
}
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
@@ -887,10 +887,21 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
|
||||
{
|
||||
if (_worker is null) return;
|
||||
try { await _worker.SetTaskStatusAsync(row.Id, status); }
|
||||
try
|
||||
{
|
||||
var baseDirty = await _worker.SetTaskStatusAsync(row.Id, status);
|
||||
ReportBaseDirty(baseDirty);
|
||||
}
|
||||
catch { /* offline; broadcast won't fire */ }
|
||||
}
|
||||
|
||||
private void ReportBaseDirty(BaseDirtyWarningDto? warning)
|
||||
{
|
||||
if (warning is null) return;
|
||||
ErrorReported?.Invoke(Loc.T(
|
||||
"vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||
{
|
||||
@@ -917,7 +928,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// Goes through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write
|
||||
// so the manual/draft-child guards apply here too; the row refreshes from the resulting
|
||||
// TaskUpdated broadcast.
|
||||
try { await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued); }
|
||||
try
|
||||
{
|
||||
var baseDirty = await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued);
|
||||
ReportBaseDirty(baseDirty);
|
||||
}
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
if (string.IsNullOrEmpty(taskId)) return;
|
||||
if (ConPtySessions.Any(s => s.TaskId == taskId)) return;
|
||||
try { await _worker.SetTaskStatusAsync(taskId, ClaudeDo.Data.Models.TaskStatus.Queued); }
|
||||
try
|
||||
{
|
||||
var baseDirty = await _worker.SetTaskStatusAsync(taskId, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||
if (baseDirty is not null)
|
||||
ErrorReported?.Invoke(Loc.T(
|
||||
"vm.queue.baseDirtyWarning", baseDirty.ModifiedCount, baseDirty.UntrackedCount));
|
||||
}
|
||||
catch { /* best-effort enqueue */ }
|
||||
await RefreshQueueAsync();
|
||||
}
|
||||
|
||||
+24
-4
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Worker.Git;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
@@ -48,7 +49,9 @@ public sealed record BatchTaskDetailDto(
|
||||
public sealed record BatchAddTaskResult(
|
||||
int Index, string Title, bool Ok, TaskRefDto? Task,
|
||||
IReadOnlyList<PossibleDuplicateDto>? PossibleDuplicates, string? Error);
|
||||
public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error);
|
||||
// BaseDirty mirrors TaskRefDto.BaseDirty: populated only for BatchUpdateTaskStatus items that
|
||||
// just transitioned to Queued against a list whose working dir has uncommitted changes.
|
||||
public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error, DirtyBaseWarning? BaseDirty = null);
|
||||
public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error);
|
||||
public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error);
|
||||
|
||||
@@ -231,7 +234,10 @@ public sealed class BatchMcpTools
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Set the status of many tasks at once — use for bulk queue/cancel/done actions instead of calling " +
|
||||
"update_task_status per task. 'Done' is refused per-item for a task with an active worktree." +
|
||||
"update_task_status per task. 'Done' is refused per-item for a task with an active worktree. " +
|
||||
"baseDirty on a 'Queued' item means that task's list has uncommitted changes in its working dir right " +
|
||||
"now — a new worktree forks from the last commit and won't include them; non-blocking, but worth " +
|
||||
"checking before assuming a fresh worktree starts from what's on disk." +
|
||||
McpToolDocs.MaxBatch)]
|
||||
public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus(
|
||||
string[] taskIds,
|
||||
@@ -239,8 +245,22 @@ public sealed class BatchMcpTools
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureWithinCap(taskIds, nameof(taskIds));
|
||||
return await RunPerTaskAsync(taskIds,
|
||||
(id, ct) => _svc.UpdateTaskStatus(id, status, ct), cancellationToken);
|
||||
|
||||
var results = new List<BatchTaskResult>(taskIds.Length);
|
||||
foreach (var id in taskIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
var task = await _svc.UpdateTaskStatus(id, status, cancellationToken);
|
||||
results.Add(new BatchTaskResult(id, true, null, task.BaseDirty));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(new BatchTaskResult(id, false, ex.Message));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
|
||||
+23
-5
@@ -7,6 +7,7 @@ using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Git;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
@@ -26,7 +27,7 @@ public sealed record CancelTaskResult(bool Cancelled, string Id);
|
||||
// review range (worktree ahead, or HandlerBaseCommit..HandlerHeadCommit for a worktree-less
|
||||
// child) contributed nothing, so a reviewer sees them before approving instead of after.
|
||||
public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList<TaskRefDto>? EmptyChildren = null);
|
||||
public sealed record RunTaskNowResult(bool Started, string TaskId);
|
||||
public sealed record RunTaskNowResult(bool Started, string TaskId, DirtyBaseWarning? BaseDirty = null);
|
||||
|
||||
public sealed record TaskDto(
|
||||
string Id,
|
||||
@@ -62,6 +63,9 @@ public sealed record TaskDto(
|
||||
|
||||
// Lean counterpart to TaskDto for writing/status-changing tools: echoes back what changed
|
||||
// without re-sending Description/Result, which the caller just sent or already has.
|
||||
// BaseDirty is populated only where the task just transitioned to Queued (or ran immediately
|
||||
// via run_task_now) and its list's working directory has uncommitted changes -- see
|
||||
// ClaudeDo.Worker.Git.BaseDirtyChecker. Every other caller leaves it null.
|
||||
public sealed record TaskRefDto(
|
||||
string Id,
|
||||
string ListId,
|
||||
@@ -75,7 +79,8 @@ public sealed record TaskRefDto(
|
||||
int? FailureMaxTurns = null,
|
||||
string? DependsOnTaskId = null,
|
||||
bool Blocked = false,
|
||||
string? BlockedReason = null);
|
||||
string? BlockedReason = null,
|
||||
DirtyBaseWarning? BaseDirty = null);
|
||||
|
||||
// tasks is populated when includeDescription=false (the default): lean references, no
|
||||
// Description/Result. tasksFull is populated when includeDescription=true: full tasks incl.
|
||||
@@ -186,6 +191,7 @@ public sealed class ExternalMcpService
|
||||
private readonly WorktreeMaintenanceService _maintenance;
|
||||
private readonly TaskMergeService _merge;
|
||||
private readonly PlanningMergeOrchestrator _planningMerge;
|
||||
private readonly IBaseDirtyChecker _baseDirtyChecker;
|
||||
|
||||
public ExternalMcpService(
|
||||
TaskRepository tasks,
|
||||
@@ -197,7 +203,8 @@ public sealed class ExternalMcpService
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
WorktreeMaintenanceService maintenance,
|
||||
TaskMergeService merge,
|
||||
PlanningMergeOrchestrator planningMerge)
|
||||
PlanningMergeOrchestrator planningMerge,
|
||||
IBaseDirtyChecker baseDirtyChecker)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_lists = lists;
|
||||
@@ -209,6 +216,7 @@ public sealed class ExternalMcpService
|
||||
_maintenance = maintenance;
|
||||
_merge = merge;
|
||||
_planningMerge = planningMerge;
|
||||
_baseDirtyChecker = baseDirtyChecker;
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -566,6 +574,7 @@ public sealed class ExternalMcpService
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
DirtyBaseWarning? baseDirty = null;
|
||||
switch (target)
|
||||
{
|
||||
case TaskStatus.Idle:
|
||||
@@ -577,6 +586,7 @@ public sealed class ExternalMcpService
|
||||
var enqueueResult = await _state.EnqueueAsync(taskId, cancellationToken);
|
||||
if (!enqueueResult.Ok)
|
||||
throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task.");
|
||||
baseDirty = enqueueResult.BaseDirty;
|
||||
break;
|
||||
|
||||
case TaskStatus.Cancelled:
|
||||
@@ -605,7 +615,7 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
return ToRefDto(reload);
|
||||
return ToRefDto(reload) with { BaseDirty = baseDirty };
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -782,7 +792,15 @@ public sealed class ExternalMcpService
|
||||
throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
}
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new RunTaskNowResult(true, taskId);
|
||||
|
||||
// Heads-up only, same as update_task_status's Queued path: a worktree forks from the
|
||||
// commit tip, not the working tree, so uncommitted changes in the list's repo right now
|
||||
// won't be included.
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
|
||||
var list = task is not null ? await _lists.GetByIdAsync(task.ListId, cancellationToken) : null;
|
||||
var baseDirty = await _baseDirtyChecker.CheckAsync(list?.WorkingDir, cancellationToken);
|
||||
|
||||
return new RunTaskNowResult(true, taskId, baseDirty);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ClaudeDo.Data.Git;
|
||||
|
||||
namespace ClaudeDo.Worker.Git;
|
||||
|
||||
public sealed record DirtyBaseWarning(int ModifiedCount, int UntrackedCount);
|
||||
|
||||
/// <summary>
|
||||
/// Detects uncommitted changes in a list's working directory before a task forks a worktree
|
||||
/// from HEAD. WorktreeManager.ResolveBaseCommitAsync forks from the commit tip, never the
|
||||
/// working tree, so local edits sitting there are silently invisible to every task queued
|
||||
/// against that list -- each one starts without them and can collide re-creating the same
|
||||
/// files. Non-blocking by design: callers surface the counts as a heads-up, never refuse the
|
||||
/// transition.
|
||||
/// </summary>
|
||||
public interface IBaseDirtyChecker
|
||||
{
|
||||
Task<DirtyBaseWarning?> CheckAsync(string? workingDir, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class BaseDirtyChecker : IBaseDirtyChecker
|
||||
{
|
||||
// Long enough to collapse every task in one batch_update_task_status call over the same
|
||||
// list into a single `git status` invocation; short enough that a later, independent queue
|
||||
// action re-checks instead of trusting a stale answer.
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly GitService _git;
|
||||
private readonly ILogger<BaseDirtyChecker> _logger;
|
||||
private readonly ConcurrentDictionary<string, (DateTime ExpiresAtUtc, DirtyBaseWarning? Warning)> _cache = new();
|
||||
|
||||
public BaseDirtyChecker(GitService git, ILogger<BaseDirtyChecker> logger)
|
||||
{
|
||||
_git = git;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DirtyBaseWarning?> CheckAsync(string? workingDir, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(workingDir))
|
||||
return null;
|
||||
|
||||
string key;
|
||||
try { key = Path.GetFullPath(workingDir); }
|
||||
catch { return null; }
|
||||
|
||||
if (_cache.TryGetValue(key, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow)
|
||||
return cached.Warning;
|
||||
|
||||
var warning = await ComputeAsync(key, ct);
|
||||
_cache[key] = (DateTime.UtcNow.Add(CacheTtl), warning);
|
||||
return warning;
|
||||
}
|
||||
|
||||
private async Task<DirtyBaseWarning?> ComputeAsync(string workingDir, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await _git.IsGitRepoAsync(workingDir, ct))
|
||||
return null;
|
||||
|
||||
var porcelain = await _git.GetStatusPorcelainAsync(workingDir, ct);
|
||||
return Parse(porcelain);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never blocks a queue/run transition on a check that only exists to warn.
|
||||
_logger.LogWarning(ex, "Base-dirty check failed for {WorkingDir}", workingDir);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static DirtyBaseWarning? Parse(string porcelain)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(porcelain)) return null;
|
||||
|
||||
int modified = 0, untracked = 0;
|
||||
foreach (var raw in porcelain.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var line = raw.TrimEnd('\r');
|
||||
if (line.Length == 0) continue;
|
||||
if (line.StartsWith("??", StringComparison.Ordinal)) untracked++;
|
||||
else modified++;
|
||||
}
|
||||
return modified == 0 && untracked == 0 ? null : new DirtyBaseWarning(modified, untracked);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,8 @@ public record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
public record PlanningMergeConflictStateDto(string PlanningTaskId, string SubtaskId);
|
||||
public record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||
public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
|
||||
public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty);
|
||||
// Verify* fields are always null on this path today -- the UI's live mergeability indicator never
|
||||
// requests a verify run (that would mean an unrequested build on every preview poll); they exist so
|
||||
// MergePreviewDto matches TaskMergeService.MergePreviewResult should a caller opt in later.
|
||||
@@ -716,7 +718,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand);
|
||||
}
|
||||
|
||||
public async Task SetTaskStatus(string taskId, string status)
|
||||
public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
|
||||
{
|
||||
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
||||
throw new HubException($"unknown status: {status}");
|
||||
@@ -726,6 +728,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
? await _state.EnqueueAsync(taskId, Context.ConnectionAborted)
|
||||
: await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted);
|
||||
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
|
||||
return new SetTaskStatusResultDto(
|
||||
result.BaseDirty is { } w ? new BaseDirtyWarningDto(w.ModifiedCount, w.UntrackedCount) : null);
|
||||
}
|
||||
|
||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
||||
|
||||
@@ -6,6 +6,7 @@ using ClaudeDo.Worker.Agents;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Findings;
|
||||
using ClaudeDo.Worker.Git;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Logging;
|
||||
@@ -108,6 +109,7 @@ builder.Services.AddSingleton<Func<ITaskStateService>>(sp => () => sp.GetRequire
|
||||
// PlanningMergeOrchestrator itself depends on ITaskStateService, so TaskStateService can only
|
||||
// reach it lazily (Func<IActiveMergeState>) — same cycle-breaking shape as the Func above.
|
||||
builder.Services.AddSingleton<Func<IActiveMergeState>>(sp => () => sp.GetRequiredService<PlanningMergeOrchestrator>());
|
||||
builder.Services.AddSingleton<IBaseDirtyChecker, BaseDirtyChecker>();
|
||||
builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<HubBroadcaster>(),
|
||||
@@ -115,6 +117,7 @@ builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
||||
sp.GetRequiredService<PlanningChainCoordinator>(),
|
||||
sp.GetRequiredService<RunCancellationRegistry>(),
|
||||
sp.GetRequiredService<Func<IActiveMergeState>>(),
|
||||
sp.GetRequiredService<IBaseDirtyChecker>(),
|
||||
sp.GetRequiredService<ILogger<TaskStateService>>()));
|
||||
|
||||
// Agent file management.
|
||||
@@ -303,6 +306,7 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskResetService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<GitService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IBaseDirtyChecker>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeMaintenanceService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskMergeService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<PlanningMergeOrchestrator>());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Git;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
@@ -17,6 +18,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
private readonly PlanningChainCoordinator _chain;
|
||||
private readonly RunCancellationRegistry _runCancels;
|
||||
private readonly Func<IActiveMergeState> _mergeState;
|
||||
private readonly IBaseDirtyChecker _baseDirtyChecker;
|
||||
private readonly ILogger<TaskStateService> _logger;
|
||||
|
||||
public TaskStateService(
|
||||
@@ -26,6 +28,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
PlanningChainCoordinator chain,
|
||||
RunCancellationRegistry runCancels,
|
||||
Func<IActiveMergeState> mergeState,
|
||||
IBaseDirtyChecker baseDirtyChecker,
|
||||
ILogger<TaskStateService> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
@@ -34,6 +37,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
_chain = chain;
|
||||
_runCancels = runCancels;
|
||||
_mergeState = mergeState;
|
||||
_baseDirtyChecker = baseDirtyChecker;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -56,7 +60,17 @@ public sealed class TaskStateService : ITaskStateService
|
||||
|
||||
_waker.Wake();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
|
||||
// Heads-up only: a worktree forks from the commit tip (WorktreeManager.ResolveBaseCommitAsync),
|
||||
// not the working tree, so uncommitted changes sitting in the list's repo right now are
|
||||
// invisible to this (and every other) queued task. Never blocks the transition above.
|
||||
var workingDir = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Id == taskId)
|
||||
.Select(t => t.List.WorkingDir)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
var baseDirty = await _baseDirtyChecker.CheckAsync(workingDir, ct);
|
||||
|
||||
return new TransitionResult(true, null, baseDirty);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ClaudeDo.Worker.Git;
|
||||
|
||||
namespace ClaudeDo.Worker.State;
|
||||
|
||||
public sealed record TransitionResult(bool Ok, string? Reason);
|
||||
public sealed record TransitionResult(bool Ok, string? Reason, DirtyBaseWarning? BaseDirty = null);
|
||||
|
||||
Reference in New Issue
Block a user