fix(ui): failureReason "usage_limit" nachziehen + Worktree-Gate prüft die Platte

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.
This commit is contained in:
mika kuns
2026-08-24 09:05:31 +02:00
parent 3dfae30fff
commit 29171b104b
5 changed files with 37 additions and 15 deletions
+1 -1
View File
@@ -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" },
+1 -1
View File
@@ -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" },
@@ -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".
+1 -1
View File
@@ -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<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
{
@@ -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]