From 29171b104b5d7f07ba8597dc85a6c89382f60f4b Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 24 Aug 2026 09:05:31 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20failureReason=20"usage=5Flimit"=20na?= =?UTF-8?q?chziehen=20+=20Worktree-Gate=20pr=C3=BCft=20die=20Platte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei liegengebliebene Consumer aus den letzten beiden Commits: - TaskRunner klassifiziert seit 07dd7570 "usage_limit", aber weder TaskRowViewModel.FailureReasonLabel noch vm.failureReason (de/en) noch die get_task-Tool-Beschreibung kannten den Wert — die UI zeigte "Grund unbekannt", das MCP-Doc listete weiterhin max_turns|timeout|error. - TaskRowViewModel.CanOpenWorktree prüfte nur auf einen nicht-leeren String. Die Zeile behält den Path eines gemergten/verworfenen Worktrees, also war der Menüpunkt aktiv und Process.Start warf in den Footer. Jetzt zusätzlich Directory.Exists — dieselbe Prüfung, die WorktreesOverviewModalViewModel und MergeSectionViewModel schon machen. --- src/ClaudeDo.Localization/locales/de.json | 2 +- src/ClaudeDo.Localization/locales/en.json | 2 +- .../ViewModels/Islands/TaskRowViewModel.cs | 17 +++++++---- .../External/ExternalMcpService.cs | 2 +- .../UiVm/TaskRowViewModelTests.cs | 29 +++++++++++++++---- 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 3e2096c1..f082b2b4 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -719,7 +719,7 @@ "mainWindow": { "closeConfirm": { "message": "{0} Mission-Control-Sitzung(en) laufen noch. Beim Beenden werden sie abgebrochen. Trotzdem schließen?" } }, "agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" }, "taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" }, - "failureReason": { "maxTurns": "Turn-Limit erreicht", "timeout": "Zeitüberschreitung", "error": "Fehler", "cancelled": "Abgebrochen", "unknown": "Grund unbekannt" }, + "failureReason": { "maxTurns": "Turn-Limit erreicht", "timeout": "Zeitüberschreitung", "usageLimit": "Nutzungslimit erreicht", "error": "Fehler", "cancelled": "Abgebrochen", "unknown": "Grund unbekannt" }, "failureReasonTooltip": { "maxTurns": "Turn-Limit erreicht ({0}/{1} Turns) — der Worktree ist meist brauchbar; Task fortsetzen statt zurücksetzen." }, "planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" }, "taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 6d1b8c12..ea4fa38c 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -719,7 +719,7 @@ "mainWindow": { "closeConfirm": { "message": "{0} Mission Control session(s) are still running. Closing the app will end them. Close anyway?" } }, "agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" }, "taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" }, - "failureReason": { "maxTurns": "Turn limit reached", "timeout": "Timed out", "error": "Error", "cancelled": "Cancelled", "unknown": "Unknown reason" }, + "failureReason": { "maxTurns": "Turn limit reached", "timeout": "Timed out", "usageLimit": "Usage limit reached", "error": "Error", "cancelled": "Cancelled", "unknown": "Unknown reason" }, "failureReasonTooltip": { "maxTurns": "Turn limit reached ({0}/{1} turns) — the worktree is usually fine; continue the task instead of resetting it." }, "planningBadge": { "active": "PLANNING", "finalized": "PLANNED" }, "taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" }, diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 00593aa9..38a209f4 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -180,7 +180,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase // placeholder so the menu wiring is uniform; always null until a real gate exists. public string? QuickSessionDisabledReason => null; - public bool CanOpenWorktree => !string.IsNullOrWhiteSpace(WorktreePath); + // The row keeps a merged/discarded worktree's recorded Path, so a non-empty string is not + // proof the folder is still there — same on-disk check the worktrees overview and the merge + // section do before offering "open". Only read when the context menu is built, not per row. + public bool CanOpenWorktree => + !string.IsNullOrWhiteSpace(WorktreePath) && System.IO.Directory.Exists(WorktreePath); public string? OpenWorktreeDisabledReason => CanOpenWorktree ? null : Loc.T("tasks.reasonNoWorktree"); public string? RefineDisabledReason @@ -233,11 +237,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase public bool HasFailureReason => Status == TaskStatus.Failed; public string FailureReasonLabel => FailureReason switch { - "max_turns" => Loc.T("vm.failureReason.maxTurns"), - "timeout" => Loc.T("vm.failureReason.timeout"), - "cancelled" => Loc.T("vm.failureReason.cancelled"), - "error" => Loc.T("vm.failureReason.error"), - _ => Loc.T("vm.failureReason.unknown"), + "max_turns" => Loc.T("vm.failureReason.maxTurns"), + "timeout" => Loc.T("vm.failureReason.timeout"), + "usage_limit" => Loc.T("vm.failureReason.usageLimit"), + "cancelled" => Loc.T("vm.failureReason.cancelled"), + "error" => Loc.T("vm.failureReason.error"), + _ => Loc.T("vm.failureReason.unknown"), }; // max_turns gets the actionable detail (turns used/configured) since that's the one case // where the fix is "raise maxTurns and continue_task", not "reset and re-run". diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 2902b100..96a81b09 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -333,7 +333,7 @@ public sealed class ExternalMcpService "Done/Failed/Cancelled tasks can be reset to Idle for re-execution. A Queued task with a blocker waits " + "for its predecessor before the picker will claim it, and WaitingForChildren is a parent whose own work " + "is done but whose children are still running. For Status=Failed, failureReason (max_turns|timeout|" + - "error|cancelled|unknown) plus failureTurnsUsed/failureMaxTurns say why without pulling get_task_log." + + "usage_limit|error|cancelled|unknown) plus failureTurnsUsed/failureMaxTurns say why without pulling get_task_log." + McpToolDocs.TaskNumberHint)] public async Task GetTask(string taskId, CancellationToken cancellationToken) { diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs index 016f2d84..32e89b36 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs @@ -65,13 +65,19 @@ public class TaskRowViewModelTests } [Fact] - public void CanOpenWorktree_True_And_ReasonNull_When_Path_Present() + public void CanOpenWorktree_True_And_ReasonNull_When_Folder_Exists() { - var vm = new TaskRowViewModel { Id = "t" }; - vm.WorktreePath = @"C:\repo\worktrees\t"; + var dir = Path.Combine(Path.GetTempPath(), $"cd_wt_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var vm = new TaskRowViewModel { Id = "t" }; + vm.WorktreePath = dir; - Assert.True(vm.CanOpenWorktree); - Assert.Null(vm.OpenWorktreeDisabledReason); + Assert.True(vm.CanOpenWorktree); + Assert.Null(vm.OpenWorktreeDisabledReason); + } + finally { Directory.Delete(dir, true); } } [Theory] @@ -87,6 +93,18 @@ public class TaskRowViewModelTests Assert.NotNull(vm.OpenWorktreeDisabledReason); } + // A merged/discarded worktree leaves its recorded Path on the row long after the folder is + // gone — the menu entry has to gray out, not throw inside Process.Start. + [Fact] + public void CanOpenWorktree_False_When_Recorded_Folder_Is_Gone() + { + var vm = new TaskRowViewModel { Id = "t" }; + vm.WorktreePath = Path.Combine(Path.GetTempPath(), $"cd_wt_gone_{Guid.NewGuid():N}"); + + Assert.False(vm.CanOpenWorktree); + Assert.NotNull(vm.OpenWorktreeDisabledReason); + } + [Fact] public void OpenWorktreeGate_Raises_PropertyChanged_On_WorktreePath_Change() { @@ -115,7 +133,6 @@ public class TaskRowViewModelTests var vm = TaskRowViewModel.FromEntity(entity); Assert.Equal(@"C:\repo\worktrees\t", vm.WorktreePath); - Assert.True(vm.CanOpenWorktree); } [Fact]