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
301 lines
14 KiB
Markdown
301 lines
14 KiB
Markdown
# 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 daemon` at login and
|
||
restarts it if it dies.
|
||
- Manage that agent from `xy` itself — 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 of `KeepAlive`, restart policy, or service status — the wrong
|
||
abstraction for a supervised daemon.
|
||
- **Hand-rolled plist + `launchctl`** would re-implement the crate and leave us
|
||
owning modern-vs-legacy `launchctl` compatibility.
|
||
|
||
### 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. Owns `AgentSpec` and thin
|
||
`install` / `uninstall` / `start` / `stop` / `status` functions that translate
|
||
it into `ServiceInstallCtx` and back out into an `AgentStatus`. 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 into
|
||
`service.rs`, formats human output, maps outcomes to exit codes. Mirrors the
|
||
existing `cli/mod.rs` / `cli/format.rs` split.
|
||
|
||
`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's `ServiceStatus::NotInstalled` is treated as corroborating, not
|
||
authoritative, because it cannot distinguish a missing plist from an
|
||
unloadable one. With `--force`, uninstalls first, which re-snapshots `PATH` and
|
||
re-resolves `current_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 calls `install()` then `start()` — see *launchd
|
||
mechanics* — leaving the plist free of `Disabled` and the daemon running.
|
||
- **`uninstall`** — delegates to the crate, which runs `launchctl remove` and
|
||
deletes the plist. Not-installed is not an error: prints `not installed`,
|
||
exits 0, matching how `xy stop` already reports `not running`.
|
||
- **`start`** / **`stop`** — `launchctl load` and `launchctl unload` on the
|
||
plist path, leaving the plist in place. Both exit 1 if the agent is not
|
||
installed. `stop` on an already-stopped agent exits 0. Note that `stop` lasts
|
||
only until the next login, since `RunAtLoad` remains set; to disable
|
||
start-on-login permanently, use `uninstall`.
|
||
- **`status`** — reports label, plist path, state, program path, snapshotted
|
||
`PATH`, and log path. Never fails on state, only on an inability to query.
|
||
State is `running` / `stopped` / `not installed`, taken directly from
|
||
`ServiceStatus`. 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 yields `Stopped(None)`. The
|
||
pid is read from the existing `paths.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.rs` for the `AgentSpec` → `ServiceInstallCtx` mapping:
|
||
label parses, args are `["daemon"]`, the `PATH` snapshot is captured, `$HOME`
|
||
becomes 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.rs` rendering an `AgentStatus` to expected
|
||
text, mirroring the existing `cli/format.rs` tests.
|
||
- A test for `impl io::Write for RotatingLogWriter` covering byte accounting and
|
||
the rotation threshold. The rotation logic is currently exercised only through
|
||
`write_line`.
|
||
- `tests/service.rs` — a real install → status → stop → start → uninstall cycle,
|
||
marked `#[ignore]` and using the label `se.aceofba.xy-test` so that a stray
|
||
`cargo nextest run` can 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.
|