service-manager 0.11 writes Disabled: true into every plist carrying KeepAlive, and our RestartPolicy::Always guarantees KeepAlive, so every agent we installed was born disabled. launchctl load honours the key while still exiting 0, so install reported success on an agent that would never start — not then and not at the next login. service::start now removes the Disabled key itself before loading, via a pure enable_plist() that rewrites nothing when the key is absent and preserves every other key, including the EnvironmentVariables PATH snapshot. That makes start self-healing for plists left disabled by an earlier build. The crate's own start() is still not used, since without Disabled it degrades to launchctl start, which fails on an unloaded job. Because launchctl load exits 0 on failure, start also checks a post-condition: it asks launchd whether the job now exists and reports a diagnostic if it does not. stop keeps no such check, since a benign unload of an already-stopped job also prints a failure while exiting 0. status now treats the plist on disk as the definition of installed, as the spec says: a plist that exists but is not loaded reports stopped with its program, PATH and snapshot date intact instead of collapsing to not-installed with every field cleared. That is precisely the state the Disabled bug left users in, so it is the state status most needs to describe. status also gains the log path the spec always listed, and the daemon's own log is renamed xy.log -> daemon.log so a supervised server named xy cannot share a file, and two rotation counters, with the daemon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
14 KiB
xy — Start on Login (macOS)
Date: 2026-07-31 Status: Approved — ready for implementation planning
Problem
The daemon must be launched by hand (xy daemon) and dies with the terminal
that started it. The MVP spec (2026-05-25) listed "auto-start at login via
launchd" as an explicit non-goal; this design delivers it.
Goals
- Install a user-level launchd agent that starts
xy daemonat login and restarts it if it dies. - Manage that agent from
xyitself — install, uninstall, start, stop, status. - Survive the environment launchd hands a login agent, which is far poorer than an interactive shell's.
- Give the daemon a log file, so "it didn't come up at login" is diagnosable.
Non-goals (deferred)
- Linux (systemd) and Windows support. The CLI shape is chosen so these are a change inside one module, not a change to the command surface.
- System-level (root) agents. User agents only.
- Managing the agent over IPC.
xy service *deliberately bypasses the daemon.
Decisions
launchd owns the daemon's lifecycle
Once installed, the LaunchAgent is the sanctioned way to run the daemon on
macOS: RunAtLoad starts it at login and KeepAlive restarts it on crash.
xy daemon remains available for foreground and development use; the existing
pidfile keeps the two from colliding.
Environment: snapshot PATH at install time
A LaunchAgent inherits PATH=/usr/bin:/bin:/usr/sbin:/sbin and nothing from
the user's shell. Supervised MCP servers inherit whatever the daemon has, and
servers launched through toolchain shims (pnpx, bunx) will not find their
interpreter under that PATH.
xy service install therefore captures the invoking shell's PATH and writes
it into the plist's EnvironmentVariables. The snapshot is deterministic and
visible in the plist, at the cost of going stale when a new toolchain is added
— xy service install --force re-snapshots.
Rejected: wrapping the daemon in /bin/zsh -lc, which makes login-time startup
depend on shell rc being fast and non-interactive-safe, and adds a process to
the tree for no gain here.
Verified 2026-07-31: XDG_CONFIG_HOME, XDG_STATE_HOME and XDG_RUNTIME_DIR
are all unset on this machine, so etcetera's Xdg strategy resolves to
~/.config and ~/.local/state identically for the CLI and for a
launchd-spawned daemon. There is no path-mismatch risk to design around.
Third-party crate: service-manager
service-manager = "0.11" (469K downloads, 29 reverse deps, updated
2026-02-18) provides LaunchdServiceManager::user() targeting
~/Library/LaunchAgents, an install context carrying environment,
autostart and restart_policy, and a status() returning
NotInstalled / Running / Stopped(Option<String>).
Rejected alternatives:
auto-launch(4.6M downloads) is built for GUI applications at login. It has no notion ofKeepAlive, restart policy, or service status — the wrong abstraction for a supervised daemon.- Hand-rolled plist +
launchctlwould re-implement the crate and leave us owning modern-vs-legacylaunchctlcompatibility.
Use the crate's generated plist; log from inside the daemon
LaunchdInstallConfig exposes only keep_alive — there is no way to set
StandardOutPath or StandardErrorPath through the typed API. Rather than
bypass it with the contents: Option<String> escape hatch and hand-author plist
XML, the daemon gains its own log file (see below).
Accepted limitation. Failures occurring before the daemon's logger exists —
missing binary, dyld error, malformed plist — appear in neither daemon.log nor
xy service status. This is worse than first assumed: LaunchdServiceManager:: status() returns ServiceStatus::Stopped(None) unconditionally
(launchd.rs:288), so the Option<String> reason carried by the enum is always
None on macOS and cannot be surfaced. Documented recourse:
launchctl print gui/$UID/se.aceofba.xy
Program path: current_exe(), with a warning
xy service install resolves and canonicalizes std::env::current_exe(). If
the result contains a target/debug or target/release path component, it
prints a warning that the agent will break on cargo clean and proceeds.
Architecture
service-manager is added to [workspace.dependencies] and consumed by the
xy crate alone. xy-protocol, xy-supervisor and xy-ipc are unchanged
except for one addition to logs.rs (below). Nothing crosses the IPC boundary:
xy service * manipulates launchd directly, which is what makes it work when
the daemon is dead.
Two new modules in crates/xy, Rust-2018 layout (foo.rs + foo/):
src/service.rs— the OS-facing unit. OwnsAgentSpecand thininstall/uninstall/start/stop/statusfunctions that translate it intoServiceInstallCtxand back out into anAgentStatus. Knows about launchd; knows nothing about clap or printing. Returns data, never prints, so it is testable without a terminal.src/cli/service.rs— the presentation unit. Parses the verbs, calls intoservice.rs, formats human output, maps outcomes to exit codes. Mirrors the existingcli/mod.rs/cli/format.rssplit.
main.rs gains a Service { #[command(subcommand)] verb: ServiceCmd } arm.
service.rs wires LaunchdServiceManager::user() under
cfg(target_os = "macos"). On other targets the verbs return
start-on-login is macOS-only for now and exit 1.
The agent
AgentSpec field |
Value | Plist key |
|---|---|---|
label |
se.aceofba.xy |
Label |
program |
canonicalized current_exe() |
ProgramArguments[0] |
args |
["daemon"] |
ProgramArguments[1..] |
environment |
[("PATH", <snapshot>)] |
EnvironmentVariables |
working_directory |
$HOME |
WorkingDirectory |
autostart |
true |
RunAtLoad |
restart_policy |
Always { delay_secs: Some(10) } |
KeepAlive |
username |
None — runs as the invoking user |
— |
Verified against service-manager-0.11.0/src/launchd.rs. Always emits
KeepAlive: true (a plain boolean); OnFailure instead emits a KeepAlive
dictionary with SuccessfulExit: false; Never omits the key. delay_secs
has no launchd equivalent and is discarded with a log::warn!. Always is
chosen deliberately: the daemon should come back regardless of how it exited.
delay_secs is set to None, since passing a value only produces a warning.
The label is the reverse-DNS form of the git.aceofba.se remote. It lives on
AgentSpec rather than being a constant so tests can install under a distinct
label.
working_directory is $HOME rather than launchd's default /, so that a
server config using a relative working_dir resolves somewhere predictable.
Daemon logging
main.rs is reordered so Paths::resolve() and ensure_dirs() run before
tracing is initialised. Today ensure_dirs() is called inside daemon::run,
which is too late to open a log file for the logger itself.
For the Cmd::Daemon arm only, the subscriber writes to stderr.and(file) via
MakeWriterExt, where the file half is log_dir/daemon.log backed by the existing
xy_supervisor::logs::RotatingLogWriter (10 MB × 5, the same rotation used for
per-server logs). No adapter type is needed: tracing-subscriber 0.3
implements MakeWriter for Mutex<W> where W: io::Write
(fmt/writer.rs:808), so a plain Mutex<RotatingLogWriter> satisfies
with_writer once the io::Write impl lands.
Corrected 2026-08-01. An earlier draft of this section claimed
Arc<Mutex<RotatingLogWriter>> also satisfied the bound, via
impl MakeWriter for Arc<W>. That is false: the Arc impl
(fmt/writer.rs:694) requires &'a W: io::Write, and &Mutex<W> does not
implement io::Write. The claim survived into the implementation plan and cost
a fix round before being caught. The shipped code uses a bare Mutex.
Every other subcommand keeps stderr-only logging; CLI output does not belong in the daemon's log.
This requires one targeted addition to existing code:
impl std::io::Write for RotatingLogWriter in xy-supervisor/src/logs.rs. The
type already tracks written and rotates, but today exposes only
write_line(tag, line), which prefixes a tag the daemon's own log does not
want.
Result: ~/.local/state/xy/logs/ becomes uniform — daemon.log for the daemon,
<server>.log per supervised server, all rotated by the same code.
launchd mechanics
Verified by reading service-manager-0.11.0/src/launchd.rs. Three behaviours
constrain the CLI and are not obvious from the crate's public documentation.
install() deliberately produces a disabled agent. Whenever KeepAlive is
present, make_plist also writes Disabled: true (launchd.rs:440) so that
install() never auto-starts, for cross-platform consistency. A Disabled
LaunchAgent does not start at login either, so install alone does not deliver
start-on-login. The crate's start() is what removes the Disabled key,
rewrites the plist, and reloads (launchd.rs:179-194). xy service install
therefore always calls install() then start(); after that the on-disk
plist is permanently free of Disabled and RunAtLoad works at next login.
There is no way to opt out: setting LaunchdInstallConfig::keep_alive = Some(true) still takes the has_keep_alive branch that writes Disabled.
The crate's stop() is unusable for our agent. It runs launchctl stop <label> (launchd.rs:208), which a KeepAlive: true service simply survives —
the crate's own doc comment says to call uninstall instead. Since uninstalling
would discard the PATH snapshot, xy service start and xy service stop are
implemented directly against launchctl on the plist path:
stop→launchctl unload <plist>start→launchctl load <plist>
This pair is symmetric, and start works because install already stripped
Disabled. The crate is used for install, uninstall and status only.
install()/uninstall() use the legacy verbs, launchctl load and
launchctl remove — not bootstrap/bootout. CLI output says "loaded" rather
than "bootstrapped" to match what actually happens.
Risk to verify during implementation
status() calls launchctl print <bare-label>, but user agents normally
require the gui/$UID/<label> form. The crate compensates with a two-pass
trick: on exit code 64 it scans stderr for a suggested fully-qualified label and
retries (launchd.rs:235-276). This is fragile and version-sensitive. Task 3
verifies it empirically against a real installed agent; if it proves unreliable,
the fallback is to run launchctl print gui/$UID/<label> ourselves and parse the
state = running line, which is what the crate is approximating anyway.
CLI
xy service install [--force]
xy service uninstall
xy service start
xy service stop
xy service status
install— exits 1 if the plist already exists at~/Library/LaunchAgents/se.aceofba.xy.plist, directing the user to--force. Presence of that file is the definition of "installed" throughout; the crate'sServiceStatus::NotInstalledis treated as corroborating, not authoritative, because it cannot distinguish a missing plist from an unloadable one. With--force, uninstalls first, which re-snapshotsPATHand re-resolvescurrent_exe(); this is also the upgrade path after installing a new binary or adding a toolchain. Warns and proceeds on a build-tree program path. On success it callsinstall()thenstart()— see launchd mechanics — leaving the plist free ofDisabledand the daemon running.uninstall— delegates to the crate, which runslaunchctl removeand deletes the plist. Not-installed is not an error: printsnot installed, exits 0, matching howxy stopalready reportsnot running.start/stop—launchctl loadandlaunchctl unloadon the plist path, leaving the plist in place. Both exit 1 if the agent is not installed.stopon an already-stopped agent exits 0. Note thatstoplasts only until the next login, sinceRunAtLoadremains set; to disable start-on-login permanently, useuninstall.status— reports label, plist path, state, program path, snapshottedPATH, and log path. Never fails on state, only on an inability to query. State isrunning/stopped/not installed, taken directly fromServiceStatus. There is deliberately no separate "loaded" line, because the crate's API cannot distinguish a loaded-but-stopped agent from an unloaded one, and no reason string, because macOS always yieldsStopped(None). The pid is read from the existingpaths.pidfile; the crate does not expose one. The snapshot date is the plist's mtime, not a value stored inside it.
Sample output:
$ xy service status
agent: se.aceofba.xy (user)
plist: ~/Library/LaunchAgents/se.aceofba.xy.plist
state: running (pid 4821)
program: /Users/olsson/.cargo/bin/xy
path: /opt/homebrew/bin:… (snapshotted 2026-07-31)
log: ~/.local/state/xy/logs/daemon.log
Exit codes
Reuses the established scheme, minus the codes that cannot apply. 0 success,
1 operational error (launchctl failed, agent missing, permission denied).
Code 2 (daemon unreachable) is structurally impossible because these commands
never open the socket. Code 3 is reachable only before dispatch: main.rs
returns it when Paths::resolve() fails, which happens ahead of every
subcommand including xy service. No xy service code path returns 3 itself.
Testing
TDD throughout — failing test first, then minimal implementation.
- Unit tests in
service.rsfor theAgentSpec→ServiceInstallCtxmapping: label parses, args are["daemon"], thePATHsnapshot is captured,$HOMEbecomes the working directory. No launchd involved. - Unit tests for the dev-build path predicate against a table of sample paths.
- Formatting tests in
cli/service.rsrendering anAgentStatusto expected text, mirroring the existingcli/format.rstests. - A test for
impl io::Write for RotatingLogWritercovering byte accounting and the rotation threshold. The rotation logic is currently exercised only throughwrite_line. tests/service.rs— a real install → status → stop → start → uninstall cycle, marked#[ignore]and using the labelse.aceofba.xy-testso that a straycargo nextest runcan never install a live agent. Run manually with--ignored.
Documentation
README.md gains the five xy service verbs, a note that the agent snapshots
PATH at install time and that --force re-snapshots, and the
launchctl print recourse for pre-logger failures.