Files
xy/docs/superpowers/specs/2026-07-31-xy-start-on-login-design.md
T
logaritmiskandClaude Opus 5 5b088f119c docs(spec): correct launchd behaviour after reading crate source
Reading service-manager-0.11.0/src/launchd.rs invalidated three claims:

- install() always writes Disabled:true alongside KeepAlive, so a freshly
  installed agent does NOT start at login. install must be followed by
  start(), which strips the key and reloads.
- The crate's stop() runs `launchctl stop`, which a KeepAlive service
  survives. start/stop are now implemented as launchctl load/unload on the
  plist path; the crate handles install/uninstall/status only.
- status() returns Stopped(None) unconditionally on macOS, so the reason
  string the spec planned to surface does not exist.

Also corrects the RestartPolicy mapping (OnFailure emits a KeepAlive
dictionary, not a boolean) and records the launchctl-print risk to verify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-07-31 22:15:27 +02:00

290 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `xy.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/xy.log` backed by the existing
`xy_supervisor::logs::RotatingLogWriter` (10 MB × 5, the same rotation used for
per-server logs). A small `MakeWriter` newtype wrapping
`Arc<Mutex<RotatingLogWriter>>` bridges the two.
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 — `xy.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/xy.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; `3` (config invalid) does not arise.
## 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.