Files
xy/docs/superpowers/specs/2026-07-31-xy-start-on-login-design.md
T
logaritmiskandClaude Opus 5 7bb80803fe fix(service): strip Disabled before load, verify it, and honour the plist
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
2026-08-01 00:27:34 +02:00

301 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 `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.