fix(claude-do): merge fix(ui): WorkerConnection-Modal meldet Fehlschläge sichtbar;
ClaudeDo-Task: 1db17881-7105-4f44-b035-0d13872bbfa0
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
Environment.Exit(0);
|
||||
}
|
||||
catch { /* nothing useful to show */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorText = Loc.T("modals.workerConnection.startFailed", ex.Message);
|
||||
return;
|
||||
}
|
||||
Environment.Exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user