Skip to content

fix(service): repair, uninstall and separate the macOS launch agent - #99

Open
vishr wants to merge 4 commits into
mainfrom
fix/macos-service-lifecycle
Open

vishr wants to merge 4 commits into
mainfrom
fix/macos-service-lifecycle

Conversation

@vishr

@vishr vishr commented Nov 2, 2026

Copy link
Copy Markdown
Member

What changed

Fixes the macOS Driga service lifecycle and corrects the MCP and service docs. The findings came from an independent code review; each was checked against main before changing it.

  1. HIGH: repairing a stale launch agent that is loaded but not running. internal/service/launchd_darwin.go now runs launchctl itself instead of going through kardianos. Install always runs launchctl bootout gui/$UID/<label> (and ignores "not loaded") before it writes a changed plist. Start runs bootstrap gui/$UID <plist> on an unloaded job and kickstart on a loaded job whose process has exited. Restart uses kickstart -k. Status comes from launchctl print. Nothing calls load or unload any more. The fake launchctl models a launchd domain. In the new loaded-but-not-running test, a stale plist's executable no longer exists. docs/release.md describes the new sequence.
  2. HIGH: orphaned launch agent after uninstall. The cask adds uninstall launchctl: "run.driga.service", signal: [["TERM", "run.driga"]] and a zap trash: list covering data, logs, config, caches, prefs, WebKit, saved state and the legacy collector plist. Homebrew skips signal on upgrade, where the app restarts itself. I left out quit because it can stall Homebrew for 10 seconds with an Automation prompt. An upgrade runs the old version's uninstall, launchctl included, so uninstall_preflight first saves the plist to ~/Library/Caches/run.driga. The postflight reinstalls from the live plist, or from that copy if it is less than 10 minutes old, and then deletes the copy.
    For an app deleted without brew, KeepAlive is now PathState {<drigad path>: true}, so launchd stops respawning a missing helper and restarts the job when the path comes back. The service also checks its executable once a minute and exits cleanly when it is gone. The docs, the README and the cask caveats say to run driga service uninstall before deleting the app by hand.
  3. MEDIUM: dev and release services collide. Release builds now carry the link-time marker -X github.com/labstack/driga/internal/service.release=true, which .goreleaser.yaml sets on all three builds. Every other build is a development build: just build, just install, go build and go run. It installs run.driga.service.dev, driga-dev on Linux or Driga service (dev) on Windows, and logs under driga-dev. An executable named driga-<name> still uses <name>. docs/usage.md, the README source-build section and CONTRIBUTING are updated.
  4. MEDIUM: docs and MCP truthfulness. The README no longer calls the MCP tools read-only; it says driga mcp serves read views plus Driga dashboard edits. The README's paragraph saying the app "removes the service and runs it itself" now describes the current design and how to uninstall. The site's MCP guide and index are corrected too.
    All Chat action tools now have annotations, following Canon mcp.md:
    • run_command: destructive, open world
    • answer_prompt, kill, cleanup: destructive
    • ack_inbox: idempotent, not destructive
    • jump, approve: not destructive
    • summarize: open world (it goes through the Claude CLI)
      The generated MCP reference now shows Destructive and Idempotent columns. A test requires every served tool to carry annotations, and every state-changing tool to set the destructive and open-world hints explicitly.
  5. LOW: plist and polling. The plist now sets ProcessType Background and ThrottleInterval 30. The app still checks the lock and process every 2 seconds, which needs no subprocess. Lifecycle reuses the service manager's answer (from launchctl, systemd or Task Scheduler) for 30 seconds while those stay the same, and asks again as soon as the service starts or stops.
  6. Environment refresh. On macOS, install compares the complete rendered plist, environment included, with the installed one, so a changed login-shell PATH reaches the service at the next app launch. If the app cannot read the login shell, it reuses the environment the service already records (service.RecordedEnvironment), so a bare Finder PATH never replaces it. On Linux and Windows an existing service keeps its environment, which the docs now state.

Why

kardianos/service's darwin backend uses launchctl load/unload and checks status with list. It cannot tell a loaded-but-not-running job from a stopped one, and load refuses a job launchd already has. The domain commands handle both cases. A link-time marker is the only build-independent way to tell a release from a checkout build, since nothing produced the driga-dev executable name.

Risk and rollout

  • No store migration. Nothing changes in agent settings or captured text, and nothing new signals or kills processes. The cask's signal only TERMs the Driga app when it is uninstalled.
  • After upgrading, every existing macOS plist is rewritten once, because the KeepAlive, ProcessType and ThrottleInterval keys changed. That causes one service restart.
  • Anyone running a checkout build as run.driga.service gets a second service under the .dev label the next time they install. Run driga service uninstall with the old build first (documented in usage.md).
  • ProcessType Background applies launchd's background resource policy (lower CPU and I/O priority) to the collector, which also serves Chat and actions. Watch whether the app feels slower; Adaptive is the fallback.
  • Not verified against real launchd or a real Homebrew install. Both are tested only through fakes: a fake launchctl domain, and the cask's Ruby block run under a fake DSL with a temporary HOME.

Contract changes

  • MCP: Chat action tools gain annotations. Names, arguments and results are unchanged.
  • Service labels: non-release builds install the .dev / -dev label.
  • No CLI flags, config keys or schema changes.

Verified

Run on macOS (darwin/arm64) on the host:

  • gofmt -l . is clean, go mod tidy -diff, go vet ./... (native, GOOS=windows, GOOS=linux)
  • go run ./cmd/driga-docgen --check, go generate ./internal/config with no .env.example diff
  • scripts/isolated.sh go test -count=1 ./...: all packages pass
  • scripts/isolated.sh go test -race on internal/service, internal/mcp, internal/desktop, scripts: pass (I did not run the full just race)
  • Cask Ruby block tested with Ruby 4.0 in four scenarios: fresh install, existing service, upgrade through the kept copy, and a stale copy
  • Not run: just ui-check / design-check (UI untouched), goreleaser check / just snapshot (goreleaser is not installed locally; CI's snapshot job covers it), actionlint, govulncheck

Follow-ups

  • SMAppService migration (SMAppService.agent(plistName:) with the plist inside the bundle) is deliberately left out of this PR. It would replace the launch agent registration and give users the Login Items UI.
  • An event-driven service status in the app (for example a lock-file watch) instead of the 2-second lock poll.
  • Environment refresh for existing Linux and Windows services.
  • Check ProcessType Background against real Chat and actions latency.

https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ

vishr added 4 commits November 1, 2026 21:45
A job launchd still had but whose process was not running (Driga.app moved)
could not be repaired: install unloaded only a running job, and start's
launchctl load refused one already loaded. The launchd controller now
boots the job out before writing a changed plist, bootstraps or kickstarts
it, and never uses the deprecated load/unload.

Install replaces the plist whenever it differs from what it would write,
including the recorded login environment. The plist keeps the job alive
only while its executable exists, runs it as a Background process with a
30-second throttle, and the service exits when its executable is deleted.

Builds without the release link-time marker install the dev label, so
just build and go build no longer take over a release's service.

Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
brew uninstall left a KeepAlive launch agent pointing at a deleted helper.
The cask now uninstalls run.driga.service, signals a running app, and zaps
Driga's data. Its uninstall_preflight keeps the plist so an upgrade, which
runs the old uninstall, still restores the service. Release builds carry the
internal/service.release marker.

Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
The app spawned launchctl every two seconds. Observe now reuses the service
manager's answer for 30 seconds while the lock and process are unchanged.
When the login shell cannot be read, the app keeps the environment the
service records instead of reinstalling it with a bare PATH.

Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
Action tools now declare destructive, idempotent and open-world hints, and
the generated reference lists them. README no longer calls driga mcp
read-only or says the app runs the service itself, and the service docs
describe dev labels, uninstalling and environment refresh.

Claude-Session: https://claude.ai/code/session_01XkoYQXnzjsCqogTYDHrxNJ
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