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>
8.9 KiB
Worker per-user autostart (drop Windows service)
Status: approved 2026-05-29 Author: brainstorm session (mika kuns + Claude)
Problem
The worker runs as a Windows service registered under LocalSystem. The worker
shells out to the claude CLI, whose authentication is stored per-user
(%USERPROFILE%\.claude). Under LocalSystem the worker uses the system profile and
cannot see the user's Claude login, so task execution fails. The installer even exposes a
"Current User" service-account radio that the backend rejects (RegisterServiceStep
fails the install). Net effect: the only installable configuration cannot authenticate
Claude.
Goal
Run the worker as the logged-in user so it inherits the user's Claude auth, starting automatically at logon and staying alive in the background (independent of the desktop app, so Prime/scheduled tasks fire when the UI is closed).
Decisions (locked)
- Lifetime: background from logon, always — independent of the UI.
- Mechanism: per-user logon Scheduled Task (
schtasks), run only when the user is logged on (no stored password), hidden, with restart-on-failure. - No console window: worker becomes
WinExe; add a Serilog rolling file sink so worker diagnostics aren't lost. - App ensures running: "Restart Worker" becomes process-based; on app startup, if SignalR doesn't connect within a few seconds, the app launches the worker.
- Auto-migrate: the installer detects and removes the old
ClaudeDoWorkerservice, then registers the task. Uninstall removes the task + kills the worker process.
Non-goals
- Cross-account elevation (admin elevates as a different account than the interactive user). Single-user / user-is-admin is assumed; the task targets the interactive user.
- Running the worker when no user is logged on (that's the whole point — it must be a user session for Claude auth).
Component changes
ClaudeDo.Worker
ClaudeDo.Worker.csproj:<OutputType>WinExe</OutputType>. Add packagesSerilog.AspNetCoreandSerilog.Sinks.File.Program.cs:- Remove
builder.Host.UseWindowsService(...). - Configure Serilog file sink: path
<LogRoot>/worker-.log,rollingInterval: Day,retainedFileCountLimit: 7, shared write.LogRootcomes fromWorkerConfig(expand~). Wire viabuilder.Host.UseSerilog(...). - Single-instance guard: at startup create
new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew). If!createdNew, log "another worker instance is already running" and exit 0. Hold the mutex for process lifetime.Local\namespace = per user session, which is what we want.
- Remove
- CLI preflight (
ClaudeCliPreflight) behavior unchanged.
ClaudeDo.Installer
- New
Steps/RegisterAutostartStep.cs(IInstallStep, "Register Autostart"):- Build a Task Scheduler definition XML (UTF-16) and register via
schtasks /Create /TN "ClaudeDoWorker" /XML "<tmpfile>" /F. - XML shape:
Principals/Principal:UserId= current interactive user (WindowsIdentity.GetCurrent().Name),LogonType=InteractiveToken,RunLevel=LeastPrivilege.Triggers/LogonTriggerwith the sameUserId.Settings:Hidden=true,MultipleInstancesPolicy=IgnoreNew,StartWhenAvailable=true,ExecutionTimeLimit=PT0S,DisallowStartIfOnBatteries=false,StopIfGoingOnBatteries=false,RestartOnFailurewithInterval(>=PT1M; Task Scheduler's minimum granularity is one minute) andCount=3.Actions/Exec/Command: quoted path to<installDir>/worker/ClaudeDo.Worker.exe.
- The XML builder is a pure function (string in → XML string out) so it is unit testable without admin.
- Build a Task Scheduler definition XML (UTF-16) and register via
MigrateServiceStep(or folded intoRegisterAutostartStepas a first phase): detect the old service viasc query ClaudeDoWorker; if present,sc stopthensc delete(poll for clearance like the oldRegisterServiceStepdid). No-op when the service doesn't exist (fresh installs).- Rename
StopServiceStep→StopWorkerStep,StartServiceStep→StartWorkerStep, reworked to be process/task based:- Stop:
schtasks /End /TN ClaudeDoWorker(ignore errors) + kill anyClaudeDo.Workerprocess whoseMainModule.FileNameis under the install dir; wait for exit. This unlocksworker/binaries before extract. - Start:
schtasks /Run /TN ClaudeDoWorker(preferred — launches as the task principal). Used by fresh install (so the worker runs immediately rather than waiting for next logon) and by Settings "restart".
- Stop:
Pages/ServicePage/ServicePageViewModel.cs: removeIsLocalSystem/IsCurrentUserradios andServiceAccountusage. Keep SignalR port, Claude CLI path, "Start at logon" toggle (AutoStart), restart delay (maps to taskRestartOnFailure/Interval, clamped to >= 1 min). UpdateServicePageView.xamlaccordingly. RemoveServiceAccountfromInstallContext.RegisterServiceStep.cs: deleted (replaced byRegisterAutostartStep).- Pipelines (
InstallPageViewModel):- Fresh: DownloadAndExtract → WriteConfig → InitDatabase → RegisterAutostart (incl. migration no-op) → CreateShortcuts → WriteUninstallRegistry → WriteInstallManifest → StartWorker.
- Update: StopWorker → DownloadAndExtract → RegisterAutostart (migrates old service) → StartWorker → WriteInstallManifest → WriteUninstallRegistry.
- DI (
App.xaml.cs): register the renamed/new steps (concrete +IInstallStepwhere needed, following the existing double-registration pattern). Core/UninstallRunner.cs: replacesc delete ClaudeDoWorkerwithschtasks /Delete /TN ClaudeDoWorker /Fand kill the worker process; alsosc deletethe legacy service best-effort (in case an old service still lingers).
ClaudeDo.Ui / ClaudeDo.App
- New
Services/WorkerLocator.cs: resolve<installDir>/worker/ClaudeDo.Worker.exeby walking up forinstall.jsonthen registryInstallLocation(mirrorsInstallerLocator). ViewModels/IslandsShellViewModel.cs:RestartWorkerService: dropSystem.ServiceProcess.ServiceController. Kill worker process(es) under the install dir, thenProcess.Start(workerExe).- Ensure-running: on startup, if the
WorkerClientconnection isn't established within ~4s, launch the worker viaWorkerLocator+Process.Start. Guarded so it runs at most once per app session.
- Remove the
System.ServiceProcesspackage reference / usings if no longer used.
Data flow
- Logon: Task Scheduler starts
ClaudeDo.Worker.exein the user session → mutex acquired → Serilog file logging → SignalR hub on127.0.0.1:47821→ app connects. - App start with worker down: app waits ~4s for SignalR; if absent,
Process.Startworker → mutex acquired → hub up → app connects. - Duplicate launch (task + app race): second instance fails the mutex → logs → exits 0.
- Restart Worker button: kill worker proc → relaunch → mutex reacquired.
Error handling
schtasks/sccalls go through the existingProcessRunner; non-zero exits surface asStepResult.Failwith the captured output (except best-effort cleanup which is ignored).- Worker single-instance: losing the mutex is a normal, non-error exit (code 0).
- App ensure-running:
Process.Startfailures are swallowed (the logon task is the primary mechanism; the app launch is a convenience).
Testing
- Unit (no admin required):
- Task-definition XML builder: asserts UserId, LogonType, Hidden, RestartOnFailure interval clamping, quoted command path.
WorkerLocator: path resolution via tempinstall.json.- Migration decision: given
sc queryoutput (exists / not-found), decide stop+delete vs no-op — keep the decision pure, mockProcessRunneroutput. - Restart-delay → task interval clamping (
< 1 min→PT1M).
- Manual verification (post-build, on this machine):
- Update from installed
1.0.2-alpha: old service is removed (sc query ClaudeDoWorker→ not found), task exists (schtasks /Query /TN ClaudeDoWorker), worker process runs as the user, app connects, no console window. - Worker log file appears at
~/.todo-app/logs/worker-<date>.log. - Kill worker → click Restart Worker in app → reconnects.
- Close app, confirm worker still running (Prime/queue alive); reopen app → connects.
- Log off / log on → worker autostarts.
- Uninstall → task gone, worker process gone, (data kept unless opted out).
- Update from installed
Risks
- Task restart granularity is minutes, not the old seconds-level service restart. The worker's own long-running resilience + the app ensure-running cover short gaps; acceptable.
- Elevated installer must target the interactive user. Using
WindowsIdentity.GetCurrent().Nameis correct when the user elevates themselves (the assumed single-user case). Documented non-goal otherwise.