fix(claude-do): merge fix(ui): WorkerConnection-Modal meldet Fehlschläge sichtbar;

ClaudeDo-Task: 1db17881-7105-4f44-b035-0d13872bbfa0
This commit is contained in:
mika kuns
2026-09-10 13:34:52 +02:00
8 changed files with 194 additions and 29 deletions
+5 -2
View File
@@ -407,7 +407,10 @@
"body": "ClaudeDo kann den Hintergrund-Worker nicht erreichen. Normalerweise wird er bei der Anmeldung automatisch gestartet. Du kannst ihn jetzt starten oder neu installieren, falls das Problem bestehen bleibt.",
"dismiss": "Ausblenden",
"rerunInstaller": "Installer erneut ausführen",
"startWorker": "Worker starten"
"startWorker": "Worker starten",
"workerNotFound": "Worker-Programm nicht gefunden. Installation prüfen.",
"installerNotFound": "Installer nicht gefunden. Installation prüfen.",
"startFailed": "Start fehlgeschlagen: {0}"
},
"listSettings": {
"title": "LISTENEINSTELLUNGEN",
@@ -777,7 +780,7 @@
},
"vm": {
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
"shell": { "restartingWorker": "Worker wird neu gestartet…", "unexpectedError": "Unerwarteter Fehler: {0}" },
"shell": { "unexpectedError": "Unerwarteter Fehler: {0}" },
"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", "waiting": "Wartet" },
+5 -2
View File
@@ -407,7 +407,10 @@
"body": "ClaudeDo can't reach the background worker. It is normally started automatically at logon. You can start it now, or reinstall if the problem persists.",
"dismiss": "Dismiss",
"rerunInstaller": "Rerun Installer",
"startWorker": "Start Worker"
"startWorker": "Start Worker",
"workerNotFound": "Worker executable not found. Check the installation.",
"installerNotFound": "Installer not found. Check the installation.",
"startFailed": "Start failed: {0}"
},
"listSettings": {
"title": "LIST SETTINGS",
@@ -777,7 +780,7 @@
},
"vm": {
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
"shell": { "restartingWorker": "Restarting worker…", "unexpectedError": "Unexpected error: {0}" },
"shell": { "unexpectedError": "Unexpected error: {0}" },
"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", "waiting": "Waiting" },
@@ -1,9 +1,10 @@
namespace ClaudeDo.Ui.Services;
// Two named types because DI resolves them by type, not by key.
public sealed class InstallerLocator() : InstallArtifactLocator("uninstaller", "ClaudeDo.Installer.exe");
// Two named types because DI resolves them by type, not by key. Not sealed: tests subclass
// these to fake Find() without touching the real filesystem/registry.
public class InstallerLocator() : InstallArtifactLocator("uninstaller", "ClaudeDo.Installer.exe");
public sealed class WorkerLocator() : InstallArtifactLocator("worker", "ClaudeDo.Worker.exe");
public class WorkerLocator() : InstallArtifactLocator("worker", "ClaudeDo.Worker.exe");
/// <summary>
/// Locates an executable inside a ClaudeDo install: walk up from the running
@@ -14,7 +15,7 @@ public abstract class InstallArtifactLocator(string subdir, string exeName)
{
private const string InstallJson = "install.json";
public string? Find()
public virtual string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory)
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
@@ -631,20 +631,25 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
await _updateCheck.CheckNowAsync(CancellationToken.None);
}
[ObservableProperty] private string? _restartWorkerStatus;
// How long to wait after launching the new worker process before deciding it survived
// (e.g. a port conflict kills it almost immediately — see #292 for the worker-side fix).
private const int RestartSurvivalCheckMs = 2000;
// Test seam: overrides the real kill-and-relaunch body, so tests can assert the
// success/failure footer routing without spawning a real worker process.
internal Func<Task>? RestartWorkerOperation { get; set; }
[RelayCommand]
private async Task RestartWorkerAsync()
{
RestartWorkerStatus = Loc.T("vm.shell.restartingWorker");
try
{
await Task.Run(RestartWorkerService);
await FlashRestartStatusAsync("Worker restarted.");
await (RestartWorkerOperation ?? (() => Task.Run(RestartWorkerService)))();
FlashFooterSuccess("Worker restarted.");
}
catch (Exception ex)
{
await FlashRestartStatusAsync($"Restart failed: {ex.Message}");
FlashFooterError($"Restart failed: {ex.Message} — check the worker log.");
}
}
@@ -670,14 +675,10 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
catch { /* may have exited or be inaccessible */ }
finally { p.Dispose(); }
}
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
}
private async Task FlashRestartStatusAsync(string text)
{
RestartWorkerStatus = text;
await Task.Delay(3000);
if (RestartWorkerStatus == text) RestartWorkerStatus = null;
var started = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
if (started is not null && started.WaitForExit(RestartSurvivalCheckMs))
throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})");
}
private bool _updatingClaudeCli;
@@ -1,7 +1,9 @@
using System;
using System.Diagnostics;
using System.IO;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals;
@@ -11,6 +13,9 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
private readonly WorkerLocator _workerLocator;
private readonly InstallerLocator _installerLocator;
// Test seam: real Process.Start by default, swappable to simulate a blocked/failed launch.
internal Action<ProcessStartInfo> ProcessStarter { get; set; } = psi => Process.Start(psi);
public WorkerConnectionModalViewModel(WorkerLocator workerLocator, InstallerLocator installerLocator)
{
_workerLocator = workerLocator;
@@ -19,15 +24,29 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
public Action? CloseAction { get; set; }
[ObservableProperty] private string? _errorText;
[RelayCommand] private void Close() => CloseAction?.Invoke();
[RelayCommand]
private void StartWorker()
{
var exe = _workerLocator.Find();
if (exe is null) return;
try { Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true }); }
catch { /* nothing useful to show */ }
if (exe is null)
{
ErrorText = Loc.T("modals.workerConnection.workerNotFound");
return;
}
try
{
ProcessStarter(new ProcessStartInfo(exe) { UseShellExecute = true });
}
catch (Exception ex)
{
ErrorText = Loc.T("modals.workerConnection.startFailed", ex.Message);
return;
}
ErrorText = null;
CloseAction?.Invoke();
}
@@ -35,18 +54,26 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
private void RerunInstaller()
{
var path = _installerLocator.Find();
if (path is null) return;
if (path is null)
{
ErrorText = Loc.T("modals.workerConnection.installerNotFound");
return;
}
try
{
// See IslandsShellViewModel.UpdateNow: an inherited CWD inside the install dir
// makes the installer block its own app\ rename.
Process.Start(new ProcessStartInfo(path)
ProcessStarter(new ProcessStartInfo(path)
{
UseShellExecute = true,
WorkingDirectory = Path.GetTempPath(),
});
}
catch (Exception ex)
{
ErrorText = Loc.T("modals.workerConnection.startFailed", ex.Message);
return;
}
Environment.Exit(0);
}
catch { /* nothing useful to show */ }
}
}
@@ -16,10 +16,14 @@
</Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr modals.workerConnection.title}" CloseCommand="{Binding CloseCommand}">
<Grid RowDefinitions="*,Auto" Margin="20,16">
<Grid RowDefinitions="*,Auto,Auto" Margin="20,16">
<TextBlock Grid.Row="0" Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr modals.workerConnection.body}"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"
<TextBlock Grid.Row="1" TextWrapping="Wrap" Margin="0,8,0,0"
Foreground="{DynamicResource StatusErrorBrush}"
IsVisible="{Binding ErrorText, Converter={x:Static ObjectConverters.IsNotNull}}"
Text="{Binding ErrorText}"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,16,0,0">
<Button Classes="btn" Content="{loc:Tr modals.workerConnection.dismiss}" Command="{Binding CloseCommand}"/>
<Button Classes="btn" Content="{loc:Tr modals.workerConnection.rerunInstaller}" Command="{Binding RerunInstallerCommand}"/>
@@ -0,0 +1,41 @@
using System;
using System.Threading.Tasks;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Tests;
// Covers the "restart worker" footer feedback fix: RestartWorkerStatus used to be set but was
// never bound in any view, so a died-on-launch worker (e.g. a port conflict, see #292) restarted
// silently. RestartWorkerOperation is a test seam standing in for the real kill-and-relaunch +
// short-timeout survival check, so these tests only assert the success/failure routing to the
// shared footer strip (WorkerLogText/WorkerLogLevel), not real process spawning.
public class IslandsShellViewModelRestartWorkerTests
{
[Fact]
public async Task ProcessSurvives_FlashesFooterSuccess()
{
var vm = new IslandsShellViewModel { RestartWorkerOperation = () => Task.CompletedTask };
await vm.RestartWorkerCommand.ExecuteAsync(null);
Assert.Equal(WorkerLogLevel.Success, vm.WorkerLogLevel);
Assert.Contains("restarted", vm.WorkerLogText);
Assert.True(vm.IsWorkerLogVisible);
}
[Fact]
public async Task ProcessDiesWithinTimeout_FlashesFooterError()
{
var vm = new IslandsShellViewModel
{
RestartWorkerOperation = () => throw new InvalidOperationException("worker process exited immediately (code 1)")
};
await vm.RestartWorkerCommand.ExecuteAsync(null);
Assert.Equal(WorkerLogLevel.Error, vm.WorkerLogLevel);
Assert.Contains("worker process exited immediately", vm.WorkerLogText);
Assert.True(vm.IsWorkerLogVisible);
}
}
@@ -0,0 +1,85 @@
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests.ViewModels;
public sealed class WorkerConnectionModalViewModelTests
{
private sealed class FakeWorkerLocator(string? path) : WorkerLocator
{
public override string? Find() => path;
}
private sealed class FakeInstallerLocator(string? path) : InstallerLocator
{
public override string? Find() => path;
}
[Fact]
public void StartWorker_ExeNotFound_ShowsErrorAndKeepsDialogOpen()
{
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(null), new FakeInstallerLocator(null));
var closed = false;
vm.CloseAction = () => closed = true;
vm.StartWorkerCommand.Execute(null);
Assert.False(closed);
Assert.False(string.IsNullOrEmpty(vm.ErrorText));
}
[Fact]
public void StartWorker_ProcessStartThrows_ShowsErrorAndKeepsDialogOpen()
{
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(@"C:\fake\ClaudeDo.Worker.exe"), new FakeInstallerLocator(null))
{
ProcessStarter = _ => throw new InvalidOperationException("blocked by AV")
};
var closed = false;
vm.CloseAction = () => closed = true;
vm.StartWorkerCommand.Execute(null);
Assert.False(closed);
Assert.Contains("blocked by AV", vm.ErrorText);
}
[Fact]
public void StartWorker_Success_ClearsErrorAndClosesDialog()
{
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(@"C:\fake\ClaudeDo.Worker.exe"), new FakeInstallerLocator(null))
{
ProcessStarter = _ => { }
};
var closed = false;
vm.CloseAction = () => closed = true;
vm.StartWorkerCommand.Execute(null);
Assert.True(closed);
Assert.Null(vm.ErrorText);
}
[Fact]
public void RerunInstaller_NotFound_ShowsErrorAndDoesNotThrow()
{
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(null), new FakeInstallerLocator(null));
vm.RerunInstallerCommand.Execute(null);
Assert.False(string.IsNullOrEmpty(vm.ErrorText));
}
[Fact]
public void RerunInstaller_ProcessStartThrows_ShowsError()
{
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(null), new FakeInstallerLocator(@"C:\fake\ClaudeDo.Installer.exe"))
{
ProcessStarter = _ => throw new InvalidOperationException("blocked by AV")
};
vm.RerunInstallerCommand.Execute(null);
Assert.Contains("blocked by AV", vm.ErrorText);
}
}