feat(ui): richer diff viewer + surface child roadblocks on parents
- UnifiedDiffParser detects added/deleted/renamed/binary files; diff modal shows a file list, binary/empty placeholders, and can diff a merged task by commit range after its worktree is gone - DetailsIslandViewModel flags children needing attention (failed, cancelled, awaiting review, or with roadblocks) on the parent - GitService gains worktree head-commit/range support; planning chain, merge orchestration, and session manager tweaks with updated tests - refresh app/installer/worker icons Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 55 KiB |
@@ -124,6 +124,20 @@ public sealed class GitService
|
|||||||
return await GetDiffAsync(worktreePath, ct);
|
return await GetDiffAsync(worktreePath, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diff between two commits, run in any repo that can reach them. Used to view a
|
||||||
|
/// task's changes after its worktree has been merged away (the commits survive on
|
||||||
|
/// the target branch even though the worktree directory and branch ref are gone).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> GetCommitRangeDiffAsync(string repoDir, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||||
|
["diff", $"{baseCommit}..{headCommit}"], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git diff {baseCommit}..{headCommit} failed (exit {exitCode}): {stderr}");
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
|
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 57 KiB |
@@ -238,7 +238,10 @@
|
|||||||
"diff": {
|
"diff": {
|
||||||
"title": "DIFF",
|
"title": "DIFF",
|
||||||
"windowTitle": "Diff",
|
"windowTitle": "Diff",
|
||||||
"merge": "Mergen…"
|
"merge": "Mergen…",
|
||||||
|
"filesHeader": "Dateien",
|
||||||
|
"binary": "Binärdatei — kein Text-Diff",
|
||||||
|
"empty": "Kein Inhalt"
|
||||||
},
|
},
|
||||||
"worktree": {
|
"worktree": {
|
||||||
"title": "Worktree"
|
"title": "Worktree"
|
||||||
|
|||||||
@@ -238,7 +238,10 @@
|
|||||||
"diff": {
|
"diff": {
|
||||||
"title": "DIFF",
|
"title": "DIFF",
|
||||||
"windowTitle": "Diff",
|
"windowTitle": "Diff",
|
||||||
"merge": "Merge…"
|
"merge": "Merge…",
|
||||||
|
"filesHeader": "Files",
|
||||||
|
"binary": "Binary file — no text diff",
|
||||||
|
"empty": "No content"
|
||||||
},
|
},
|
||||||
"worktree": {
|
"worktree": {
|
||||||
"title": "Worktree"
|
"title": "Worktree"
|
||||||
|
|||||||
@@ -574,6 +574,13 @@
|
|||||||
<Style Selector="Border[Tag=?] > TextBlock">
|
<Style Selector="Border[Tag=?] > TextBlock">
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
<!-- R → rename (sage) -->
|
||||||
|
<Style Selector="Border[Tag=R]">
|
||||||
|
<Setter Property="Background" Value="#268B9D7A"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border[Tag=R] > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource SageBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- LIST NAV ITEM -->
|
<!-- LIST NAV ITEM -->
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(HasChildOutcomes));
|
OnPropertyChanged(nameof(HasChildOutcomes));
|
||||||
OnPropertyChanged(nameof(ShowMergeSection));
|
OnPropertyChanged(nameof(ShowMergeSection));
|
||||||
|
NotifyAttention();
|
||||||
|
|
||||||
// The Session tab is only visible when it has outcomes; if it just
|
// The Session tab is only visible when it has outcomes; if it just
|
||||||
// emptied while selected, fall back to Output so the body isn't blank.
|
// emptied while selected, fall back to Output so the body isn't blank.
|
||||||
@@ -354,7 +355,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
[ObservableProperty] private string? _worktreePath;
|
[ObservableProperty] private string? _worktreePath;
|
||||||
[ObservableProperty] private string? _worktreeBaseCommit;
|
[ObservableProperty] private string? _worktreeBaseCommit;
|
||||||
|
[ObservableProperty] private string? _worktreeHeadCommit;
|
||||||
[ObservableProperty] private string? _worktreeStateLabel;
|
[ObservableProperty] private string? _worktreeStateLabel;
|
||||||
|
// Repo working dir of the selected task's list — used to diff a merged task's
|
||||||
|
// commit range after its worktree directory is gone.
|
||||||
|
private string? _listWorkingDir;
|
||||||
[ObservableProperty] private string? _branchLine;
|
[ObservableProperty] private string? _branchLine;
|
||||||
[ObservableProperty] private int _turns;
|
[ObservableProperty] private int _turns;
|
||||||
[ObservableProperty] private int _tokens;
|
[ObservableProperty] private int _tokens;
|
||||||
@@ -388,6 +393,27 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
|
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
|
||||||
public bool HasChildOutcomes => ChildOutcomes.Count > 0;
|
public bool HasChildOutcomes => ChildOutcomes.Count > 0;
|
||||||
|
|
||||||
|
// Children that need the user's attention before the parent can be approved:
|
||||||
|
// failed, cancelled, still awaiting their own review, or that reported roadblocks.
|
||||||
|
// The parent deliberately stays in WaitingForChildren until these are resolved;
|
||||||
|
// this surfaces a flag so the roadblock is visible on the parent.
|
||||||
|
public int ChildrenNeedingAttention => ChildOutcomes.Count(c =>
|
||||||
|
c.Status == ClaudeDo.Data.Models.TaskStatus.Failed
|
||||||
|
|| c.Status == ClaudeDo.Data.Models.TaskStatus.Cancelled
|
||||||
|
|| c.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview
|
||||||
|
|| c.RoadblockCount > 0);
|
||||||
|
public bool HasChildrenNeedingAttention => ChildrenNeedingAttention > 0;
|
||||||
|
public string ChildrenAttentionText => ChildrenNeedingAttention == 1
|
||||||
|
? "1 child needs attention"
|
||||||
|
: $"{ChildrenNeedingAttention} children need attention";
|
||||||
|
|
||||||
|
private void NotifyAttention()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ChildrenNeedingAttention));
|
||||||
|
OnPropertyChanged(nameof(HasChildrenNeedingAttention));
|
||||||
|
OnPropertyChanged(nameof(ChildrenAttentionText));
|
||||||
|
}
|
||||||
|
|
||||||
[ObservableProperty] private string _newSubtaskTitle = "";
|
[ObservableProperty] private string _newSubtaskTitle = "";
|
||||||
|
|
||||||
// Planning merge controls
|
// Planning merge controls
|
||||||
@@ -840,6 +866,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
EditableDescription = "";
|
EditableDescription = "";
|
||||||
Model = null;
|
Model = null;
|
||||||
WorktreePath = null;
|
WorktreePath = null;
|
||||||
|
WorktreeHeadCommit = null;
|
||||||
|
_listWorkingDir = null;
|
||||||
WorktreeStateLabel = null;
|
WorktreeStateLabel = null;
|
||||||
BranchLine = null;
|
BranchLine = null;
|
||||||
DiffAdditions = 0;
|
DiffAdditions = 0;
|
||||||
@@ -876,6 +904,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
var entity = await ctx.Tasks
|
var entity = await ctx.Tasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
|
.Include(t => t.List)
|
||||||
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
if (entity == null) return;
|
if (entity == null) return;
|
||||||
@@ -885,8 +914,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
try { EditableDescription = entity.Description ?? ""; }
|
try { EditableDescription = entity.Description ?? ""; }
|
||||||
finally { _suppressDescSave = false; }
|
finally { _suppressDescSave = false; }
|
||||||
Model = entity.Model;
|
Model = entity.Model;
|
||||||
|
_listWorkingDir = entity.List?.WorkingDir;
|
||||||
WorktreePath = entity.Worktree?.Path;
|
WorktreePath = entity.Worktree?.Path;
|
||||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||||
|
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
|
||||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||||||
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
|
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
|
||||||
@@ -914,13 +945,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
||||||
|
|
||||||
if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
|
if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
|
||||||
{
|
|
||||||
await LoadPlanningChildrenAsync(row.Id, ct);
|
await LoadPlanningChildrenAsync(row.Id, ct);
|
||||||
}
|
// Surface every parent's children — planning or improvement — in the
|
||||||
else
|
// Session tab with their live status + roadblock count. This is what
|
||||||
{
|
// makes the Session tab appear for planning parents and lets a child's
|
||||||
await LoadChildOutcomesAsync(row.Id, ct);
|
// roadblock register on the parent.
|
||||||
}
|
await LoadChildOutcomesAsync(row.Id, ct);
|
||||||
|
|
||||||
if (entity.Worktree != null
|
if (entity.Worktree != null
|
||||||
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
|
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
|
||||||
@@ -1125,6 +1155,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
row.RoadblockCount = child.RoadblockCount;
|
row.RoadblockCount = child.RoadblockCount;
|
||||||
row.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
row.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||||||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||||||
|
NotifyAttention();
|
||||||
}
|
}
|
||||||
catch { /* best-effort */ }
|
catch { /* best-effort */ }
|
||||||
}
|
}
|
||||||
@@ -1148,11 +1179,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
var entity = await ctx.Tasks
|
var entity = await ctx.Tasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
|
.Include(t => t.List)
|
||||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||||
if (entity == null || Task?.Id != taskId) return;
|
if (entity == null || Task?.Id != taskId) return;
|
||||||
|
|
||||||
|
_listWorkingDir = entity.List?.WorkingDir;
|
||||||
WorktreePath = entity.Worktree?.Path;
|
WorktreePath = entity.Worktree?.Path;
|
||||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||||
|
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
|
||||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
||||||
AgentState = StatusToStateKey(entity.Status);
|
AgentState = StatusToStateKey(entity.Status);
|
||||||
@@ -1190,21 +1224,53 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||||
private async System.Threading.Tasks.Task OpenDiffAsync()
|
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||||||
{
|
{
|
||||||
if (WorktreePath == null || ShowDiffModal == null) return;
|
if (ShowDiffModal == null) return;
|
||||||
var diffVm = new DiffModalViewModel(_services.GetRequiredService<ClaudeDo.Data.Git.GitService>())
|
var git = _services.GetRequiredService<ClaudeDo.Data.Git.GitService>();
|
||||||
|
|
||||||
|
// Active worktree on disk → diff the worktree live (and allow merging from it).
|
||||||
|
var hasLiveWorktree =
|
||||||
|
WorktreePath != null
|
||||||
|
&& WorktreeStateLabel == "Active"
|
||||||
|
&& System.IO.Directory.Exists(WorktreePath);
|
||||||
|
|
||||||
|
DiffModalViewModel diffVm;
|
||||||
|
if (hasLiveWorktree)
|
||||||
{
|
{
|
||||||
WorktreePath = WorktreePath,
|
diffVm = new DiffModalViewModel(git)
|
||||||
BaseRef = WorktreeBaseCommit,
|
{
|
||||||
TaskId = Task?.Id,
|
WorktreePath = WorktreePath!,
|
||||||
TaskTitle = Task?.Title ?? "",
|
BaseRef = WorktreeBaseCommit,
|
||||||
ShowMergeModal = ShowMergeModal,
|
TaskId = Task?.Id,
|
||||||
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
TaskTitle = Task?.Title ?? "",
|
||||||
};
|
ShowMergeModal = ShowMergeModal,
|
||||||
|
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (CanDiffMergedRange)
|
||||||
|
{
|
||||||
|
// Worktree is gone (merged/discarded) but the commits survive on the
|
||||||
|
// target branch — diff the captured base..head range in the repo. No
|
||||||
|
// merge action: the work is already integrated.
|
||||||
|
diffVm = new DiffModalViewModel(git)
|
||||||
|
{
|
||||||
|
WorktreePath = _listWorkingDir!,
|
||||||
|
BaseRef = WorktreeBaseCommit,
|
||||||
|
HeadCommit = WorktreeHeadCommit,
|
||||||
|
FromCommitRange = true,
|
||||||
|
TaskId = Task?.Id,
|
||||||
|
TaskTitle = Task?.Title ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else return;
|
||||||
|
|
||||||
await diffVm.LoadAsync();
|
await diffVm.LoadAsync();
|
||||||
await ShowDiffModal(diffVm);
|
await ShowDiffModal(diffVm);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanOpenDiff() => WorktreePath != null;
|
private bool CanDiffMergedRange =>
|
||||||
|
WorktreeBaseCommit != null && WorktreeHeadCommit != null && _listWorkingDir != null;
|
||||||
|
|
||||||
|
private bool CanOpenDiff() => WorktreePath != null || CanDiffMergedRange;
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
|
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
|
||||||
private void OpenWorktree()
|
private void OpenWorktree()
|
||||||
@@ -1235,6 +1301,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
NotifySessionSections();
|
NotifySessionSections();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnWorktreeHeadCommitChanged(string? value) =>
|
||||||
|
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
|
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -1473,7 +1542,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
// hasChildren: conflicts arrive via PlanningMergeConflictEvent → conflict dialog
|
// hasChildren: conflicts arrive via PlanningMergeConflictEvent → conflict dialog
|
||||||
}
|
}
|
||||||
catch { /* stale review action; broadcast reconciles */ }
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A real failure (e.g. a child still needs attention, so the unit can't
|
||||||
|
// be approved yet) must not vanish — tell the user why nothing happened.
|
||||||
|
if (ShowErrorAsync != null)
|
||||||
|
await ShowErrorAsync(ex.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -154,21 +154,23 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
if (ShowConflictDialog == null || _dbFactory == null) return;
|
if (ShowConflictDialog == null || _dbFactory == null) return;
|
||||||
|
|
||||||
string subtaskTitle = subtaskId;
|
string subtaskTitle = subtaskId;
|
||||||
string worktreePath = System.Environment.CurrentDirectory;
|
// The conflict lives in the list's working dir (the repo being merged into),
|
||||||
|
// not the subtask worktree. VS Code must open this folder to show the merge UI.
|
||||||
|
string repoDirectory = System.Environment.CurrentDirectory;
|
||||||
string targetBranch = Worker?.LastApproveTarget ?? "main";
|
string targetBranch = Worker?.LastApproveTarget ?? "main";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
var entity = await ctx.Tasks
|
var entity = await ctx.Tasks
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.List)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(t => t.Id == subtaskId);
|
.FirstOrDefaultAsync(t => t.Id == subtaskId);
|
||||||
if (entity != null)
|
if (entity != null)
|
||||||
{
|
{
|
||||||
subtaskTitle = entity.Title;
|
subtaskTitle = entity.Title;
|
||||||
if (entity.Worktree?.Path is { } p)
|
if (entity.List?.WorkingDir is { } dir && !string.IsNullOrWhiteSpace(dir))
|
||||||
worktreePath = p;
|
repoDirectory = dir;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
|
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
|
||||||
@@ -179,7 +181,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
subtaskTitle,
|
subtaskTitle,
|
||||||
targetBranch,
|
targetBranch,
|
||||||
conflictedFiles,
|
conflictedFiles,
|
||||||
worktreePath);
|
repoDirectory);
|
||||||
|
|
||||||
await ShowConflictDialog(vm);
|
await ShowConflictDialog(vm);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
|||||||
|
|
||||||
public enum DiffLineKind { Add, Del, Ctx, File }
|
public enum DiffLineKind { Add, Del, Ctx, File }
|
||||||
|
|
||||||
|
public enum DiffFileStatus { Modified, Added, Deleted, Renamed }
|
||||||
|
|
||||||
public sealed class DiffLineViewModel
|
public sealed class DiffLineViewModel
|
||||||
{
|
{
|
||||||
public required DiffLineKind Kind { get; init; }
|
public required DiffLineKind Kind { get; init; }
|
||||||
@@ -32,10 +34,27 @@ public sealed class DiffLineViewModel
|
|||||||
|
|
||||||
public sealed class DiffFileViewModel
|
public sealed class DiffFileViewModel
|
||||||
{
|
{
|
||||||
public required string Path { get; init; }
|
public required string Path { get; set; }
|
||||||
|
public string? OldPath { get; set; }
|
||||||
|
public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified;
|
||||||
|
public bool IsBinary { get; set; }
|
||||||
public int Additions { get; set; }
|
public int Additions { get; set; }
|
||||||
public int Deletions { get; set; }
|
public int Deletions { get; set; }
|
||||||
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
|
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
|
||||||
|
|
||||||
|
/// Single-letter badge for the file's change kind (A/M/D/R).
|
||||||
|
public string StatusCode => Status switch
|
||||||
|
{
|
||||||
|
DiffFileStatus.Added => "A",
|
||||||
|
DiffFileStatus.Deleted => "D",
|
||||||
|
DiffFileStatus.Renamed => "R",
|
||||||
|
_ => "M",
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool HasLines => Lines.Count > 0;
|
||||||
|
|
||||||
|
/// A text file that produced no diff hunks (e.g. a newly added empty file).
|
||||||
|
public bool IsEmptyContent => !IsBinary && Lines.Count == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class DiffModalViewModel : ViewModelBase
|
public sealed partial class DiffModalViewModel : ViewModelBase
|
||||||
@@ -44,6 +63,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
public required string WorktreePath { get; init; }
|
public required string WorktreePath { get; init; }
|
||||||
public string? BaseRef { get; init; }
|
public string? BaseRef { get; init; }
|
||||||
|
/// When set together with <see cref="FromCommitRange"/>, the diff is computed as
|
||||||
|
/// <c>BaseRef..HeadCommit</c> inside <see cref="WorktreePath"/> (used as the repo
|
||||||
|
/// dir) — lets a merged task's diff be viewed after its worktree is gone.
|
||||||
|
public string? HeadCommit { get; init; }
|
||||||
|
public bool FromCommitRange { get; init; }
|
||||||
public string? TaskId { get; init; }
|
public string? TaskId { get; init; }
|
||||||
public string TaskTitle { get; init; } = "";
|
public string TaskTitle { get; init; } = "";
|
||||||
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
|
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
|
||||||
@@ -77,6 +101,8 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
|||||||
var vm = ResolveMergeVm();
|
var vm = ResolveMergeVm();
|
||||||
await vm.InitializeAsync(TaskId, TaskTitle);
|
await vm.InitializeAsync(TaskId, TaskTitle);
|
||||||
await ShowMergeModal(vm);
|
await ShowMergeModal(vm);
|
||||||
|
// The diff is stale once the worktree has been merged away — close it too.
|
||||||
|
if (vm.Merged) CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAsync(CancellationToken ct = default)
|
public async Task LoadAsync(CancellationToken ct = default)
|
||||||
@@ -87,9 +113,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
|||||||
string raw;
|
string raw;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
raw = BaseRef is not null
|
raw = FromCommitRange && BaseRef is not null && HeadCommit is not null
|
||||||
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct)
|
||||||
: await _git.GetDiffAsync(WorktreePath, ct);
|
: BaseRef is not null
|
||||||
|
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
||||||
|
: await _git.GetDiffAsync(WorktreePath, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
|
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
|
||||||
|
/// close itself after this modal closes.
|
||||||
|
public bool Merged { get; private set; }
|
||||||
|
|
||||||
public MergeModalViewModel(WorkerClient worker)
|
public MergeModalViewModel(WorkerClient worker)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
@@ -80,6 +84,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
switch (result.Status)
|
switch (result.Status)
|
||||||
{
|
{
|
||||||
case "merged":
|
case "merged":
|
||||||
|
Merged = true;
|
||||||
SuccessMessage = result.ErrorMessage is not null
|
SuccessMessage = result.ErrorMessage is not null
|
||||||
? $"Merged with warning: {result.ErrorMessage}"
|
? $"Merged with warning: {result.ErrorMessage}"
|
||||||
: Loc.T("vm.merge.merged");
|
: Loc.T("vm.merge.merged");
|
||||||
|
|||||||
@@ -27,6 +27,36 @@ public static class UnifiedDiffParser
|
|||||||
|
|
||||||
if (current == null) continue;
|
if (current == null) continue;
|
||||||
|
|
||||||
|
// File-level metadata that carries the change kind.
|
||||||
|
if (line.StartsWith("new file", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
current.Status = DiffFileStatus.Added;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("deleted file", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
current.Status = DiffFileStatus.Deleted;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("rename from ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
current.Status = DiffFileStatus.Renamed;
|
||||||
|
current.OldPath = line["rename from ".Length..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("rename to ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
current.Status = DiffFileStatus.Renamed;
|
||||||
|
current.Path = line["rename to ".Length..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("Binary files", StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("GIT binary patch", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
current.IsBinary = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (line.StartsWith("@@ ", StringComparison.Ordinal))
|
if (line.StartsWith("@@ ", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
// e.g. "@@ -10,7 +10,9 @@"
|
// e.g. "@@ -10,7 +10,9 @@"
|
||||||
@@ -34,13 +64,15 @@ public static class UnifiedDiffParser
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip diff metadata lines
|
// Skip remaining diff metadata lines
|
||||||
if (line.StartsWith("--- ", StringComparison.Ordinal) ||
|
if (line.StartsWith("--- ", StringComparison.Ordinal) ||
|
||||||
line.StartsWith("+++ ", StringComparison.Ordinal) ||
|
line.StartsWith("+++ ", StringComparison.Ordinal) ||
|
||||||
line.StartsWith("index ", StringComparison.Ordinal) ||
|
line.StartsWith("index ", StringComparison.Ordinal) ||
|
||||||
line.StartsWith("new file", StringComparison.Ordinal) ||
|
line.StartsWith("old mode", StringComparison.Ordinal) ||
|
||||||
line.StartsWith("deleted file", StringComparison.Ordinal) ||
|
line.StartsWith("new mode", StringComparison.Ordinal) ||
|
||||||
line.StartsWith("Binary ", StringComparison.Ordinal))
|
line.StartsWith("similarity index", StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("copy from", StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("copy to", StringComparison.Ordinal))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (line.StartsWith('+'))
|
if (line.StartsWith('+'))
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
private readonly string _planningTaskId;
|
private readonly string _planningTaskId;
|
||||||
private readonly string _worktreePath;
|
// The repository directory that is currently mid-merge (the list's working dir),
|
||||||
|
// NOT the subtask worktree. Opening this folder is what makes VS Code show its
|
||||||
|
// merge-conflict resolution UI.
|
||||||
|
private readonly string _repoDirectory;
|
||||||
|
|
||||||
public string SubtaskTitle { get; }
|
public string SubtaskTitle { get; }
|
||||||
public string TargetBranch { get; }
|
public string TargetBranch { get; }
|
||||||
@@ -29,11 +32,11 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
|
|||||||
string subtaskTitle,
|
string subtaskTitle,
|
||||||
string targetBranch,
|
string targetBranch,
|
||||||
IReadOnlyList<string> conflictedFiles,
|
IReadOnlyList<string> conflictedFiles,
|
||||||
string worktreePath)
|
string repoDirectory)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_planningTaskId = planningTaskId;
|
_planningTaskId = planningTaskId;
|
||||||
_worktreePath = worktreePath;
|
_repoDirectory = repoDirectory;
|
||||||
SubtaskTitle = subtaskTitle;
|
SubtaskTitle = subtaskTitle;
|
||||||
TargetBranch = targetBranch;
|
TargetBranch = targetBranch;
|
||||||
ConflictedFiles = conflictedFiles;
|
ConflictedFiles = conflictedFiles;
|
||||||
@@ -44,12 +47,13 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var args = string.Join(" ", ConflictedFiles.Select(f => $"\"{f}\""));
|
// Open the folder that is mid-merge so VS Code shows the Source Control
|
||||||
|
// merge-conflict UI for every conflicted file. Opening individual files
|
||||||
|
// gives only a plain editor with no conflict resolution affordances.
|
||||||
Process.Start(new ProcessStartInfo
|
Process.Start(new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "code",
|
FileName = "code",
|
||||||
Arguments = args,
|
Arguments = $"\"{_repoDirectory}\"",
|
||||||
WorkingDirectory = _worktreePath,
|
|
||||||
UseShellExecute = true,
|
UseShellExecute = true,
|
||||||
});
|
});
|
||||||
VsCodeError = null;
|
VsCodeError = null;
|
||||||
|
|||||||
@@ -313,6 +313,22 @@
|
|||||||
<ScrollViewer IsVisible="{Binding IsSessionTab}" Padding="14,10">
|
<ScrollViewer IsVisible="{Binding IsSessionTab}" Padding="14,10">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
|
|
||||||
|
<!-- Attention band: a child failed, was cancelled, still needs its own
|
||||||
|
review, or reported roadblocks. The parent stays waiting until resolved. -->
|
||||||
|
<Border IsVisible="{Binding HasChildrenNeedingAttention}"
|
||||||
|
Background="{DynamicResource ErrorTintBrush}"
|
||||||
|
BorderBrush="{DynamicResource BloodBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="8" Padding="10,8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Warning}"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
Width="14" Height="14" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="meta" Text="{Binding ChildrenAttentionText}"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Child outcomes -->
|
<!-- Child outcomes -->
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}">
|
<StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}">
|
||||||
<TextBlock Classes="section-label" Text="OUTCOMES" />
|
<TextBlock Classes="section-label" Text="OUTCOMES" />
|
||||||
|
|||||||
@@ -26,51 +26,100 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ctl:ModalShell.Footer>
|
</ctl:ModalShell.Footer>
|
||||||
|
|
||||||
<!-- Body: sidebar + diff content -->
|
<!-- Body: two islands — file list | diff content -->
|
||||||
<Grid ColumnDefinitions="240,*">
|
<Grid ColumnDefinitions="280,12,*" Margin="16">
|
||||||
|
|
||||||
<!-- File sidebar -->
|
<!-- Files island -->
|
||||||
<Border Grid.Column="0"
|
<Border Grid.Column="0" Classes="island">
|
||||||
Classes="sidebar-pane">
|
<DockPanel>
|
||||||
<ListBox ItemsSource="{Binding Files}"
|
<Border DockPanel.Dock="Top" Classes="island-header">
|
||||||
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
|
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.diff.filesHeader}"/>
|
||||||
Background="Transparent"
|
</Border>
|
||||||
BorderThickness="0"
|
<ListBox ItemsSource="{Binding Files}"
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
|
||||||
<ListBox.ItemTemplate>
|
Background="Transparent"
|
||||||
<DataTemplate x:DataType="vm:DiffFileViewModel">
|
BorderThickness="0"
|
||||||
<Border Padding="10,8" Background="Transparent">
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||||
<StackPanel Spacing="4">
|
<ListBox.ItemTemplate>
|
||||||
<TextBlock Classes="path-mono" Text="{Binding Path}"
|
<DataTemplate x:DataType="vm:DiffFileViewModel">
|
||||||
TextTrimming="PrefixCharacterEllipsis"/>
|
<Border Padding="10,8" Background="Transparent">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Spacing="4">
|
||||||
<Border Classes="chip" Padding="5,2">
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
<TextBlock Foreground="{DynamicResource MossBrightBrush}"
|
<Border Grid.Column="0" Tag="{Binding StatusCode}"
|
||||||
Text="{Binding Additions, StringFormat='+{0}'}"/>
|
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
|
||||||
</Border>
|
VerticalAlignment="Center">
|
||||||
<Border Classes="chip" Padding="5,2">
|
<TextBlock Text="{Binding StatusCode}"
|
||||||
<TextBlock Foreground="{DynamicResource BloodBrush}"
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
Text="{Binding Deletions, StringFormat='−{0}'}"/>
|
FontSize="{StaticResource FontSizeEyebrow}"
|
||||||
</Border>
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding Path}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="PrefixCharacterEllipsis"/>
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6"
|
||||||
|
IsVisible="{Binding !IsBinary}">
|
||||||
|
<Border Classes="chip" Padding="5,2">
|
||||||
|
<TextBlock Foreground="{DynamicResource MossBrightBrush}"
|
||||||
|
Text="{Binding Additions, StringFormat='+{0}'}"/>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="chip" Padding="5,2">
|
||||||
|
<TextBlock Foreground="{DynamicResource BloodBrush}"
|
||||||
|
Text="{Binding Deletions, StringFormat='−{0}'}"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</Border>
|
||||||
</Border>
|
</DataTemplate>
|
||||||
</DataTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox>
|
||||||
</ListBox>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Diff content -->
|
<!-- Diff content island -->
|
||||||
<Grid Grid.Column="1" Background="{DynamicResource VoidBrush}">
|
<Border Grid.Column="2" Classes="island">
|
||||||
<TextBlock Classes="body" Text="{Binding StatusMessage}"
|
<DockPanel>
|
||||||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
<Border DockPanel.Dock="Top" Classes="island-header"
|
||||||
HorizontalAlignment="Center"
|
IsVisible="{Binding SelectedFile, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
VerticalAlignment="Center"/>
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
<Border Grid.Column="0" Tag="{Binding SelectedFile.StatusCode}"
|
||||||
VerticalScrollBarVisibility="Auto">
|
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
|
||||||
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
|
VerticalAlignment="Center">
|
||||||
</ScrollViewer>
|
<TextBlock Text="{Binding SelectedFile.StatusCode}"
|
||||||
</Grid>
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="{StaticResource FontSizeEyebrow}"
|
||||||
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding SelectedFile.Path}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="PrefixCharacterEllipsis"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Background="{DynamicResource VoidBrush}">
|
||||||
|
<!-- Load / no-changes message -->
|
||||||
|
<TextBlock Classes="body" Text="{Binding StatusMessage}"
|
||||||
|
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
<!-- Binary file -->
|
||||||
|
<TextBlock Classes="body" Text="{loc:Tr modals.diff.binary}"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
IsVisible="{Binding SelectedFile.IsBinary}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
<!-- Empty / no-content file -->
|
||||||
|
<TextBlock Classes="body" Text="{loc:Tr modals.diff.empty}"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
IsVisible="{Binding SelectedFile.IsEmptyContent}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
<!-- Diff content -->
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
IsVisible="{Binding SelectedFile.HasLines}">
|
||||||
|
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</ctl:ModalShell>
|
</ctl:ModalShell>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -102,8 +102,10 @@ approve is the single review+merge action. Review transitions live in `TaskState
|
|||||||
`PlanningSessionManager.FinalizeAsync` is the single path:
|
`PlanningSessionManager.FinalizeAsync` is the single path:
|
||||||
|
|
||||||
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized` and sets `Status` to `WaitingForChildren` (or `WaitingForReview` if the parent has no children).
|
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized` and sets `Status` to `WaitingForChildren` (or `WaitingForReview` if the parent has no children).
|
||||||
2. `PlanningChainCoordinator.SetupChainAsync` attaches the `agent` tag to every child, enqueues child[0], and `BlockOn`s child[i] → child[i-1].
|
2. `PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false)` establishes the blocked-by chain (`BlockOn`s child[i] → child[i-1]) but **leaves children `Idle`** — finalize never auto-queues. Queueing is a deliberate user action: `QueuePlanAsync` (hub `QueuePlanningSubtasksAsync`, the "Queue plan" button) calls `SetupChainAsync(parent, enqueue: true)`, which sets every non-terminal child `Queued` and re-applies the chain.
|
||||||
3. The first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
|
3. Once queued, the first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
|
||||||
|
|
||||||
|
A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks) does **not** advance the parent — the parent stays in `WaitingForChildren` until every child is terminal. The UI surfaces blocked children on the parent's Session tab (`ChildOutcomes` + a "children need attention" band) so the roadblock is visible without forcing a transition.
|
||||||
|
|
||||||
`TaskRepository.FinalizePlanningAsync` no longer exists. The `Mark*Async` repository helpers are `internal` — only `TaskStateService` calls them.
|
`TaskRepository.FinalizePlanningAsync` no longer exists. The `Mark*Async` repository helpers are `internal` — only `TaskStateService` calls them.
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<ApplicationIcon>ClaudeTaskWorker.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
BIN
src/ClaudeDo.Worker/ClaudeTaskWorker.ico
Normal file
BIN
src/ClaudeDo.Worker/ClaudeTaskWorker.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -325,7 +325,9 @@ public sealed class TaskMergeService
|
|||||||
: targetBranch;
|
: targetBranch;
|
||||||
|
|
||||||
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
|
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
|
||||||
return await MergeAsync(taskId, target, removeWorktree: false, $"Merge {wt.BranchName}", ct);
|
// Remove the worktree on approve (matching the unit-merge path) so merged
|
||||||
|
// worktrees don't pile up; the merge commit on the target branch is the record.
|
||||||
|
return await MergeAsync(taskId, target, removeWorktree: true, $"Merge {wt.BranchName}", ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MergeResult Blocked(string reason) =>
|
private static MergeResult Blocked(string reason) =>
|
||||||
|
|||||||
@@ -19,17 +19,21 @@ public sealed class PlanningChainCoordinator
|
|||||||
_state = state;
|
_state = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sets up a sequential queue chain over a planning parent's children.
|
// Sets up a sequential chain over a planning parent's children.
|
||||||
// - First non-terminal child gets Status=Queued, BlockedByTaskId=null.
|
// - First non-terminal child gets BlockedByTaskId=null.
|
||||||
// - Each subsequent non-terminal child gets Status=Queued + BlockedByTaskId=<predecessor>,
|
// - Each subsequent non-terminal child gets BlockedByTaskId=<predecessor>,
|
||||||
// so the picker skips them until the predecessor finishes.
|
// so the picker skips them until the predecessor finishes.
|
||||||
|
// - When enqueue is true, each non-terminal child is also set to Status=Queued
|
||||||
|
// (the user-driven "Queue plan"). When false (finalize), children are left
|
||||||
|
// Idle and only the blocked-by links are established, so nothing runs until
|
||||||
|
// the user queues the plan.
|
||||||
// - Terminal children (Done/Failed/Cancelled) are left untouched; they are
|
// - Terminal children (Done/Failed/Cancelled) are left untouched; they are
|
||||||
// skipped when computing predecessors so a re-run on a partially executed
|
// skipped when computing predecessors so a re-run on a partially executed
|
||||||
// chain leaves history alone but still reshapes the tail.
|
// chain leaves history alone but still reshapes the tail.
|
||||||
// - Running children abort the operation — the chain cannot be reshaped while
|
// - Running children abort the operation — the chain cannot be reshaped while
|
||||||
// one of its members is mid-flight.
|
// one of its members is mid-flight.
|
||||||
// Returns the number of children placed in the chain.
|
// Returns the number of children placed in the chain.
|
||||||
internal async Task<int> SetupChainAsync(string parentTaskId, CancellationToken ct = default)
|
internal async Task<int> SetupChainAsync(string parentTaskId, bool enqueue, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var parent = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == parentTaskId, ct)
|
var parent = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == parentTaskId, ct)
|
||||||
@@ -56,7 +60,8 @@ public sealed class PlanningChainCoordinator
|
|||||||
var state = _state();
|
var state = _state();
|
||||||
for (int i = 0; i < sequenceable.Count; i++)
|
for (int i = 0; i < sequenceable.Count; i++)
|
||||||
{
|
{
|
||||||
await state.EnqueueAsync(sequenceable[i].Id, ct);
|
if (enqueue)
|
||||||
|
await state.EnqueueAsync(sequenceable[i].Id, ct);
|
||||||
if (i == 0)
|
if (i == 0)
|
||||||
await state.UnblockAsync(sequenceable[i].Id, ct);
|
await state.UnblockAsync(sequenceable[i].Id, ct);
|
||||||
else
|
else
|
||||||
@@ -81,7 +86,7 @@ public sealed class PlanningChainCoordinator
|
|||||||
if (phase != PlanningPhase.Finalized)
|
if (phase != PlanningPhase.Finalized)
|
||||||
throw new InvalidOperationException("Plan must be finalized before it can be queued.");
|
throw new InvalidOperationException("Plan must be finalized before it can be queued.");
|
||||||
|
|
||||||
return await SetupChainAsync(parentTaskId, ct);
|
return await SetupChainAsync(parentTaskId, enqueue: true, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> OnChildFinishedAsync(
|
public async Task<string?> OnChildFinishedAsync(
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ public sealed class PlanningMcpService
|
|||||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Finalize the planning session, promoting all draft child tasks to queued or manual status.")]
|
[McpServerTool, Description("Finalize the planning session. Child tasks are left idle and chain-linked (each blocked by its predecessor); they are NOT queued automatically — the user queues the plan from the app when ready. The queueAgentTasks argument is accepted for compatibility but ignored.")]
|
||||||
public async Task<int> Finalize(
|
public async Task<int> Finalize(
|
||||||
bool queueAgentTasks,
|
bool queueAgentTasks,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -149,8 +149,10 @@ public sealed class PlanningMcpService
|
|||||||
|
|
||||||
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
|
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
int count = children.Count;
|
int count = children.Count;
|
||||||
if (queueAgentTasks && children.Count > 0)
|
// Establish the blocked-by chain but leave children Idle; queueing is a
|
||||||
count = await _chain.SetupChainAsync(ctx.ParentTaskId, cancellationToken);
|
// deliberate user action ("Queue plan"), never an automatic finalize step.
|
||||||
|
if (children.Count > 0)
|
||||||
|
count = await _chain.SetupChainAsync(ctx.ParentTaskId, enqueue: false, cancellationToken);
|
||||||
|
|
||||||
foreach (var c in children)
|
foreach (var c in children)
|
||||||
await BroadcastTaskUpdatedAsync(c.Id, cancellationToken);
|
await BroadcastTaskUpdatedAsync(c.Id, cancellationToken);
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
parent.FinishedAt = DateTime.UtcNow;
|
parent.FinishedAt = DateTime.UtcNow;
|
||||||
await ctx.SaveChangesAsync(ct);
|
await ctx.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Surface the Done transition to the UI. Without this the parent row stays
|
||||||
|
// visibly stuck in WaitingForReview even though the unit merge completed.
|
||||||
|
await _broadcaster.TaskUpdated(parentTaskId);
|
||||||
|
|
||||||
// Only planning builds an integration branch via the aggregator; skip cleanup otherwise.
|
// Only planning builds an integration branch via the aggregator; skip cleanup otherwise.
|
||||||
if (isPlanning)
|
if (isPlanning)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -209,12 +209,13 @@ public sealed class PlanningSessionManager
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
finalizeResult.Reason ?? $"Could not finalize planning for task {taskId}.");
|
finalizeResult.Reason ?? $"Could not finalize planning for task {taskId}.");
|
||||||
|
|
||||||
int count = 0;
|
// Establish the blocked-by chain but leave children Idle; queueing is a
|
||||||
|
// deliberate user action ("Queue plan"), never an automatic finalize step.
|
||||||
|
// queueAgentTasks is accepted for compatibility but no longer auto-queues.
|
||||||
var children = await tasks.GetChildrenAsync(taskId, ct);
|
var children = await tasks.GetChildrenAsync(taskId, ct);
|
||||||
if (queueAgentTasks && children.Count > 0)
|
int count = children.Count;
|
||||||
count = await _chain.SetupChainAsync(taskId, ct);
|
if (children.Count > 0)
|
||||||
else
|
count = await _chain.SetupChainAsync(taskId, enqueue: false, ct);
|
||||||
count = children.Count;
|
|
||||||
|
|
||||||
// Best-effort cleanup — don't block finalization on git state.
|
// Best-effort cleanup — don't block finalization on git state.
|
||||||
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace ClaudeDo.Worker.Refine;
|
|||||||
public sealed class RefineRunner : IRefineRunner
|
public sealed class RefineRunner : IRefineRunner
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
|
||||||
private const int MaxTurns = 25;
|
private const int MaxTurns = 5;
|
||||||
|
|
||||||
private readonly IClaudeProcess _claude;
|
private readonly IClaudeProcess _claude;
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public class ConflictResolutionViewModelTests
|
|||||||
subtaskTitle: "My subtask",
|
subtaskTitle: "My subtask",
|
||||||
targetBranch: "main",
|
targetBranch: "main",
|
||||||
conflictedFiles: new[] { "src/Foo.cs", "src/Bar.cs" },
|
conflictedFiles: new[] { "src/Foo.cs", "src/Bar.cs" },
|
||||||
worktreePath: "C:/worktrees/plan-1");
|
repoDirectory: "C:/repos/plan-1");
|
||||||
|
|
||||||
// ------------------------------------------------------------------ tests
|
// ------------------------------------------------------------------ tests
|
||||||
|
|
||||||
|
|||||||
109
tests/ClaudeDo.Ui.Tests/ViewModels/UnifiedDiffParserTests.cs
Normal file
109
tests/ClaudeDo.Ui.Tests/ViewModels/UnifiedDiffParserTests.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class UnifiedDiffParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Modified_file_counts_additions_and_deletions()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"diff --git a/src/Foo.cs b/src/Foo.cs\n" +
|
||||||
|
"index 111..222 100644\n" +
|
||||||
|
"--- a/src/Foo.cs\n" +
|
||||||
|
"+++ b/src/Foo.cs\n" +
|
||||||
|
"@@ -1,3 +1,3 @@\n" +
|
||||||
|
" ctx\n" +
|
||||||
|
"-old\n" +
|
||||||
|
"+new\n";
|
||||||
|
|
||||||
|
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
|
||||||
|
Assert.Equal("src/Foo.cs", file.Path);
|
||||||
|
Assert.Equal(DiffFileStatus.Modified, file.Status);
|
||||||
|
Assert.Equal("M", file.StatusCode);
|
||||||
|
Assert.Equal(1, file.Additions);
|
||||||
|
Assert.Equal(1, file.Deletions);
|
||||||
|
Assert.True(file.HasLines);
|
||||||
|
Assert.False(file.IsBinary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void New_file_is_marked_added()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"diff --git a/New.cs b/New.cs\n" +
|
||||||
|
"new file mode 100644\n" +
|
||||||
|
"index 000..abc\n" +
|
||||||
|
"--- /dev/null\n" +
|
||||||
|
"+++ b/New.cs\n" +
|
||||||
|
"@@ -0,0 +1,1 @@\n" +
|
||||||
|
"+hello\n";
|
||||||
|
|
||||||
|
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
|
||||||
|
Assert.Equal(DiffFileStatus.Added, file.Status);
|
||||||
|
Assert.Equal("A", file.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deleted_file_is_marked_deleted()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"diff --git a/Gone.cs b/Gone.cs\n" +
|
||||||
|
"deleted file mode 100644\n" +
|
||||||
|
"index abc..000\n" +
|
||||||
|
"--- a/Gone.cs\n" +
|
||||||
|
"+++ /dev/null\n" +
|
||||||
|
"@@ -1,1 +0,0 @@\n" +
|
||||||
|
"-bye\n";
|
||||||
|
|
||||||
|
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
|
||||||
|
Assert.Equal(DiffFileStatus.Deleted, file.Status);
|
||||||
|
Assert.Equal("D", file.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rename_captures_old_and_new_path()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"diff --git a/Old.cs b/New.cs\n" +
|
||||||
|
"similarity index 100%\n" +
|
||||||
|
"rename from Old.cs\n" +
|
||||||
|
"rename to New.cs\n";
|
||||||
|
|
||||||
|
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
|
||||||
|
Assert.Equal(DiffFileStatus.Renamed, file.Status);
|
||||||
|
Assert.Equal("R", file.StatusCode);
|
||||||
|
Assert.Equal("Old.cs", file.OldPath);
|
||||||
|
Assert.Equal("New.cs", file.Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Binary_file_is_flagged_with_no_lines()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"diff --git a/img.png b/img.png\n" +
|
||||||
|
"new file mode 100644\n" +
|
||||||
|
"index 000..abc\n" +
|
||||||
|
"Binary files /dev/null and b/img.png differ\n";
|
||||||
|
|
||||||
|
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
|
||||||
|
Assert.True(file.IsBinary);
|
||||||
|
Assert.False(file.HasLines);
|
||||||
|
Assert.False(file.IsEmptyContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_new_file_reports_empty_content()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"diff --git a/Empty.txt b/Empty.txt\n" +
|
||||||
|
"new file mode 100644\n" +
|
||||||
|
"index 000..000\n";
|
||||||
|
|
||||||
|
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
|
||||||
|
Assert.Equal(DiffFileStatus.Added, file.Status);
|
||||||
|
Assert.False(file.HasLines);
|
||||||
|
Assert.True(file.IsEmptyContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
{
|
{
|
||||||
await SeedPlanningFamilyAsync("P", 3);
|
await SeedPlanningFamilyAsync("P", 3);
|
||||||
|
|
||||||
var count = await _sut.SetupChainAsync("P", default);
|
var count = await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
Assert.Equal(3, count);
|
Assert.Equal(3, count);
|
||||||
var kids = await GetChildrenAsync("P");
|
var kids = await GetChildrenAsync("P");
|
||||||
@@ -92,7 +92,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
{
|
{
|
||||||
await SeedPlanningFamilyAsync("P", 2, childStatus: TaskStatus.Idle);
|
await SeedPlanningFamilyAsync("P", 2, childStatus: TaskStatus.Idle);
|
||||||
|
|
||||||
var count = await _sut.SetupChainAsync("P", default);
|
var count = await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
Assert.Equal(2, count);
|
Assert.Equal(2, count);
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
public async Task OnChildDone_UnblocksTheSuccessor()
|
public async Task OnChildDone_UnblocksTheSuccessor()
|
||||||
{
|
{
|
||||||
await SeedPlanningFamilyAsync("P", 3);
|
await SeedPlanningFamilyAsync("P", 3);
|
||||||
await _sut.SetupChainAsync("P", default);
|
await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
// Mark the head child Done before announcing.
|
// Mark the head child Done before announcing.
|
||||||
await using (var ctx = _factory.CreateDbContext())
|
await using (var ctx = _factory.CreateDbContext())
|
||||||
@@ -128,7 +128,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
public async Task OnChildFailed_CancelsPendingSuccessors_ChainIsNotWedged()
|
public async Task OnChildFailed_CancelsPendingSuccessors_ChainIsNotWedged()
|
||||||
{
|
{
|
||||||
await SeedPlanningFamilyAsync("P", 3);
|
await SeedPlanningFamilyAsync("P", 3);
|
||||||
await _sut.SetupChainAsync("P", default);
|
await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
await using (var ctx = _factory.CreateDbContext())
|
await using (var ctx = _factory.CreateDbContext())
|
||||||
{
|
{
|
||||||
@@ -153,7 +153,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
{
|
{
|
||||||
// Chain: c0 → c1 → c2 → c3. c1 fails mid-chain; c2 and c3 must be cancelled.
|
// Chain: c0 → c1 → c2 → c3. c1 fails mid-chain; c2 and c3 must be cancelled.
|
||||||
await SeedPlanningFamilyAsync("P", 4);
|
await SeedPlanningFamilyAsync("P", 4);
|
||||||
await _sut.SetupChainAsync("P", default);
|
await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
// Mark c0 Done so c1 was unblocked; c1 ran and failed.
|
// Mark c0 Done so c1 was unblocked; c1 ran and failed.
|
||||||
await using (var ctx = _factory.CreateDbContext())
|
await using (var ctx = _factory.CreateDbContext())
|
||||||
@@ -182,7 +182,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
public async Task OnChildDone_LastChild_ReturnsNull()
|
public async Task OnChildDone_LastChild_ReturnsNull()
|
||||||
{
|
{
|
||||||
await SeedPlanningFamilyAsync("P", 2);
|
await SeedPlanningFamilyAsync("P", 2);
|
||||||
await _sut.SetupChainAsync("P", default);
|
await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
await using (var ctx = _factory.CreateDbContext())
|
await using (var ctx = _factory.CreateDbContext())
|
||||||
{
|
{
|
||||||
@@ -208,7 +208,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => _sut.SetupChainAsync("P", default));
|
() => _sut.SetupChainAsync("P", enqueue: true, default));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -230,7 +230,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
var count = await _sut.SetupChainAsync("P", default);
|
var count = await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
Assert.Equal(4, count);
|
Assert.Equal(4, count);
|
||||||
var kids = await GetChildrenAsync("P");
|
var kids = await GetChildrenAsync("P");
|
||||||
@@ -257,7 +257,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
var count = await _sut.SetupChainAsync("P", default);
|
var count = await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
// Only the two non-terminal tail children get chained.
|
// Only the two non-terminal tail children get chained.
|
||||||
Assert.Equal(2, count);
|
Assert.Equal(2, count);
|
||||||
@@ -303,4 +303,21 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
var kids = await GetChildrenAsync("P");
|
var kids = await GetChildrenAsync("P");
|
||||||
Assert.All(kids, k => Assert.Equal(TaskStatus.Idle, k.Status));
|
Assert.All(kids, k => Assert.Equal(TaskStatus.Idle, k.Status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetupChain_LinkOnly_LeavesChildrenIdle_ButEstablishesChain()
|
||||||
|
{
|
||||||
|
// Finalize path: children must stay Idle (nothing auto-queues) but still
|
||||||
|
// get the blocked-by chain so a later "Queue plan" runs them in order.
|
||||||
|
await SeedPlanningFamilyAsync("P", 3);
|
||||||
|
|
||||||
|
var count = await _sut.SetupChainAsync("P", enqueue: false, default);
|
||||||
|
|
||||||
|
Assert.Equal(3, count);
|
||||||
|
var kids = await GetChildrenAsync("P");
|
||||||
|
Assert.All(kids, k => Assert.Equal(TaskStatus.Idle, k.Status));
|
||||||
|
Assert.Null(kids[0].BlockedByTaskId);
|
||||||
|
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
|
||||||
|
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,19 +121,20 @@ public sealed class PlanningEndToEndTests : IDisposable
|
|||||||
Assert.Equal(PlanningPhase.Finalized, reload!.PlanningPhase);
|
Assert.Equal(PlanningPhase.Finalized, reload!.PlanningPhase);
|
||||||
|
|
||||||
var kids = await _tasks.GetChildrenAsync(parent.Id);
|
var kids = await _tasks.GetChildrenAsync(parent.Id);
|
||||||
// SetupChainAsync auto-attaches agent tag and queues all children;
|
// Finalize no longer auto-queues. Children stay Idle and chain-linked
|
||||||
// the first one is unblocked, the rest are BlockedBy their predecessor.
|
// (head unblocked, rest BlockedBy their predecessor) until the user
|
||||||
Assert.Equal(TaskStatus.Queued, kids[0].Status);
|
// explicitly queues the plan.
|
||||||
|
Assert.Equal(TaskStatus.Idle, kids[0].Status);
|
||||||
Assert.Null(kids[0].BlockedByTaskId);
|
Assert.Null(kids[0].BlockedByTaskId);
|
||||||
Assert.Equal(TaskStatus.Queued, kids[1].Status);
|
Assert.Equal(TaskStatus.Idle, kids[1].Status);
|
||||||
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
|
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regression: original bug was "queue never picks up planning tasks". After Finalize
|
// Regression: original bug was "queue never picks up planning tasks". Finalize
|
||||||
// with queueAgentTasks=true, the first child must be claimable by the queue picker
|
// leaves children Idle; queueing the plan (the explicit user gate) must make the
|
||||||
// automatically — without anyone calling WakeQueue() manually.
|
// first child claimable by the picker automatically — without a manual WakeQueue().
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FinalizeAsync_FirstChildIsClaimedByPicker_WithinDeadline()
|
public async Task QueuePlanAfterFinalize_FirstChildIsClaimedByPicker_WithinDeadline()
|
||||||
{
|
{
|
||||||
var listId = Guid.NewGuid().ToString();
|
var listId = Guid.NewGuid().ToString();
|
||||||
var wd = Path.Combine(Path.GetTempPath(), $"cd_e2e_wd_{Guid.NewGuid():N}");
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_e2e_wd_{Guid.NewGuid():N}");
|
||||||
@@ -160,12 +161,18 @@ public sealed class PlanningEndToEndTests : IDisposable
|
|||||||
|
|
||||||
var kidsBefore = await _tasks.GetChildrenAsync(parent.Id);
|
var kidsBefore = await _tasks.GetChildrenAsync(parent.Id);
|
||||||
var firstChildId = kidsBefore[0].Id;
|
var firstChildId = kidsBefore[0].Id;
|
||||||
var wakesBefore = _built.WakeCount();
|
|
||||||
|
|
||||||
await _manager.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
|
await _manager.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
|
||||||
|
|
||||||
// The picker should pick the first child immediately. Auto-wake fires inside
|
// Finalize leaves every child Idle — nothing is claimable yet.
|
||||||
// _state.EnqueueAsync; we don't need a manual WakeQueue() for the bug to be fixed.
|
var afterFinalize = await _tasks.GetChildrenAsync(parent.Id);
|
||||||
|
Assert.All(afterFinalize, k => Assert.Equal(TaskStatus.Idle, k.Status));
|
||||||
|
|
||||||
|
// Queueing the plan is the gate that enqueues + auto-wakes. The picker should
|
||||||
|
// then pick the first child immediately, without a manual WakeQueue().
|
||||||
|
var wakesBefore = _built.WakeCount();
|
||||||
|
await _built.Chain.QueuePlanAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
var picker = new QueuePicker(_db.CreateFactory());
|
var picker = new QueuePicker(_db.CreateFactory());
|
||||||
|
|
||||||
TaskEntity? claimed = null;
|
TaskEntity? claimed = null;
|
||||||
@@ -181,6 +188,6 @@ public sealed class PlanningEndToEndTests : IDisposable
|
|||||||
Assert.Equal(firstChildId, claimed!.Id);
|
Assert.Equal(firstChildId, claimed!.Id);
|
||||||
Assert.Equal(TaskStatus.Running, claimed.Status);
|
Assert.Equal(TaskStatus.Running, claimed.Status);
|
||||||
Assert.True(_built.WakeCount() > wakesBefore,
|
Assert.True(_built.WakeCount() > wakesBefore,
|
||||||
"TaskStateService.EnqueueAsync should auto-wake the queue.");
|
"QueuePlanAsync → EnqueueAsync should auto-wake the queue.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -531,6 +531,8 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
Assert.Equal(TaskStatus.Done, updated!.Status);
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||||
Assert.Equal(WorktreeState.Merged, wt!.State);
|
Assert.Equal(WorktreeState.Merged, wt!.State);
|
||||||
|
// Approve removes the worktree so merged worktrees don't pile up.
|
||||||
|
Assert.False(Directory.Exists(wtCtx.WorktreePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user