feat(worker): run worker as per-user logon task instead of Windows service
A LocalSystem Windows service can't see the logged-in user's Claude CLI authentication, so the worker now runs as the current user via a hidden per-user logon Scheduled Task with restart-on-failure. - Worker is WinExe (no console window) with a Serilog rolling file sink and a single-instance mutex so the logon task, app ensure-running, and Restart button can't fight over the SignalR port. - Installer replaces the service steps (register/start/stop) with autostart task steps, migrates the legacy ClaudeDoWorker service away on update, and removes the task on uninstall. ServicePage drops the service-account UI. - UI gains a WorkerLocator; the app ensures the worker is running at startup and the Restart button kills+relaunches this install's worker process. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
|
||||
private readonly UpdateCheckService _updateCheck = null!;
|
||||
private readonly InstallerLocator _installerLocator = null!;
|
||||
private readonly WorkerLocator _workerLocator = null!;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
||||
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
||||
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
|
||||
@@ -170,6 +171,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
WorkerClient worker,
|
||||
UpdateCheckService updateCheck,
|
||||
InstallerLocator installerLocator,
|
||||
WorkerLocator workerLocator,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
|
||||
Func<MergeModalViewModel> mergeVmFactory,
|
||||
@@ -178,6 +180,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||
_updateCheck = updateCheck;
|
||||
_installerLocator = installerLocator;
|
||||
_workerLocator = workerLocator;
|
||||
_dbFactory = dbFactory;
|
||||
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
|
||||
_mergeVmFactory = mergeVmFactory;
|
||||
@@ -218,6 +221,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
_primeStatusTimer.Elapsed += (_, _) =>
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null);
|
||||
_ = Lists.LoadAsync();
|
||||
_ = EnsureWorkerRunningAsync();
|
||||
_updateCheck.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
|
||||
@@ -301,43 +305,58 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
|
||||
[ObservableProperty] private string? _restartWorkerStatus;
|
||||
|
||||
private bool _ensureRunningAttempted;
|
||||
|
||||
private async Task EnsureWorkerRunningAsync()
|
||||
{
|
||||
if (_ensureRunningAttempted) return;
|
||||
_ensureRunningAttempted = true;
|
||||
await Task.Delay(TimeSpan.FromSeconds(4));
|
||||
if (Worker?.IsConnected == true) return;
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) return;
|
||||
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); }
|
||||
catch { /* logon task is the primary mechanism; this is a convenience */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestartWorkerAsync()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
await FlashRestartStatusAsync("Service control is Windows-only.");
|
||||
return;
|
||||
}
|
||||
|
||||
RestartWorkerStatus = "Restarting worker…";
|
||||
try
|
||||
{
|
||||
await Task.Run(RestartWorkerService);
|
||||
await FlashRestartStatusAsync("Worker restarted.");
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// ServiceController throws this when the service is not installed.
|
||||
await FlashRestartStatusAsync("ClaudeDoWorker service is not installed.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await FlashRestartStatusAsync($"Restart failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
private static void RestartWorkerService()
|
||||
private void RestartWorkerService()
|
||||
{
|
||||
using var sc = new System.ServiceProcess.ServiceController("ClaudeDoWorker");
|
||||
if (sc.Status != System.ServiceProcess.ServiceControllerStatus.Stopped)
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
|
||||
|
||||
// Only kill the worker belonging to THIS installation — not any other
|
||||
// ClaudeDo.Worker on the machine (e.g. a second install).
|
||||
var exeFull = System.IO.Path.GetFullPath(exe);
|
||||
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
|
||||
{
|
||||
sc.Stop();
|
||||
sc.WaitForStatus(System.ServiceProcess.ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(20));
|
||||
try
|
||||
{
|
||||
var path = p.MainModule?.FileName;
|
||||
if (path is not null &&
|
||||
!string.Equals(System.IO.Path.GetFullPath(path), exeFull, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
p.Kill(entireProcessTree: true);
|
||||
p.WaitForExit(10000);
|
||||
}
|
||||
catch { /* may have exited or be inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
sc.Start();
|
||||
sc.WaitForStatus(System.ServiceProcess.ServiceControllerStatus.Running, TimeSpan.FromSeconds(20));
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
private async Task FlashRestartStatusAsync(string text)
|
||||
|
||||
Reference in New Issue
Block a user