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

@@ -23,7 +23,6 @@ public sealed class InstallContext
public int SignalRPort { get; set; } = 47_821;
public int QueueBackstopIntervalMs { get; set; } = 30_000;
public string ClaudeBin { get; set; } = "claude";
public string ServiceAccount { get; set; } = "CurrentUser";
public bool AutoStart { get; set; } = true;
public int RestartDelayMs { get; set; } = 5000;

View File

@@ -0,0 +1,52 @@
using System.Security;
namespace ClaudeDo.Installer.Core;
public static class ScheduledTaskXml
{
public static string Build(string userId, string workerExePath, int restartIntervalMinutes)
{
var minutes = restartIntervalMinutes < 1 ? 1 : restartIntervalMinutes;
var user = SecurityElement.Escape(userId);
var cmd = SecurityElement.Escape(workerExePath);
return $"""
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>ClaudeDo background worker (per-user).</Description>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<UserId>{user}</UserId>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>{user}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<Hidden>true</Hidden>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<RestartOnFailure>
<Interval>PT{minutes}M</Interval>
<Count>3</Count>
</RestartOnFailure>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{cmd}</Command>
</Exec>
</Actions>
</Task>
""";
}
}

View File

@@ -9,9 +9,9 @@ namespace ClaudeDo.Installer.Core;
public sealed class UninstallRunner
{
private readonly InstallContext _context;
private readonly StopServiceStep _stopService;
private readonly StopWorkerStep _stopService;
public UninstallRunner(InstallContext context, StopServiceStep stopService)
public UninstallRunner(InstallContext context, StopWorkerStep stopService)
{
_context = context;
_stopService = stopService;
@@ -27,16 +27,17 @@ public sealed class UninstallRunner
// 2) Stop service. If stop fails we MUST abort — deleting a service whose
// process is still running leaves orphan locked binaries under the install dir
// which Directory.Delete will silently skip.
progress.Report("Stopping worker service...");
progress.Report("Stopping worker...");
var stopResult = await _stopService.ExecuteAsync(_context, progress, ct);
if (!stopResult.Success)
return StepResult.Fail(
$"Cannot uninstall: worker service did not stop cleanly. {stopResult.ErrorMessage} " +
$"Cannot uninstall: worker did not stop cleanly. {stopResult.ErrorMessage} " +
"Kill the worker manually and re-run uninstall.");
// 3) Unregister service.
progress.Report("Unregistering service...");
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
// 3) Unregister the autostart task, and best-effort remove any legacy service.
progress.Report("Removing autostart task...");
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.TaskName}\" /F", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct);
// 3b) Remove Apps & Features registry entry (best-effort).
progress.Report("Removing Add/Remove Programs entry...");