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:
mika kuns
2026-05-30 09:39:41 +02:00
parent 1e5b3a6c3e
commit 26c4e5771b
26 changed files with 1244 additions and 265 deletions

View File

@@ -11,7 +11,6 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,43 @@
namespace ClaudeDo.Ui.Services;
public sealed class WorkerLocator
{
private const string InstallJson = "install.json";
private const string WorkerExe = "ClaudeDo.Worker.exe";
private const string WorkerSubdir = "worker";
public string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory)
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
public string? FindByWalkingUp(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
{
var candidate = Path.Combine(dir.FullName, WorkerSubdir, WorkerExe);
return File.Exists(candidate) ? candidate : null;
}
dir = dir.Parent;
}
return null;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public string? FindByRegistry()
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
var location = key?.GetValue("InstallLocation") as string;
if (string.IsNullOrEmpty(location)) return null;
var candidate = Path.Combine(location, WorkerSubdir, WorkerExe);
return File.Exists(candidate) ? candidate : null;
}
catch { return null; }
}
}

View File

@@ -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)