From 4c78a5d6fd7abb473cbca5892c57ea2fd53fc776 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 11:52:34 +0200 Subject: [PATCH 1/8] fix(localization): make locale-parity test able to detect a malformed locale file LocaleStore.Load silently skips a *.json that fails to parse, so a broken de.json left store.Available with only "en" and the parity loop compared English against itself, always green. Add two guards before the loop: loaded-locale count must match the number of *.json files in the folder, and "de" must be present via TryGet, both with failure messages naming the missing file. Also independently JsonDocument.Parse every *.json so a syntax error surfaces as a syntax error with the file name, not as "locale missing". LocaleStore's catch-and-skip is untouched -- a broken translation must still not crash the app at runtime. --- .../EnJsonCoverageTests.cs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/ClaudeDo.Localization.Tests/EnJsonCoverageTests.cs b/tests/ClaudeDo.Localization.Tests/EnJsonCoverageTests.cs index e9dcd8c9..b587d053 100644 --- a/tests/ClaudeDo.Localization.Tests/EnJsonCoverageTests.cs +++ b/tests/ClaudeDo.Localization.Tests/EnJsonCoverageTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using ClaudeDo.Localization; namespace ClaudeDo.Localization.Tests; @@ -16,10 +17,34 @@ public class EnJsonCoverageTests throw new DirectoryNotFoundException("Could not locate src/ClaudeDo.Localization/locales"); } + [Fact] + public void All_locale_files_parse_as_valid_json() + { + var localesDir = LocalesDir(); + foreach (var path in Directory.EnumerateFiles(localesDir, "*.json")) + { + try + { + JsonDocument.Parse(File.ReadAllText(path)); + } + catch (JsonException ex) + { + Assert.Fail($"{Path.GetFileName(path)} is not valid JSON: {ex.Message}"); + } + } + } + [Fact] public void Every_locale_has_same_keys_as_english() { - var store = LocaleStore.Load(LocalesDir()); + var localesDir = LocalesDir(); + var jsonFileCount = Directory.EnumerateFiles(localesDir, "*.json").Count(); + var store = LocaleStore.Load(localesDir); + Assert.True(store.Available.Count == jsonFileCount, + $"Expected {jsonFileCount} locale(s) to load from {localesDir}, but only {store.Available.Count} loaded " + + $"(loaded: {string.Join(", ", store.Available.Select(l => l.Code))}). A *.json file failed to parse."); + Assert.True(store.TryGet("de", out _), "Locale 'de' did not load — de.json failed to parse."); + Assert.True(store.TryGet("en", out var en)); var enKeys = en!.Strings.Keys.ToHashSet(); From e099d4f6cfda4f1d7dcdfb9bde4890a990ec17e7 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 11:53:28 +0200 Subject: [PATCH 2/8] test(data): assert migrated schema matches EF model (schema-drift guard) Sibling migrations branched off the same parent have silently dropped each other's columns during a SQLite table rebuild before, and every other Data.Tests fixture uses EnsureCreated, which skips the migration chain entirely and would never catch that. --- .../MigrationSchemaDriftTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/ClaudeDo.Data.Tests/MigrationSchemaDriftTests.cs diff --git a/tests/ClaudeDo.Data.Tests/MigrationSchemaDriftTests.cs b/tests/ClaudeDo.Data.Tests/MigrationSchemaDriftTests.cs new file mode 100644 index 00000000..65139540 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/MigrationSchemaDriftTests.cs @@ -0,0 +1,96 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Data.Sqlite; + +namespace ClaudeDo.Data.Tests; + +// Sibling migrations branched off the same parent have silently dropped each other's columns +// during a SQLite table rebuild before (see docs/explore-notes). Every other Data.Tests fixture +// uses EnsureCreated, which skips the migration chain entirely, so nothing else in the suite +// would catch that. This compares the schema the migration chain actually produces against the +// schema EnsureCreated builds from today's model. +public sealed class MigrationSchemaDriftTests : IDisposable +{ + private readonly string _migratedDbPath; + private readonly string _modelDbPath; + + public MigrationSchemaDriftTests() + { + _migratedDbPath = Path.Combine(Path.GetTempPath(), $"claudedo_drift_migrated_{Guid.NewGuid():N}.db"); + _modelDbPath = Path.Combine(Path.GetTempPath(), $"claudedo_drift_model_{Guid.NewGuid():N}.db"); + } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + foreach (var path in new[] { _migratedDbPath, _modelDbPath }) + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(path + suffix); } catch { } + } + + [Fact] + public void Migrated_schema_has_the_same_columns_as_the_current_model() + { + using (var migrated = new ClaudeDoDbContext(OptionsFor(_migratedDbPath))) + ClaudeDoDbContext.MigrateAndConfigure(migrated); + using (var model = new ClaudeDoDbContext(OptionsFor(_modelDbPath))) + model.Database.EnsureCreated(); + + var migratedColumns = ReadColumnsByTable(_migratedDbPath); + var modelColumns = ReadColumnsByTable(_modelDbPath); + + var mismatches = new List(); + foreach (var (table, expectedColumns) in modelColumns) + { + if (!migratedColumns.TryGetValue(table, out var actualColumns)) + { + mismatches.Add($"{table}: missing from migrated schema entirely"); + continue; + } + + var missingInMigrated = expectedColumns.Except(actualColumns).ToList(); + var extraInMigrated = actualColumns.Except(expectedColumns).ToList(); + if (missingInMigrated.Count > 0 || extraInMigrated.Count > 0) + { + mismatches.Add( + $"{table}: missing-in-migrated=[{string.Join(", ", missingInMigrated)}] " + + $"extra-in-migrated=[{string.Join(", ", extraInMigrated)}]"); + } + } + + Assert.True(mismatches.Count == 0, "Schema drift between migrated DB and current model:\n" + string.Join("\n", mismatches)); + } + + private static DbContextOptions OptionsFor(string dbPath) => + new DbContextOptionsBuilder() + .UseSqlite($"Data Source={dbPath}") + .Options; + + private static Dictionary> ReadColumnsByTable(string dbPath) + { + using var conn = new SqliteConnection($"Data Source={dbPath}"); + conn.Open(); + + var tables = new List(); + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name != '__EFMigrationsHistory' AND name NOT LIKE 'sqlite_%'"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + tables.Add(reader.GetString(0)); + } + + var result = new Dictionary>(); + foreach (var table in tables) + { + var columns = new HashSet(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"PRAGMA table_info(\"{table}\")"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + columns.Add(reader.GetString(reader.GetOrdinal("name"))); + result[table] = columns; + } + + return result; + } +} From 25ead4c8dd166f5f41004c5c6fce431baa248da3 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 12:09:12 +0200 Subject: [PATCH 3/8] =?UTF-8?q?=EF=BB=BFchore(claude-do):=20feat(worktrees?= =?UTF-8?q?):=20warn=20about=20git=20worktrees=20ClaudeDo=20doesn't=20k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From `docs/open.md` (re-verified against the code 2026-09-02, still open): nothing reconciles `git worktree list` against the `worktrees` table. `ExternalMcpService.ListWorktrees` and the Worktrees overview read DB rows only; `WorktreeManager` calls git solely inside its self-heal path (`ListWorktreePathsForBranchAsync`). Observed once: 22 git-registered worktrees + 26 `claudedo/*` branches in `C: ClaudeDo-Task: 765fed13-db66-44e4-9285-c4b2d415b443 --- src/ClaudeDo.Data/Git/GitService.cs | 32 +++++++ .../Lifecycle/UntrackedWorktreeRecovery.cs | 89 +++++++++++++++++++ src/ClaudeDo.Worker/Program.cs | 1 + .../UntrackedWorktreeRecoveryTests.cs | 73 +++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index eb3febbc..b2c00879 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -338,6 +338,38 @@ public sealed class GitService return paths; } + /// All git-registered worktrees for , with their branch (short name, null if detached). + public async Task> ListWorktreesAsync(string repoDir, CancellationToken ct = default) + { + var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["worktree", "list", "--porcelain"], ct); + if (exitCode != 0) return new(); + + var result = new List<(string Path, string? Branch)>(); + string? currentPath = null; + string? currentBranch = null; + foreach (var raw in stdout.Split('\n')) + { + var line = raw.TrimEnd('\r'); + if (line.StartsWith("worktree ", StringComparison.Ordinal)) + { + currentPath = line["worktree ".Length..].Trim(); + } + else if (line.StartsWith("branch ", StringComparison.Ordinal)) + { + var b = line["branch ".Length..].Trim(); + currentBranch = b.StartsWith("refs/heads/", StringComparison.Ordinal) ? b["refs/heads/".Length..] : b; + } + else if (string.IsNullOrWhiteSpace(line)) + { + if (currentPath is not null) result.Add((currentPath, currentBranch)); + currentPath = null; + currentBranch = null; + } + } + if (currentPath is not null) result.Add((currentPath, currentBranch)); + return result; + } + public async Task WorktreePruneAsync(string repoDir, CancellationToken ct = default) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "prune"], ct); diff --git a/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs new file mode 100644 index 00000000..d97ae76e --- /dev/null +++ b/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs @@ -0,0 +1,89 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Lifecycle; + +/// +/// Startup-only sweep: warns when a list's repo has git-registered claudedo/* worktrees +/// that the worktrees table doesn't know about (e.g. left behind by a manual cleanup +/// outside the app). Detection only — nothing is removed or pruned. +/// +public sealed class UntrackedWorktreeRecovery : IHostedService +{ + public const string Phase = "untracked-worktrees"; + + private const string BranchPrefix = "claudedo/"; + + private readonly IDbContextFactory _dbFactory; + private readonly GitService _git; + private readonly HubBroadcaster _broadcaster; + private readonly ILogger _logger; + + public UntrackedWorktreeRecovery( + IDbContextFactory dbFactory, + GitService git, + HubBroadcaster broadcaster, + ILogger logger) + { + _dbFactory = dbFactory; + _git = git; + _broadcaster = broadcaster; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); + var lists = await new ListRepository(ctx).GetAllAsync(cancellationToken); + var dbPaths = (await new WorktreeRepository(ctx).GetAllAsync(cancellationToken)) + .Select(w => w.Path) + .ToList(); + + var foundAny = false; + foreach (var list in lists.Where(l => !string.IsNullOrWhiteSpace(l.WorkingDir))) + { + List<(string Path, string? Branch)> registered; + try + { + registered = await _git.ListWorktreesAsync(list.WorkingDir!, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Untracked worktree recovery: git worktree list failed for {WorkingDir}", list.WorkingDir); + continue; + } + + var claudedoPaths = registered + .Where(w => w.Branch is not null && w.Branch.StartsWith(BranchPrefix, StringComparison.Ordinal)) + .Select(w => w.Path); + + var untracked = FindUntrackedPaths(claudedoPaths, dbPaths); + if (untracked.Count == 0) continue; + + foundAny = true; + _logger.LogWarning( + "Untracked worktree recovery: found {Count} git worktree(s) under '{Repo}' that ClaudeDo's database doesn't know about; not touched", + untracked.Count, list.WorkingDir); + } + + if (!foundAny) + _logger.LogInformation("Untracked worktree recovery: no untracked worktrees found"); + + await _broadcaster.OperationProgress(OperationProgressOpKeys.StartupRecovery, Phase, lists.Count, lists.Count); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// Registered worktree paths not present among , comparing normalized full paths. + public static List FindUntrackedPaths(IEnumerable registeredPaths, IEnumerable dbPaths) + { + var known = dbPaths.Select(Normalize).ToHashSet(StringComparer.OrdinalIgnoreCase); + return registeredPaths.Where(p => !known.Contains(Normalize(p))).ToList(); + } + + private static string Normalize(string path) => + Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 3d28bc59..3c9f2bd9 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -77,6 +77,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddSignalR().AddJsonProtocol(options => { options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs new file mode 100644 index 00000000..a0d2561d --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs @@ -0,0 +1,73 @@ +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClaudeDo.Worker.Tests.Lifecycle; + +public sealed class UntrackedWorktreeRecoveryTests : IDisposable +{ + private readonly DbFixture _db = new(); + + public void Dispose() => _db.Dispose(); + + [Fact] + public void FindUntrackedPaths_PathNotInDb_IsReturned() + { + var registered = new[] { @"C:\repo\.worktrees\abc123" }; + var db = new[] { @"C:\repo\.worktrees\def456" }; + + var untracked = UntrackedWorktreeRecovery.FindUntrackedPaths(registered, db); + + Assert.Equal(registered, untracked); + } + + [Fact] + public void FindUntrackedPaths_PathInDb_IsExcluded() + { + var registered = new[] { @"C:\repo\.worktrees\abc123" }; + var db = new[] { @"C:\repo\.worktrees\abc123" }; + + var untracked = UntrackedWorktreeRecovery.FindUntrackedPaths(registered, db); + + Assert.Empty(untracked); + } + + [Fact] + public void FindUntrackedPaths_DifferentCasingAndSeparators_StillMatches() + { + var registered = new[] { @"C:\repo\.worktrees\abc123\" }; + var db = new[] { "c:/repo/.worktrees/ABC123" }; + + var untracked = UntrackedWorktreeRecovery.FindUntrackedPaths(registered, db); + + Assert.Empty(untracked); + } + + [Fact] + public void FindUntrackedPaths_EmptyDb_ReturnsAllRegistered() + { + var registered = new[] { @"C:\repo\.worktrees\abc123", @"C:\repo\.worktrees\def456" }; + + var untracked = UntrackedWorktreeRecovery.FindUntrackedPaths(registered, Array.Empty()); + + Assert.Equal(registered, untracked); + } + + [Fact] + public async Task StartAsync_NoLists_DoesNotThrow() + { + var hubContext = new CapturingHubContext(); + var sut = new UntrackedWorktreeRecovery( + _db.CreateFactory(), + new ClaudeDo.Data.Git.GitService(), + new HubBroadcaster(hubContext), + NullLogger.Instance); + + await sut.StartAsync(CancellationToken.None); + await sut.StopAsync(CancellationToken.None); + + Assert.Contains(hubContext.Proxy.Calls, c => + c.Method == "OperationProgress" && (string)c.Args[0]! == "startup-recovery"); + } +} From b24ff614ff52422e6f0c99f16fb6a10cb828387d Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 12:16:06 +0200 Subject: [PATCH 4/8] =?UTF-8?q?=EF=BB=BFchore(claude-do):=20TaskRowViewMod?= =?UTF-8?q?el:=20manuelle=20OnPropertyChanged-Kette=20auf=20[Noti?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs enthält ~107 handgeschriebene OnPropertyChanged(nameof(...))-Aufrufe in partial void OnXChanged-Methoden. Das Projekt nutzt an anderen Stellen den Generator: [NotifyPropertyChangedFor] in DetailsIslandViewModel.cs, ConflictResolverViewModel.cs, AgentConfigEditorViewModel.cs, Installer/WizardViewModel.cs. Das ist die eine Datei, in der eine ve ClaudeDo-Task: 87976462-5845-4e01-8233-32afadca02bd --- .../ViewModels/Islands/TaskRowViewModel.cs | 372 +++++++------- .../TaskRowViewModelNotificationsTests.cs | 463 ++++++++++++++++++ 2 files changed, 632 insertions(+), 203 deletions(-) create mode 100644 tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelNotificationsTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 87fa0100..1341439e 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -8,67 +8,206 @@ namespace ClaudeDo.Ui.ViewModels.Islands; public sealed partial class TaskRowViewModel : ViewModelBase { public required string Id { get; init; } - [ObservableProperty] private int _number; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowNumberBadge))] + private int _number; [ObservableProperty] private string _title = ""; [ObservableProperty] private string _listName = ""; - [ObservableProperty] private bool _done; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsOverdue))] + [NotifyPropertyChangedFor(nameof(CanAddToMyDay))] + private bool _done; [ObservableProperty] private bool _isStarred; - [ObservableProperty] private bool _isMyDay; - [ObservableProperty] private bool _isSelected; - [ObservableProperty] private TaskStatus _status; - [ObservableProperty] private PlanningPhase _planningPhase; - [ObservableProperty] private string? _branch; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanAddToMyDay))] + private bool _isMyDay; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowRowActions))] + private bool _isSelected; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusChipClass))] + [NotifyPropertyChangedFor(nameof(StatusLabel))] + [NotifyPropertyChangedFor(nameof(ShowStatusChip))] + [NotifyPropertyChangedFor(nameof(IsRunning))] + [NotifyPropertyChangedFor(nameof(IsWaitingForReview))] + [NotifyPropertyChangedFor(nameof(IsParked))] + [NotifyPropertyChangedFor(nameof(IsQueued))] + [NotifyPropertyChangedFor(nameof(IsWaiting))] + [NotifyPropertyChangedFor(nameof(HasFailureReason))] + [NotifyPropertyChangedFor(nameof(FailureReasonTooltip))] + [NotifyPropertyChangedFor(nameof(StatusChipTooltip))] + [NotifyPropertyChangedFor(nameof(IsDraft))] + [NotifyPropertyChangedFor(nameof(IsPlanned))] + [NotifyPropertyChangedFor(nameof(CanOpenPlanningSession))] + [NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))] + [NotifyPropertyChangedFor(nameof(CanSendToQueue))] + [NotifyPropertyChangedFor(nameof(CanRefine))] + [NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))] + [NotifyPropertyChangedFor(nameof(CancelDisabledReason))] + [NotifyPropertyChangedFor(nameof(RefineDisabledReason))] + [NotifyPropertyChangedFor(nameof(PlanningDisabledReason))] + [NotifyPropertyChangedFor(nameof(MarkAsDisabledReason))] + private TaskStatus _status; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPlanningParent))] + [NotifyPropertyChangedFor(nameof(PlanningBadge))] + [NotifyPropertyChangedFor(nameof(ShowStatusChip))] + [NotifyPropertyChangedFor(nameof(IsPlanActive))] + [NotifyPropertyChangedFor(nameof(IsPlanFinalized))] + [NotifyPropertyChangedFor(nameof(CanOpenPlanningSession))] + [NotifyPropertyChangedFor(nameof(CanResumeOrDiscardPlanning))] + [NotifyPropertyChangedFor(nameof(CanQueuePlan))] + [NotifyPropertyChangedFor(nameof(CanSendToQueue))] + [NotifyPropertyChangedFor(nameof(CanFinalizePlanning))] + [NotifyPropertyChangedFor(nameof(CanRefine))] + [NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))] + [NotifyPropertyChangedFor(nameof(RefineDisabledReason))] + [NotifyPropertyChangedFor(nameof(PlanningDisabledReason))] + private PlanningPhase _planningPhase; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasBranch))] + private string? _branch; [ObservableProperty] private string? _diffStat; - [ObservableProperty] private ClaudeDo.Data.Models.WorktreeState? _worktreeState; - [ObservableProperty] private string? _worktreePath; - [ObservableProperty] private DateTime? _scheduledFor; - [ObservableProperty] private int _diffAdditions; - [ObservableProperty] private int _diffDeletions; - [ObservableProperty] private bool _dropHintAbove; - [ObservableProperty] private bool _dropHintBelow; - [ObservableProperty] private string? _parentTaskId; - [ObservableProperty] private string? _createdBy; - [ObservableProperty] private string? _blockedByTaskId; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsParked))] + [NotifyPropertyChangedFor(nameof(StatusLabel))] + private ClaudeDo.Data.Models.WorktreeState? _worktreeState; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanOpenWorktree))] + [NotifyPropertyChangedFor(nameof(OpenWorktreeDisabledReason))] + private string? _worktreePath; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsOverdue))] + [NotifyPropertyChangedFor(nameof(HasSchedule))] + private DateTime? _scheduledFor; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasDiff))] + [NotifyPropertyChangedFor(nameof(DiffAdditionsText))] + private int _diffAdditions; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasDiff))] + [NotifyPropertyChangedFor(nameof(DiffDeletionsText))] + private int _diffDeletions; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsDropTarget))] + private bool _dropHintAbove; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsDropTarget))] + private bool _dropHintBelow; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsChild))] + [NotifyPropertyChangedFor(nameof(IsAgentSuggested))] + [NotifyPropertyChangedFor(nameof(ShowAsChild))] + [NotifyPropertyChangedFor(nameof(IsDraft))] + [NotifyPropertyChangedFor(nameof(IsPlanned))] + [NotifyPropertyChangedFor(nameof(CanSendToQueue))] + [NotifyPropertyChangedFor(nameof(CanOpenPlanningSession))] + [NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))] + [NotifyPropertyChangedFor(nameof(PlanningDisabledReason))] + private string? _parentTaskId; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsAgentSuggested))] + private string? _createdBy; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsQueued))] + [NotifyPropertyChangedFor(nameof(IsWaiting))] + [NotifyPropertyChangedFor(nameof(StatusChipClass))] + private string? _blockedByTaskId; [ObservableProperty] private string? _dependsOnTaskId; [ObservableProperty] private bool _isExpanded = true; - [ObservableProperty] private bool _hasPlanningChildren; - [ObservableProperty] private bool _hasQueuedSubtasks; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPlanningParent))] + [NotifyPropertyChangedFor(nameof(CanQueuePlan))] + private bool _hasPlanningChildren; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))] + [NotifyPropertyChangedFor(nameof(CanSendToQueue))] + [NotifyPropertyChangedFor(nameof(CanQueuePlan))] + [NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))] + private bool _hasQueuedSubtasks; [ObservableProperty] private bool _showListChip = true; - [ObservableProperty] private bool _parentFinalized; - [ObservableProperty] private bool _parentInView = true; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsDraft))] + [NotifyPropertyChangedFor(nameof(IsPlanned))] + [NotifyPropertyChangedFor(nameof(CanSendToQueue))] + [NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))] + private bool _parentFinalized; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowAsChild))] + [NotifyPropertyChangedFor(nameof(IsDraft))] + [NotifyPropertyChangedFor(nameof(IsPlanned))] + private bool _parentInView = true; // The three below are extrinsic — computed and assigned by TasksIslandViewModel.Regroup // (it alone has the cross-row graph needed to walk DependsOnTaskId chains), mirroring how // ParentInView is assigned by the same pass rather than derived locally. [ObservableProperty] private bool _showAsChainMember; [ObservableProperty] private int? _chainStep; [ObservableProperty] private string? _chainAfterLabel; - [ObservableProperty] private int _roadblockCount; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasRoadblock))] + [NotifyPropertyChangedFor(nameof(RoadblockTooltip))] + private int _roadblockCount; // Only meaningful when Status=Failed; stamped by TaskRunner.MarkFailed. "unknown" for a // Failed task that predates this field. Null on every other status. - [ObservableProperty] private string? _failureReason; - [ObservableProperty] private int? _failureTurnsUsed; - [ObservableProperty] private int? _failureMaxTurns; - [ObservableProperty] private bool _isRefining; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasFailureReason))] + [NotifyPropertyChangedFor(nameof(FailureReasonLabel))] + [NotifyPropertyChangedFor(nameof(FailureReasonTooltip))] + [NotifyPropertyChangedFor(nameof(StatusChipTooltip))] + private string? _failureReason; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FailureReasonTooltip))] + private int? _failureTurnsUsed; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FailureReasonTooltip))] + private int? _failureMaxTurns; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanRefine))] + [NotifyPropertyChangedFor(nameof(RefineDisabledReason))] + private bool _isRefining; // Manual = a reminder only the user can do. Every "hand this to Claude" affordance is hidden // and automation skips it; opening a hand-driven ConPTY session stays allowed. - [ObservableProperty] private bool _isManual; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ManualBadge))] + [NotifyPropertyChangedFor(nameof(CanRefine))] + [NotifyPropertyChangedFor(nameof(CanSendToQueue))] + [NotifyPropertyChangedFor(nameof(CanOpenPlanningSession))] + [NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))] + [NotifyPropertyChangedFor(nameof(RefineDisabledReason))] + [NotifyPropertyChangedFor(nameof(PlanningDisabledReason))] + private bool _isManual; // Set by the shell from Mission Control's open ConPTY panes: this task has a live hand-driven // session, which outranks the persisted status on the lifecycle chip (an interactive task is // typically Idle+Active-worktree, i.e. would otherwise read "Parked"). - [ObservableProperty] private bool _hasInteractiveSession; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsParked))] + [NotifyPropertyChangedFor(nameof(StatusChipClass))] + [NotifyPropertyChangedFor(nameof(StatusLabel))] + [NotifyPropertyChangedFor(nameof(ShowStatusChip))] + [NotifyPropertyChangedFor(nameof(StatusChipTooltip))] + [NotifyPropertyChangedFor(nameof(CanSendToQueue))] + [NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))] + [NotifyPropertyChangedFor(nameof(CanDeleteTask))] + [NotifyPropertyChangedFor(nameof(DeleteDisabledReason))] + private bool _hasInteractiveSession; // Set by the custom drag while this row is being dragged — drives the "grabbed" row style. [ObservableProperty] private bool _isDragging; // Set from PointerEntered/PointerExited in TaskRowView.axaml.cs. Drives ShowRowActions // together with IsSelected, so hover-only affordances (refine button, unset star, dequeue-X) // stay reachable via keyboard/selection, not just mouse hover. - [ObservableProperty] private bool _isHovered; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowRowActions))] + private bool _isHovered; // Transient: set from HubBroadcaster's OperationProgress while the worker is still creating // this task's worktree (the silent gap between Queued and the first agent output). Cleared // by the next entity refresh — UpdateFromEntity always reflects a settled state, so there's // nothing left to show past that point. - [ObservableProperty] private string? _creationPhase; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasCreationPhase))] + [NotifyPropertyChangedFor(nameof(CreationPhaseLabel))] + private string? _creationPhase; // True while a drag is hovering this row (i.e. it would show a drop-hint gap). Used to // suppress the ordinary hover highlight/transitions so they don't fight the hint. @@ -309,179 +448,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase _ => "idle", }; - partial void OnStatusChanged(TaskStatus value) - { - OnPropertyChanged(nameof(StatusChipClass)); - OnPropertyChanged(nameof(StatusLabel)); - OnPropertyChanged(nameof(ShowStatusChip)); - OnPropertyChanged(nameof(IsRunning)); - OnPropertyChanged(nameof(IsWaitingForReview)); - OnPropertyChanged(nameof(IsParked)); - OnPropertyChanged(nameof(IsQueued)); - OnPropertyChanged(nameof(IsWaiting)); - OnPropertyChanged(nameof(HasFailureReason)); - OnPropertyChanged(nameof(FailureReasonTooltip)); - OnPropertyChanged(nameof(StatusChipTooltip)); - OnPropertyChanged(nameof(IsDraft)); - OnPropertyChanged(nameof(IsPlanned)); - OnPropertyChanged(nameof(CanOpenPlanningSession)); - OnPropertyChanged(nameof(CanRemoveFromQueue)); - OnPropertyChanged(nameof(CanSendToQueue)); - OnPropertyChanged(nameof(CanRefine)); - OnPropertyChanged(nameof(SendToQueueDisabledReason)); - OnPropertyChanged(nameof(CancelDisabledReason)); - OnPropertyChanged(nameof(RefineDisabledReason)); - OnPropertyChanged(nameof(PlanningDisabledReason)); - OnPropertyChanged(nameof(MarkAsDisabledReason)); - } - - partial void OnParentTaskIdChanged(string? value) - { - OnPropertyChanged(nameof(IsChild)); - OnPropertyChanged(nameof(IsAgentSuggested)); - OnPropertyChanged(nameof(ShowAsChild)); - OnPropertyChanged(nameof(IsDraft)); - OnPropertyChanged(nameof(IsPlanned)); - OnPropertyChanged(nameof(CanSendToQueue)); - OnPropertyChanged(nameof(CanOpenPlanningSession)); - OnPropertyChanged(nameof(SendToQueueDisabledReason)); - OnPropertyChanged(nameof(PlanningDisabledReason)); - } - - partial void OnParentInViewChanged(bool value) - { - OnPropertyChanged(nameof(ShowAsChild)); - OnPropertyChanged(nameof(IsDraft)); - OnPropertyChanged(nameof(IsPlanned)); - } - - partial void OnCreatedByChanged(string? value) => OnPropertyChanged(nameof(IsAgentSuggested)); - - partial void OnParentFinalizedChanged(bool value) - { - OnPropertyChanged(nameof(IsDraft)); - OnPropertyChanged(nameof(IsPlanned)); - OnPropertyChanged(nameof(CanSendToQueue)); - OnPropertyChanged(nameof(SendToQueueDisabledReason)); - } - - partial void OnPlanningPhaseChanged(PlanningPhase value) - { - OnPropertyChanged(nameof(IsPlanningParent)); - OnPropertyChanged(nameof(PlanningBadge)); - OnPropertyChanged(nameof(ShowStatusChip)); - OnPropertyChanged(nameof(IsPlanActive)); - OnPropertyChanged(nameof(IsPlanFinalized)); - OnPropertyChanged(nameof(CanOpenPlanningSession)); - OnPropertyChanged(nameof(CanResumeOrDiscardPlanning)); - OnPropertyChanged(nameof(CanQueuePlan)); - OnPropertyChanged(nameof(CanSendToQueue)); - OnPropertyChanged(nameof(CanFinalizePlanning)); - OnPropertyChanged(nameof(CanRefine)); - OnPropertyChanged(nameof(SendToQueueDisabledReason)); - OnPropertyChanged(nameof(RefineDisabledReason)); - OnPropertyChanged(nameof(PlanningDisabledReason)); - } - - partial void OnIsRefiningChanged(bool value) - { - OnPropertyChanged(nameof(CanRefine)); - OnPropertyChanged(nameof(RefineDisabledReason)); - } - - partial void OnIsManualChanged(bool value) - { - OnPropertyChanged(nameof(ManualBadge)); - OnPropertyChanged(nameof(CanRefine)); - OnPropertyChanged(nameof(CanSendToQueue)); - OnPropertyChanged(nameof(CanOpenPlanningSession)); - OnPropertyChanged(nameof(SendToQueueDisabledReason)); - OnPropertyChanged(nameof(RefineDisabledReason)); - OnPropertyChanged(nameof(PlanningDisabledReason)); - } - - partial void OnHasInteractiveSessionChanged(bool value) - { - OnPropertyChanged(nameof(IsParked)); - OnPropertyChanged(nameof(StatusChipClass)); - OnPropertyChanged(nameof(StatusLabel)); - OnPropertyChanged(nameof(ShowStatusChip)); - OnPropertyChanged(nameof(StatusChipTooltip)); - OnPropertyChanged(nameof(CanSendToQueue)); - OnPropertyChanged(nameof(SendToQueueDisabledReason)); - OnPropertyChanged(nameof(CanDeleteTask)); - OnPropertyChanged(nameof(DeleteDisabledReason)); - } - - partial void OnFailureReasonChanged(string? value) - { - OnPropertyChanged(nameof(HasFailureReason)); - OnPropertyChanged(nameof(FailureReasonLabel)); - OnPropertyChanged(nameof(FailureReasonTooltip)); - OnPropertyChanged(nameof(StatusChipTooltip)); - } - - partial void OnFailureTurnsUsedChanged(int? value) => OnPropertyChanged(nameof(FailureReasonTooltip)); - partial void OnFailureMaxTurnsChanged(int? value) => OnPropertyChanged(nameof(FailureReasonTooltip)); - - partial void OnHasQueuedSubtasksChanged(bool value) - { - OnPropertyChanged(nameof(CanRemoveFromQueue)); - OnPropertyChanged(nameof(CanSendToQueue)); - OnPropertyChanged(nameof(CanQueuePlan)); - OnPropertyChanged(nameof(SendToQueueDisabledReason)); - } - - partial void OnIsHoveredChanged(bool value) => OnPropertyChanged(nameof(ShowRowActions)); - partial void OnIsSelectedChanged(bool value) => OnPropertyChanged(nameof(ShowRowActions)); - - partial void OnBlockedByTaskIdChanged(string? value) - { - OnPropertyChanged(nameof(IsQueued)); - OnPropertyChanged(nameof(IsWaiting)); - OnPropertyChanged(nameof(StatusChipClass)); - } - - partial void OnHasPlanningChildrenChanged(bool value) - { - OnPropertyChanged(nameof(IsPlanningParent)); - OnPropertyChanged(nameof(CanQueuePlan)); - } - - partial void OnNumberChanged(int value) => OnPropertyChanged(nameof(ShowNumberBadge)); - partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch)); - partial void OnWorktreePathChanged(string? value) - { - OnPropertyChanged(nameof(CanOpenWorktree)); - OnPropertyChanged(nameof(OpenWorktreeDisabledReason)); - } - partial void OnWorktreeStateChanged(ClaudeDo.Data.Models.WorktreeState? value) - { - OnPropertyChanged(nameof(IsParked)); - OnPropertyChanged(nameof(StatusLabel)); - } - partial void OnDoneChanged(bool value) - { - OnPropertyChanged(nameof(IsOverdue)); - OnPropertyChanged(nameof(CanAddToMyDay)); - } - partial void OnIsMyDayChanged(bool value) => OnPropertyChanged(nameof(CanAddToMyDay)); - partial void OnScheduledForChanged(DateTime? value) - { - OnPropertyChanged(nameof(IsOverdue)); - OnPropertyChanged(nameof(HasSchedule)); - } - partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); } - partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); } - partial void OnRoadblockCountChanged(int value) { OnPropertyChanged(nameof(HasRoadblock)); OnPropertyChanged(nameof(RoadblockTooltip)); } - partial void OnDropHintAboveChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget)); - partial void OnDropHintBelowChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget)); - partial void OnCreationPhaseChanged(string? value) - { - OnPropertyChanged(nameof(HasCreationPhase)); - OnPropertyChanged(nameof(CreationPhaseLabel)); - } - public void RefreshLocalized() { OnPropertyChanged(nameof(StatusLabel)); diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelNotificationsTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelNotificationsTests.cs new file mode 100644 index 00000000..d8fe1112 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelNotificationsTests.cs @@ -0,0 +1,463 @@ +using System.Collections.Generic; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Islands; +using Xunit; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.UiVm; + +// Characterization test for TaskRowViewModel's PropertyChanged fan-out. For every source +// property that today drives dependent notifications (via a handwritten partial OnXChanged), +// this pins down the exact SET of names raised when that property changes. Order is +// deliberately not asserted: a NotifyPropertyChangedFor-based rewrite raises the source +// property's own notification before its dependents instead of after, which is an unobservable +// reordering, not a behavior change. This test must stay green across that rewrite. +public class TaskRowViewModelNotificationsTests +{ + private static HashSet Raised(TaskRowViewModel vm, System.Action mutate) + { + var raised = new HashSet(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); + mutate(); + return raised; + } + + [Fact] + public void StatusChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.Status = TaskStatus.Running); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.Status), + nameof(TaskRowViewModel.StatusChipClass), + nameof(TaskRowViewModel.StatusLabel), + nameof(TaskRowViewModel.ShowStatusChip), + nameof(TaskRowViewModel.IsRunning), + nameof(TaskRowViewModel.IsWaitingForReview), + nameof(TaskRowViewModel.IsParked), + nameof(TaskRowViewModel.IsQueued), + nameof(TaskRowViewModel.IsWaiting), + nameof(TaskRowViewModel.HasFailureReason), + nameof(TaskRowViewModel.FailureReasonTooltip), + nameof(TaskRowViewModel.StatusChipTooltip), + nameof(TaskRowViewModel.IsDraft), + nameof(TaskRowViewModel.IsPlanned), + nameof(TaskRowViewModel.CanOpenPlanningSession), + nameof(TaskRowViewModel.CanRemoveFromQueue), + nameof(TaskRowViewModel.CanSendToQueue), + nameof(TaskRowViewModel.CanRefine), + nameof(TaskRowViewModel.SendToQueueDisabledReason), + nameof(TaskRowViewModel.CancelDisabledReason), + nameof(TaskRowViewModel.RefineDisabledReason), + nameof(TaskRowViewModel.PlanningDisabledReason), + nameof(TaskRowViewModel.MarkAsDisabledReason), + }, raised); + } + + [Fact] + public void ParentTaskIdChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.ParentTaskId = "parent"); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.ParentTaskId), + nameof(TaskRowViewModel.IsChild), + nameof(TaskRowViewModel.IsAgentSuggested), + nameof(TaskRowViewModel.ShowAsChild), + nameof(TaskRowViewModel.IsDraft), + nameof(TaskRowViewModel.IsPlanned), + nameof(TaskRowViewModel.CanSendToQueue), + nameof(TaskRowViewModel.CanOpenPlanningSession), + nameof(TaskRowViewModel.SendToQueueDisabledReason), + nameof(TaskRowViewModel.PlanningDisabledReason), + }, raised); + } + + [Fact] + public void ParentInViewChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.ParentInView = false); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.ParentInView), + nameof(TaskRowViewModel.ShowAsChild), + nameof(TaskRowViewModel.IsDraft), + nameof(TaskRowViewModel.IsPlanned), + }, raised); + } + + [Fact] + public void CreatedByChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.CreatedBy = "someone"); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.CreatedBy), + nameof(TaskRowViewModel.IsAgentSuggested), + }, raised); + } + + [Fact] + public void ParentFinalizedChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.ParentFinalized = true); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.ParentFinalized), + nameof(TaskRowViewModel.IsDraft), + nameof(TaskRowViewModel.IsPlanned), + nameof(TaskRowViewModel.CanSendToQueue), + nameof(TaskRowViewModel.SendToQueueDisabledReason), + }, raised); + } + + [Fact] + public void PlanningPhaseChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.PlanningPhase = PlanningPhase.Active); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.PlanningPhase), + nameof(TaskRowViewModel.IsPlanningParent), + nameof(TaskRowViewModel.PlanningBadge), + nameof(TaskRowViewModel.ShowStatusChip), + nameof(TaskRowViewModel.IsPlanActive), + nameof(TaskRowViewModel.IsPlanFinalized), + nameof(TaskRowViewModel.CanOpenPlanningSession), + nameof(TaskRowViewModel.CanResumeOrDiscardPlanning), + nameof(TaskRowViewModel.CanQueuePlan), + nameof(TaskRowViewModel.CanSendToQueue), + nameof(TaskRowViewModel.CanFinalizePlanning), + nameof(TaskRowViewModel.CanRefine), + nameof(TaskRowViewModel.SendToQueueDisabledReason), + nameof(TaskRowViewModel.RefineDisabledReason), + nameof(TaskRowViewModel.PlanningDisabledReason), + }, raised); + } + + [Fact] + public void IsRefiningChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.IsRefining = true); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.IsRefining), + nameof(TaskRowViewModel.CanRefine), + nameof(TaskRowViewModel.RefineDisabledReason), + }, raised); + } + + [Fact] + public void IsManualChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.IsManual = true); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.IsManual), + nameof(TaskRowViewModel.ManualBadge), + nameof(TaskRowViewModel.CanRefine), + nameof(TaskRowViewModel.CanSendToQueue), + nameof(TaskRowViewModel.CanOpenPlanningSession), + nameof(TaskRowViewModel.SendToQueueDisabledReason), + nameof(TaskRowViewModel.RefineDisabledReason), + nameof(TaskRowViewModel.PlanningDisabledReason), + }, raised); + } + + [Fact] + public void HasInteractiveSessionChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.HasInteractiveSession = true); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.HasInteractiveSession), + nameof(TaskRowViewModel.IsParked), + nameof(TaskRowViewModel.StatusChipClass), + nameof(TaskRowViewModel.StatusLabel), + nameof(TaskRowViewModel.ShowStatusChip), + nameof(TaskRowViewModel.StatusChipTooltip), + nameof(TaskRowViewModel.CanSendToQueue), + nameof(TaskRowViewModel.SendToQueueDisabledReason), + nameof(TaskRowViewModel.CanDeleteTask), + nameof(TaskRowViewModel.DeleteDisabledReason), + }, raised); + } + + [Fact] + public void FailureReasonChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.FailureReason = "error"); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.FailureReason), + nameof(TaskRowViewModel.HasFailureReason), + nameof(TaskRowViewModel.FailureReasonLabel), + nameof(TaskRowViewModel.FailureReasonTooltip), + nameof(TaskRowViewModel.StatusChipTooltip), + }, raised); + } + + [Fact] + public void FailureTurnsUsedChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.FailureTurnsUsed = 5); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.FailureTurnsUsed), + nameof(TaskRowViewModel.FailureReasonTooltip), + }, raised); + } + + [Fact] + public void FailureMaxTurnsChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.FailureMaxTurns = 10); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.FailureMaxTurns), + nameof(TaskRowViewModel.FailureReasonTooltip), + }, raised); + } + + [Fact] + public void HasQueuedSubtasksChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.HasQueuedSubtasks = true); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.HasQueuedSubtasks), + nameof(TaskRowViewModel.CanRemoveFromQueue), + nameof(TaskRowViewModel.CanSendToQueue), + nameof(TaskRowViewModel.CanQueuePlan), + nameof(TaskRowViewModel.SendToQueueDisabledReason), + }, raised); + } + + [Fact] + public void IsHoveredChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.IsHovered = true); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.IsHovered), + nameof(TaskRowViewModel.ShowRowActions), + }, raised); + } + + [Fact] + public void IsSelectedChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.IsSelected = true); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.IsSelected), + nameof(TaskRowViewModel.ShowRowActions), + }, raised); + } + + [Fact] + public void BlockedByTaskIdChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.BlockedByTaskId = "blocker"); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.BlockedByTaskId), + nameof(TaskRowViewModel.IsQueued), + nameof(TaskRowViewModel.IsWaiting), + nameof(TaskRowViewModel.StatusChipClass), + }, raised); + } + + [Fact] + public void HasPlanningChildrenChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.HasPlanningChildren = true); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.HasPlanningChildren), + nameof(TaskRowViewModel.IsPlanningParent), + nameof(TaskRowViewModel.CanQueuePlan), + }, raised); + } + + [Fact] + public void NumberChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.Number = 5); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.Number), + nameof(TaskRowViewModel.ShowNumberBadge), + }, raised); + } + + [Fact] + public void BranchChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.Branch = "main"); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.Branch), + nameof(TaskRowViewModel.HasBranch), + }, raised); + } + + [Fact] + public void WorktreePathChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.WorktreePath = @"C:\repo\worktrees\t"); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.WorktreePath), + nameof(TaskRowViewModel.CanOpenWorktree), + nameof(TaskRowViewModel.OpenWorktreeDisabledReason), + }, raised); + } + + [Fact] + public void WorktreeStateChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.WorktreeState = ClaudeDo.Data.Models.WorktreeState.Active); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.WorktreeState), + nameof(TaskRowViewModel.IsParked), + nameof(TaskRowViewModel.StatusLabel), + }, raised); + } + + [Fact] + public void DoneChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.Done = true); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.Done), + nameof(TaskRowViewModel.IsOverdue), + nameof(TaskRowViewModel.CanAddToMyDay), + }, raised); + } + + [Fact] + public void IsMyDayChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.IsMyDay = true); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.IsMyDay), + nameof(TaskRowViewModel.CanAddToMyDay), + }, raised); + } + + [Fact] + public void ScheduledForChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.ScheduledFor = DateTime.UtcNow); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.ScheduledFor), + nameof(TaskRowViewModel.IsOverdue), + nameof(TaskRowViewModel.HasSchedule), + }, raised); + } + + [Fact] + public void DiffAdditionsChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.DiffAdditions = 5); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.DiffAdditions), + nameof(TaskRowViewModel.HasDiff), + nameof(TaskRowViewModel.DiffAdditionsText), + }, raised); + } + + [Fact] + public void DiffDeletionsChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.DiffDeletions = 5); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.DiffDeletions), + nameof(TaskRowViewModel.HasDiff), + nameof(TaskRowViewModel.DiffDeletionsText), + }, raised); + } + + [Fact] + public void RoadblockCountChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.RoadblockCount = 3); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.RoadblockCount), + nameof(TaskRowViewModel.HasRoadblock), + nameof(TaskRowViewModel.RoadblockTooltip), + }, raised); + } + + [Fact] + public void DropHintAboveChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.DropHintAbove = true); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.DropHintAbove), + nameof(TaskRowViewModel.IsDropTarget), + }, raised); + } + + [Fact] + public void DropHintBelowChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.DropHintBelow = true); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.DropHintBelow), + nameof(TaskRowViewModel.IsDropTarget), + }, raised); + } + + [Fact] + public void CreationPhaseChanged_Notifies_Dependents() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = Raised(vm, () => vm.CreationPhase = "creating_worktree"); + Assert.Equal(new HashSet + { + nameof(TaskRowViewModel.CreationPhase), + nameof(TaskRowViewModel.HasCreationPhase), + nameof(TaskRowViewModel.CreationPhaseLabel), + }, raised); + } +} From 9e805646652b6de4211ebac2e350fa13e14de293 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 12:16:44 +0200 Subject: [PATCH 5/8] feat(data): add dependency audit and diff-review threshold to list-handler prompts Triage phase now audits dependsOnTaskId chains before handoff: distinguishes real from artificial (plan-numbered) dependencies, resolves artificial ones via update_task, groups survivors into a wave plan, and checks the wave count against the 4-handoff cap (2 merge rounds) so a chain too long to finish says so explicitly instead of stranding mid-run. Merge phase now reads a task's own diff when preview_merge_set's changedFileCount is below 5, reserving the sonnet review subagent for larger diffs where the context-protection cost is actually worth it. Updates DefaultFor_merge_helper_merge_never_reads_diffs_itself, renamed to DefaultFor_merge_helper_merge_gates_the_diff_subagent_on_a_file_count_threshold, to match the new threshold behavior. --- src/ClaudeDo.Data/PromptFiles.cs | 13 +++++++++++-- tests/ClaudeDo.Data.Tests/PromptFilesTests.cs | 7 +++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/ClaudeDo.Data/PromptFiles.cs b/src/ClaudeDo.Data/PromptFiles.cs index 9cf17a5c..2386be43 100644 --- a/src/ClaudeDo.Data/PromptFiles.cs +++ b/src/ClaudeDo.Data/PromptFiles.cs @@ -448,6 +448,15 @@ public static class PromptFiles Cancel nothing without an explicit answer. + ## Phase 1.5 — Audit dependencies + For every surviving task that carries a dependsOnTaskId, decide whether the link is real or artificial before you hand off. Real: task B genuinely needs something task A produces — a symbol/type B's code references, a changed signature, a migration that must land before repository code assumes it. Artificial: the tasks just got numbered in sequence by the plan but touch disjoint files for unrelated reasons. Task descriptions usually name their target files explicitly — use that to check for real file/symbol overlap instead of trusting the order. + + Resolve every artificial link with update_task(taskId, dependsOnTaskId="") — a chain numbered 1→2→3→... is the default shape to suspect, not the default shape to trust. + + Group what's left into waves: batches that can run in parallel, where a later wave depends only on tasks in an earlier one. Report this as a wave plan (e.g. "[3] → [4,5,6] → [7]"), not a flat list — the Wait/Merge sessions after you need the batching, not just which ids survived. + + Then check the wave count against the handoff cap: this list-handler chain allows at most 4 handoffs total (wait, merge, wait_final, merge_final), which is exactly two merge rounds. If your wave plan needs more than two rounds to clear, say so explicitly when you hand off — name which waves won't fit — so the Merge session ends by telling the user "the rest needs manual approval in the UI" instead of silently stranding mid-chain. + ## Phase 2 — Enhance for execution Before touching any task, check the list's verify command ONCE for this whole run (not per task): call get_list_config. If verifyCommand is already set, leave it alone — never overwrite an existing one — and move straight to sharpening tasks below. @@ -471,7 +480,7 @@ public static class PromptFiles Once every surviving task is enhanced, print your triage summary — one line per task from the brief: title — dedupe action (kept / merged into X / cancelled as duplicate of X) — enhanced (yes/no). - Then call handoff_list_handler(taskId, survivingTaskIds, nextPhase: "wait"). That opens a fresh session to carry out the wait/merge phases with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop — do not continue into phase 3 yourself. + Then print the wave plan from Phase 1.5, including any "won't fit in two rounds" note, and call handoff_list_handler(taskId, survivingTaskIds, nextPhase: "wait"). That opens a fresh session to carry out the wait/merge phases with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop — do not continue into phase 3 yourself. """; private const string MergeHelperWaitDefault = """ @@ -505,7 +514,7 @@ public static class PromptFiles One task at a time. Default to the order the brief lists them. If the overlap check shows two or more tasks touching the same file, tell the user which tasks collide and merge those in an order you can justify (e.g. the one making the smaller change first) — deviate from brief order only with that stated reason. - 1. Never read a task's diff yourself. Instead start a sonnet subagent (Task tool) per task to inspect get_task_diff and sanity-check it against the task's title and description; only its short verdict (clean / risky + reason) comes back into your own context. If preview_merge_set flagged this task in an overlap, tell the subagent to also skim the diff of the other task(s) sharing that file. + 1. Check this task's changedFileCount from preview_merge_set first. Below 5 changed files, read get_task_diff yourself and sanity-check it against the task's title and description — a subagent hop is wasted context for a diff that small. At 5 or more, delegate instead: start a sonnet subagent (Task tool) to inspect get_task_diff and sanity-check it, so only its short verdict (clean / risky + reason) comes back into your own context — the whole point of never reading a large diff yourself. If preview_merge_set flagged this task in an overlap, also skim (or have the subagent skim) the diff of the other task(s) sharing that file. 2. Before approving, cross-check changedFileCount from preview_merge_set (or the length of get_task_diff's files list — the diff tool has no changedFileCount field). 0 changed files means an empty branch, not a clean success — do not approve it; treat it like a risky diff instead. 3. If the subagent's verdict is risky, or the change looks wrong or incomplete, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip. 4. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true, skipVerify=true). skipVerify defers the list's verify command to ONE verify_merges call after the last merge (below) instead of paying it per merge. diff --git a/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs b/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs index 11af00f3..270a47f1 100644 --- a/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs +++ b/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs @@ -221,11 +221,14 @@ public class PromptFilesTests } [Fact] - public void DefaultFor_merge_helper_merge_never_reads_diffs_itself() + public void DefaultFor_merge_helper_merge_gates_the_diff_subagent_on_a_file_count_threshold() { + // Below 5 changed files a subagent hop is pure context-protection overhead for a tiny + // diff; the threshold keeps the subagent mandatory once a diff is actually large. var d = PromptFiles.DefaultFor(PromptKind.MergeHelperMerge); - Assert.Contains("Never read a task's diff yourself", d); + Assert.Contains("Below 5 changed files", d); Assert.Contains("sonnet subagent", d); + Assert.Contains("changedFileCount", d); } [Fact] From 9f90e75a857235f506396ccc32f2a81480d0f50f Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 12:17:00 +0200 Subject: [PATCH 6/8] refactor(tests): share StubWorkerClient between Ui.Tests and Worker.Tests Worker.Tests carried its own hand-rolled FakeWorkerClient implementing the whole IWorkerClient surface, duplicating StubWorkerClient in Ui.Tests. Every hub-signature change had to be applied twice. Link StubWorkerClient.cs into Worker.Tests via a csproj Compile Include and subclass it there; add SetTaskStatusCalls tracking to the shared stub (the one behavior Worker.Tests needed that Ui.Tests didn't have). --- tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 7 +- .../ClaudeDo.Worker.Tests.csproj | 4 + .../UiVm/TasksIslandViewModelPlanningTests.cs | 184 +----------------- 3 files changed, 12 insertions(+), 183 deletions(-) diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index d9a05bec..5e7a2e35 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -98,7 +98,12 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task> InstallSessionSkillAsync(string url) => Task.FromResult(new List()); public virtual Task UpdateSessionSkillAsync(string sourceUrl) => Task.CompletedTask; public virtual Task RemoveSessionSkillAsync(string sourceUrl) => Task.CompletedTask; - public virtual Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.FromResult(null); + public List<(string TaskId, TaskStatus Status)> SetTaskStatusCalls { get; } = new(); + public virtual Task SetTaskStatusAsync(string taskId, TaskStatus status) + { + SetTaskStatusCalls.Add((taskId, status)); + return Task.FromResult(null); + } public virtual Task SetTaskDoneAsync(string taskId) => Task.CompletedTask; public virtual Task UnsetTaskDoneAsync(string taskId) => Task.CompletedTask; public virtual Task DequeueTaskAsync(string taskId) => Task.CompletedTask; diff --git a/tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj b/tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj index c7d07e4d..87d78bf4 100644 --- a/tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj +++ b/tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj @@ -22,6 +22,10 @@ + + + + diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 2956b686..fc4e597d 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -2,6 +2,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.Tests; using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Worker.Tests.Infrastructure; using CommunityToolkit.Mvvm.Input; @@ -13,188 +14,7 @@ namespace ClaudeDo.Worker.Tests.UiVm; // ── Fake worker client ──────────────────────────────────────────────────────── -sealed class FakeWorkerClient : IWorkerClient -{ - public int StartPlanningCalls { get; private set; } - public int ResumePlanningCalls { get; private set; } - public int DiscardPlanningCalls { get; private set; } - public int FinalizePlanningCalls { get; private set; } - public int WakeQueueCalls { get; private set; } - public List<(string TaskId, TaskStatus Status)> SetTaskStatusCalls { get; } = new(); - - public bool IsConnected => false; - public bool IsReconnecting => false; - public string? LastApproveTarget => null; -#pragma warning disable CS0067 // events required by IWorkerClient but not exercised by this fake - public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged; - public event Action? TaskStartedEvent; - public event Action? TaskFinishedEvent; - public event Action? TaskUpdatedEvent; - public event Action? ConnectionRestoredEvent; - public event Action? WorktreeUpdatedEvent; - public event Action? ListUpdatedEvent; - public event Action? TaskMessageEvent; - public event Action? WorkerLogReceivedEvent; - public event Action? TaskQuestionAskedEvent; - public event Action? TaskQuestionResolvedEvent; - public event Action, string>? HandoffRequestedEvent; - public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId); - public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId); - public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line); - public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total); - - public Task RunNowAsync(string taskId) => Task.CompletedTask; - public Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask; - public Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer) => Task.CompletedTask; - public Task GetPendingQuestionAsync(string taskId) => Task.FromResult(null); - public Task ResetTaskAsync(string taskId) => Task.CompletedTask; - public Task CancelTaskAsync(string taskId) => Task.CompletedTask; - public Task<(bool Ok, string? Error)> DeleteTaskAsync(string taskId) => Task.FromResult<(bool, string?)>((true, null)); - public Task> GetAgentsAsync() => Task.FromResult(new List()); - public Task RefreshAgentsAsync() => Task.CompletedTask; - public Task RestoreDefaultAgentsAsync() => Task.FromResult(null); - public Task GetListConfigAsync(string listId) => Task.FromResult(null); - public Task> GetRepoImportFoldersAsync() => Task.FromResult(new List()); - public Task SetRepoImportFoldersAsync(List folders) => Task.CompletedTask; - public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; - public Task> GetSessionSkillsAsync() => Task.FromResult(new List()); - public Task> InstallSessionSkillAsync(string url) => Task.FromResult(new List()); - public Task UpdateSessionSkillAsync(string sourceUrl) => Task.CompletedTask; - public Task RemoveSessionSkillAsync(string sourceUrl) => Task.CompletedTask; - public Task SetTaskStatusAsync(string taskId, TaskStatus status) - { - SetTaskStatusCalls.Add((taskId, status)); - return Task.FromResult(null); - } - public Task SetTaskDoneAsync(string taskId) => Task.CompletedTask; - public Task UnsetTaskDoneAsync(string taskId) => Task.CompletedTask; - public Task DequeueTaskAsync(string taskId) => Task.CompletedTask; - public Task ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult(null); - public Task PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult(null); - public Task MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty(), null)); - public Task StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty(), null)); - public Task GetMergeConflictDocumentsAsync(string taskId) => Task.FromResult(new MergeConflictDocumentsDto(taskId, System.Array.Empty())); - public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask; - public Task ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty(), null)); - public Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask; - public Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask; - public Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask; - public Task CancelReviewAsync(string taskId) => Task.CompletedTask; - public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; } - public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; } - public Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; - public Task GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) - => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); - public Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, CancellationToken ct = default) - => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); - public Task CreateMergeHelperTaskAsync( - IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) - => Task.FromResult(Guid.NewGuid().ToString()); - public Task GetMergeHelperHandoffLaunchSpecAsync( - string taskId, IReadOnlyList survivingTaskIds, string nextPhase, CancellationToken ct = default) - => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); - public Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) - => Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty(), new Dictionary())); - public int PlanningStartSpecCalls { get; private set; } - public int PlanningResumeSpecCalls { get; private set; } - public Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) - { PlanningStartSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); } - public Task GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default) - { PlanningResumeSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); } - public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask; - public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; } - public Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default) - { - DiscardPlanningCalls++; - return Task.FromResult(new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, 0, 0)); - } - public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { FinalizePlanningCalls++; return Task.CompletedTask; } - public Task GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0); - public Task> GetRecentLogsAsync() => Task.FromResult>(System.Array.Empty()); - - public event Action? NotesUpdatedEvent; - public event Action? PrepStartedEvent; - public event Action? PrepLineEvent; - public event Action? PrepFinishedEvent; - public event Action? RefineStartedEvent; - public event Action? RefineFinishedEvent; - public event Action? OperationProgressEvent; - public event Action? MergeProgressEvent; - public event Action? PlanningMergeStartedEvent; - public event Action? PlanningSubtaskMergedEvent; - public event Action, bool>? PlanningMergeConflictEvent; - public event Action? PlanningMergeAbortedEvent; - public event Action? PlanningCompletedEvent; - public event Action? PrimeFired; - public event Action? UsageUpdatedEvent; -#pragma warning restore CS0067 - - public Task GetMergeTargetsAsync(string taskId) => Task.FromResult(null); - public Task> GetPlanningAggregateAsync(string planningTaskId) => Task.FromResult>(Array.Empty()); - public Task BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) => Task.FromResult(null); - public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask; - public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask; - public Task> GetActiveExternalPlanningMergeConflictsAsync() - => Task.FromResult>(Array.Empty()); - - public Task GetAppSettingsAsync() => Task.FromResult(null); - public Task UpdateAppSettingsAsync(AppSettingsDto dto) => Task.CompletedTask; - public Task> GetPrimeSchedulesAsync() => Task.FromResult(new List()); - public Task UpsertPrimeScheduleAsync(PrimeScheduleDto dto) => Task.FromResult(null); - public Task DeletePrimeScheduleAsync(Guid id) => Task.CompletedTask; - public Task UpdateListAsync(UpdateListDto dto) => Task.CompletedTask; - public Task UpdateListConfigAsync(UpdateListConfigDto dto) => Task.CompletedTask; - public Task CleanupFinishedWorktreesAsync(string? listId = null) => Task.FromResult(null); - public Task ResetAllWorktreesAsync() => Task.FromResult(null); - public Task> GetWorktreesOverviewAsync(string? listId) => Task.FromResult(new List()); - public Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState) => Task.FromResult((true, (string?)null)); - public Task ForceRemoveWorktreeAsync(string taskId) => Task.FromResult(null); - public Task GetWeekReportAsync(DateOnly start, DateOnly end) => Task.FromResult(null); - public Task GenerateWeekReportAsync(DateOnly start, DateOnly end) => Task.FromResult(""); - public Task RunDailyPrepNowAsync() => Task.FromResult(false); - public Task ClearMyDayAsync() => Task.CompletedTask; - public Task> GetDailyNotesAsync(DateOnly day) => Task.FromResult(new List()); - public Task> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end) => Task.FromResult(new List()); - public Task AddDailyNoteAsync(DateOnly day, string text) => Task.FromResult(null); - public Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask; - public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask; - public Task GetLastPrepLogAsync() => Task.FromResult(string.Empty); - public Task RefineTaskAsync(string taskId) => Task.CompletedTask; - public Task GetClaudeBinAsync() => Task.FromResult(null); - public Task SetClaudeBinAsync(string? value) => Task.FromResult(null); - public Task GetOnlineInboxStateAsync() => Task.FromResult(null); - public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask; - public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask; - public Task ClearOnlineInboxAuthAsync() => Task.CompletedTask; - public IReadOnlyList GetActiveTasks() => System.Array.Empty(); - - public Task GetUsageSnapshotAsync() => Task.FromResult(null); - public Task RefreshUsageAsync() => Task.FromResult(null); - public Task> GetModelUsageAsync(DateOnly from, DateOnly to) - => Task.FromResult>(System.Array.Empty()); - public Task> GetTaskUsageAsync(DateOnly from, DateOnly to) - => Task.FromResult>(System.Array.Empty()); - - public Task GetTokenTrackerStatusAsync() => - Task.FromResult(new TokenTrackerStatusDto( - Installed: false, Version: null, NodeOk: true, NodeVersion: "v22.0.0", - LastFetchedUtc: null, LastError: null, FormatVersion: null, SessionCount: 0)); - - public Task RefreshTokenTrackerAsync() => GetTokenTrackerStatusAsync(); - - public Task InstallTokenTrackerAsync() => GetTokenTrackerStatusAsync(); - - public Task OpenTokenTrackerDashboardAsync() => - Task.FromResult(new TokenTrackerDashboardDto(false, null, "not configured")); - - public Task GetTicketSettingsAsync() => Task.FromResult(null); - public Task SetTicketApiBaseUrlAsync(string? baseUrl) => Task.CompletedTask; - public Task SetTicketTokenAsync(string token) => Task.CompletedTask; - public Task ClearTicketTokenAsync() => Task.CompletedTask; - public Task TestTicketConnectionAsync() => Task.FromResult(null); - public Task> GetTicketProjectsAsync() => Task.FromResult(new List()); - public Task ImportTicketsAsync(string listId) => Task.FromResult(new TicketImportResultDto(0, 0)); -} +sealed class FakeWorkerClient : StubWorkerClient; // ── Helper to build VM with pre-seeded Items ────────────────────────────────── From 912201770f8903a15d9aa189029ce2fc3ded114e Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 12:20:02 +0200 Subject: [PATCH 7/8] fix(worker): add failureReason field to batch_get_tasks The tool description claimed Failed tasks carry failureReason, but the field was missing from BatchTaskDetailDto and rejected when requested in `fields`. Add failureReason to the DTO, KnownDetailFields, and BuildDetail so list handlers and other callers can read failure reasons without per-task get_task calls. Tested: 31 BatchMcpToolsTests pass, including two new cases for failureReason retrieval on Failed and Idle tasks. Co-Authored-By: Claude Haiku 4.5 --- src/ClaudeDo.Worker/External/BatchMcpTools.cs | 3 ++ .../External/BatchMcpToolsTests.cs | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/ClaudeDo.Worker/External/BatchMcpTools.cs b/src/ClaudeDo.Worker/External/BatchMcpTools.cs index 4d864718..aff487f8 100644 --- a/src/ClaudeDo.Worker/External/BatchMcpTools.cs +++ b/src/ClaudeDo.Worker/External/BatchMcpTools.cs @@ -45,6 +45,7 @@ public sealed record BatchTaskDetailDto( int? SortOrder = null, int? RoadblockCount = null, string? RoadblockText = null, + string? FailureReason = null, bool DescriptionTruncated = false, int? DescriptionFullLength = null, bool ResultTruncated = false, @@ -80,6 +81,7 @@ public sealed class BatchMcpTools { "listId", "title", "description", "status", "result", "createdBy", "createdAt", "startedAt", "finishedAt", "isMyDay", "sortOrder", "roadblockCount", "roadblockText", + "failureReason", }; private readonly ExternalMcpService _svc; @@ -187,6 +189,7 @@ public sealed class BatchMcpTools SortOrder: Want("sortOrder") ? t.SortOrder : null, RoadblockCount: Want("roadblockCount") ? t.RoadblockCount : null, RoadblockText: Want("roadblockText") ? ExtractRoadblockText(t.Result) : null, + FailureReason: Want("failureReason") ? t.FailureReason : null, DescriptionTruncated: descTruncated, DescriptionFullLength: descFullLength, ResultTruncated: resultTruncated, diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index 366d546f..db6e02f9 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -623,4 +623,38 @@ public sealed class BatchMcpToolsTests : IDisposable Assert.Equal(2, progress.Reports.Count); Assert.Equal(2, progress.Reports[1].Total); } + + [Fact] + public async Task BatchGetTasks_FailedTaskWithFailureReason_ReturnsReasonWhenRequested() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, "failed", TaskStatus.Failed); + task.FailureReason = "max-turns-exceeded"; + await _tasks.UpdateAsync(task); + var sut = BuildSut(); + + var results = await sut.BatchGetTasks( + new[] { task.Id }, includeDescription: true, fields: new[] { "failureReason" }, + cancellationToken: CancellationToken.None); + + var found = results.Single(r => r.Id == task.Id).TaskFull!; + Assert.Equal("max-turns-exceeded", found.FailureReason); + Assert.Null(found.Description); + Assert.Null(found.Status); + } + + [Fact] + public async Task BatchGetTasks_IdleTaskWithoutFailureReason_ReturnsNullWhenRequested() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, "idle", TaskStatus.Idle); + var sut = BuildSut(); + + var results = await sut.BatchGetTasks( + new[] { task.Id }, includeDescription: true, fields: new[] { "failureReason" }, + cancellationToken: CancellationToken.None); + + var found = results.Single(r => r.Id == task.Id).TaskFull!; + Assert.Null(found.FailureReason); + } } From dc1e67d07b1d771d5658bee2a041a770a820f0bc Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 12:55:51 +0200 Subject: [PATCH 8/8] fix(tests): stop Ui.Tests order-dependent flakiness on shared Loc.Current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loc.Current (src/ClaudeDo.Ui/Localization/Loc.cs) is a process-wide mutable static; which localizer is active depended on xUnit's parallel test scheduling. Ui.Tests: disable collection parallelization (cheap, pure VM suite) and install the real localizer in a module initializer so no test can ever observe the uninitialized key-echo default. Worker.Tests: install the same module initializer but leave parallelization on — disabling it measured a 27% wall-clock regression on its real-SQLite/git suite, and the initializer alone already removes the race. AgentConfigEditorViewModelTests now installs its own localizer instead of relying on ambient state. --- tests/ClaudeDo.Ui.Tests/TestSetup.cs | 20 +++++++++++++++++++ .../AgentConfigEditorViewModelTests.cs | 13 ++++++++++++ tests/ClaudeDo.Worker.Tests/TestSetup.cs | 18 +++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/tests/ClaudeDo.Ui.Tests/TestSetup.cs b/tests/ClaudeDo.Ui.Tests/TestSetup.cs index 79ef3e65..90277d81 100644 --- a/tests/ClaudeDo.Ui.Tests/TestSetup.cs +++ b/tests/ClaudeDo.Ui.Tests/TestSetup.cs @@ -1,5 +1,12 @@ using System.Runtime.CompilerServices; +using ClaudeDo.Localization; +using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; +using Xunit; + +// Loc.Current (src/ClaudeDo.Ui/Localization/Loc.cs) is a process-wide mutable static that many +// ViewModel test constructors assign — parallel collections race on which localizer is active. +[assembly: CollectionBehavior(DisableTestParallelization = true)] namespace ClaudeDo.Ui.Tests; @@ -17,4 +24,17 @@ internal static class TestSetup var path = Path.Combine(Path.GetTempPath(), "claudedo-ui-tests", $"operation-timing-{Environment.ProcessId}.ndjson"); OperationTiming.Shared = new OperationTiming(path); } + + // Installs the real localizer before any test runs, so a class that reads Loc.T/Loc.Current + // without setting it up itself (e.g. via a ViewModel error message) doesn't fall back to + // Loc's key-echo default depending on which test class happens to run first. + [ModuleInitializer] + internal static void InstallRealLocalizer() + { + var dir = AppContext.BaseDirectory; + while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales"))) + dir = Path.GetDirectoryName(dir); + Loc.Current = new Localizer( + LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en"); + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs index 12101a51..b6cdbdc6 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs @@ -1,4 +1,5 @@ using ClaudeDo.Data.Models; +using ClaudeDo.Localization; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Agent; @@ -9,6 +10,18 @@ namespace ClaudeDo.Ui.Tests.ViewModels; public class AgentConfigEditorViewModelTests { + public AgentConfigEditorViewModelTests() + { + // Loc.T(...) is read below without installing a localizer via a ViewModel constructor + // (unlike its sibling test classes) — install one directly so this class doesn't depend + // on whichever neighbour test happened to run first and set Loc.Current. + var dir = AppContext.BaseDirectory; + while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales"))) + dir = Path.GetDirectoryName(dir); + Loc.Current = new Localizer( + LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en"); + } + private sealed class FakeWorker : StubWorkerClient { public AppSettingsDto? App; diff --git a/tests/ClaudeDo.Worker.Tests/TestSetup.cs b/tests/ClaudeDo.Worker.Tests/TestSetup.cs index ef2daba8..64564ee7 100644 --- a/tests/ClaudeDo.Worker.Tests/TestSetup.cs +++ b/tests/ClaudeDo.Worker.Tests/TestSetup.cs @@ -1,4 +1,6 @@ using System.Runtime.CompilerServices; +using ClaudeDo.Localization; +using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; namespace ClaudeDo.Worker.Tests; @@ -16,4 +18,20 @@ internal static class TestSetup var path = Path.Combine(Path.GetTempPath(), "claudedo-worker-tests", $"operation-timing-{Environment.ProcessId}.ndjson"); OperationTiming.Shared = new OperationTiming(path); } + + // Installs the real localizer before any test runs, mirroring ClaudeDo.Ui.Tests' TestSetup, + // so a UiVm test that reads Loc.T without setting up its own localizer doesn't depend on + // ambient state left by whichever test class happened to run first. Deliberately does NOT + // also disable test parallelization here (unlike Ui.Tests) — measured 14m49s -> 18m46s + // (+27%) on this project's real-SQLite/git suite, too costly for a test-only ordering issue + // that the module initializer alone already closes. + [ModuleInitializer] + internal static void InstallRealLocalizer() + { + var dir = AppContext.BaseDirectory; + while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales"))) + dir = Path.GetDirectoryName(dir); + Loc.Current = new Localizer( + LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en"); + } }