Skip to content

feat(service): managed daemon service (launchd/systemd) — completes install UX (#615) - #618

Open
kokorolx wants to merge 11 commits into
masterfrom
feat/615-daemon-service-install-ux
Open

kokorolx wants to merge 11 commits into
masterfrom
feat/615-daemon-service-install-ux

Conversation

@kokorolx

@kokorolx kokorolx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

What

Completes the install-UX workstream (#601): the one-line installers (#533 curl|bash, #603 opencode init, #534 zero-config init) get the binary onto the machine — this change registers the daemon with the OS supervisor so it autostarts at login, restarts after crashes, and survives temporary PostgreSQL outages.

New CLI surface (macOS launchd / Linux systemd --user):

nano-brain service install     # register + start
nano-brain service status      # definition + supervisor state + /health readiness
nano-brain service restart
nano-brain service update      # refresh definition after npm upgrade
nano-brain service uninstall

Exit codes: 0 ready · 1 degraded/lifecycle failure · 2 not registered · 3 unsupported (Windows/root/container rejected).

Key pieces

  • Platform adapters behind an injectable command runner (unit tests never touch a live supervisor): launchd bootout→bootstrap(retry)→kickstart, systemd daemon-reload→enable→restart; plist/unit rendering with XML + systemd escaping; atomic same-dir writes with rollback that re-registers the previous definition
  • Stable launcher pinning: validated NANO_BRAIN_BIN (canonicalized absolute) → global npm wrapper [node, run.js] (survives npm update -g) → direct binary; npx/local launchers rejected; absolute config path recorded at install, never env/credentials
  • PostgreSQL-resilient startup: storage.NewPoolWithRetry (1s→30s bounded backoff, cancellation-aware, malformed-DSN still fail-fast); startServer guards on the configured port
  • Service-aware recovery: client commands and init restart a registered service through its supervisor (before any TTY prompt), never a competing PID daemon
  • npm wrapper forwards SIGTERM/SIGINT to the Go child (no orphans) and exits with its status/signal

Validation

  • go test -race -short ./... — PASS · node --test npm/run.test.js npm/postinstall.test.js — 30/30
  • Integration (nanobrain_test only): delayed-PG proxy → NewPoolWithRetry reconnects without restart — PASS
  • macOS native smoke (isolated HOME/config, port 3199): install → launchd active → /health ready in 4s → status contract → kill -9 → launchd KeepAlive auto-restart → update → uninstall — PASS
  • Windows: the package has a pre-existing build break on Windows (verified identical on origin/master); the service subsystem adds zero new errors (tag-free GOOS split, build-tagged legacyDaemonRunning)
  • Independent review: REQUEST-CHANGES (round 1) → APPROVE-WITH-NITS (round 2) → nits closed; evidence in docs/evidence/cross-platform-daemon-auto-start/

Notes

  • Specs archived to openspec/specs/managed-daemon-service + service-resilient-startup (10 requirements)
  • No REST/MCP contract, DB migration, or persistent data-model change

kokorolx added 11 commits August 7, 2026 16:01
…ers, rendering, status (#615)

- service install/uninstall/status/restart/update with exit codes 0-3
- launchd (macOS) + systemd --user (Linux) adapters behind an injectable
  command runner (unit tests never touch a live supervisor)
- plist/unit rendering with XML + systemd escaping; atomic same-dir writes
- launcher resolution precedence: NANO_BRAIN_BIN → global npm wrapper →
  direct executable; npx/local launchers rejected for persistent install
- direct /health probe with no auto-start side effects; status contract JSON
- transactional install with definition rollback on manager failure
…uncher metadata (#615)

- storage.NewPoolWithRetry: 1s/2s/4s backoff capped at 30s for the foreground
  server; malformed DSN still fails immediately; NewPool stays fail-fast
- startServer uses the retry helper so a managed service survives PostgreSQL
  startup gaps
- npm/run.js spawns the Go child and forwards SIGTERM/SIGINT so a managed
  service never leaves an orphaned process; exits with child status/signal
- run.js sets NANO_BRAIN_NPM_{LAUNCHED,RUNJS,NODE,GLOBAL} metadata so the CLI
  pins a stable [node, run.js] pair (global root only) for service install
)

- recoverFromConnectionRefused: registered managed services are restarted
  through the native supervisor; legacy serve -d only when unregistered;
  a managed definition that fails to start never falls back to legacy
- init wizard stepServe routes through the same managed path
- register() no longer takes a spec (manager transition only) so recovery
  can (re)start an existing definition without rewriting it
- unit tests for the managed/legacy recovery matrix via injected platform
  factory and health-wait hooks
…y, evidence (#615)

- README + SETUP_AGENT document service install/status/restart/update/
  uninstall, launcher requirements (global npm / NANO_BRAIN_BIN / direct
  binary), loginctl enable-linger, log/status guidance, serve -d migration
- startServer loads config first and guards on the configured port
  (guardBeforeStartPort): a managed service on a non-default port is neither
  blocked by an unrelated :3100 server nor blind to a duplicate on its own
  port (found by the macOS smoke test)
- launchd register retries bootstrap with a 700ms bounded backoff: launchctl
  fails with 'Input/output error' when bootstrap runs immediately after
  bootout while the old job is SIGTERMed (found by the smoke test)
- buildServiceSpec uses build-tagged legacyDaemonRunning so the service
  subsystem adds no Windows build breakage (pre-existing daemon.go break
  on Windows verified identical on origin/master, out of scope)
- evidence: unit/integration validation + full macOS native smoke transcript
  (install → active → /health → status contract → kill -9 auto-restart →
  update → uninstall)
Major fixes:
- systemd register uses restart instead of start: a plain start is a no-op
  on an active unit, so service update never reloaded the new binary on
  Linux (spec scenario 'update restarts the service' was broken)
- unregistered status now reports supervisor_state=inactive plus the install
  remediation per the managed-daemon-service JSON contract
- status resolves the config path pinned at install time via a sidecar
  marker (~/.nano-brain/service/config-path) instead of the interactive env,
  so the probe stays on the pinned endpoint
- install rollback re-registers the restored previous definition so a failed
  update no longer leaves a previously-running service stopped
- NANO_BRAIN_BIN is canonicalized to an absolute path before being pinned
  (launchd/systemd spawn from /; a relative path would crash-loop)
- launchd register creates the service log directory before bootstrapping
  (the plist references it and launchd refuses jobs with unopenable log paths)
- unregister treats 'No such process' (launchd) and 'not loaded' (systemd)
  as absent-state success so uninstall can still remove the definition
- client recovery restarts a registered managed service before the
  TTY/NO_AUTO_START prompt gate (a managed service is designed to run)

Minor fixes:
- npm wrapper re-raises the child's death signal (dropping its own handlers
  first) so the exit status reflects the signal; new node test covers it
- pool retry logs the actual next_retry delay before sleeping
- service definition files written 0600 (user-only) per the spec
- validation.md documents the pre-existing Windows build break honestly
  (identical on origin/master; service files add zero new errors)
- status keeps the unregistered install remediation when the /health probe
  also reports an error (probe error no longer overwrites it)
- uninstall removes the config marker even when the definition was already
  removed manually (no stale endpoint in status)
- install rollback surfaces a failed restore-write explicitly instead of
  re-registering the wrong definition
- unit tests for the launchd 'No such process' and systemd 'not loaded'
  absent-state idempotent unregister branches
Archives the change and applies the managed-daemon-service and
service-resilient-startup capabilities to the main specs (10 requirements
added). Adds the independent review evidence (REQUEST-CHANGES round 1 →
APPROVE-WITH-NITS round 2 → nits closed).
…too) (#615)

CI (ubuntu) failed 6 tests that passed locally on macOS because they
hardcoded the launchd adapter while its usable() gate rejects non-darwin,
and the legacy recovery path polled a real /health wait (locally it
happened to pass because the dev server runs on :3100).

- tests now wrap real adapters in a usable-neutral testPlatform (embed +
  usable()=nil) so the same tests run on any GOOS; the real register/
  unregister/restart logic is still exercised via the fake runner
- client recovery uses the serviceHealthyWaitFn hook for the legacy path
  too, so tests never depend on a live server
- TestPlatformUsablePlatformGate asserts launchd/systemd reject non-native
  GOOS; the root/container test skips where the platform gate applies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant