feat(desktop): AO_KEEP_DAEMON to keep the daemon alive after the app closes (#2230)#2847
Open
axisrow wants to merge 2 commits into
Open
feat(desktop): AO_KEEP_DAEMON to keep the daemon alive after the app closes (#2230)#2847axisrow wants to merge 2 commits into
axisrow wants to merge 2 commits into
Conversation
…closes
Opt-in env var: when set, the desktop app spawns its daemon without the
OS-native supervisor link and skips the orphan-cleanup kill on exit, so the
daemon persists across app quit and stops only on an explicit `ao stop`.
Default (unset) is byte-for-byte unchanged — the daemon self-stops shortly
after the app quits.
The persistent-daemon mode is also a prerequisite for a detached / remote
topology (a headless daemon in a container, attached from the desktop over an
SSH tunnel): without it, closing the desktop window arms-then-fires the
supervisor watchdog and kills every running agent session in the container.
Mechanism:
- daemonEnv() stamps AO_OWNER="persistent" under AO_KEEP_DAEMON (else "app").
The daemon writes AO_OWNER verbatim into running.json; the attach path reads
that durable record (not this Electron process's env, which differs across
launches) and skips the supervisor re-link for "persistent". A daemon spawned
keep-alive stays persistent even when the app is later reopened without
AO_KEEP_DAEMON. shouldLinkOnAttach("persistent") === false is a regression
test.
- Under the flag the daemon's stdio is redirected to ~/.ao/daemon.log (stdin
ignored) and the child is unref()'d, so Electron-owned pipes no longer kill
the kept-alive daemon on the next stderr log write. The parent's log fd is
closed on the spawn-throw path and after a successful spawn so each
keep-alive start/stop cycle does not accumulate descriptors.
- The port-bound supervisor link and the stdio port-scanners are skipped under
the flag; port discovery falls back to the running.json handshake.
- keepDaemonAlive uses an explicit allowlist (1/true/yes/on); off/no and any
unrecognized string keep the default self-stop behavior.
- The process.on("exit") orphan-cleanup kill is skipped under the flag.
Rebased clean onto current main (5532fa1); the keyboard-shortcut refactor
(PR AgentWrapper#2795) that landed in the meantime is left untouched.
Closes AgentWrapper#2230
Co-Authored-By: Claude <noreply@anthropic.com>
Local review (Claude subagent + Codex companion), cycle 1. No critical findings in scope; two non-blocking cleanups applied: - Warn when the keep-daemon log redirect fails (openSync throws). Previously the catch silently fell back to stdio "ignore", leaving a long-lived keep-alive daemon with zero log output and no signal. Now mirrors the supervisor-link-skip warning. - Fix a doc-comment typo: "stopping only on an explicit `ao stop`" (was `ao start`). Deferred (out of scope, follow-up): the auto-update + persistent-daemon interaction Codex flagged — a keep-alive daemon survives quitAndInstall, so the relaunched UI can attach to an old bundled backend. This is the same P1 raised on the combined PR and is best handled as its own follow-up (version negotiation or kill-on-update); it does not affect self-build (where the auto-updater is disabled) and is inherent to persistent mode, not a bug in this PR's opt-in env-var. Co-Authored-By: Claude <noreply@anthropic.com>
Contributor
Author
🔍 Local review (cycle 1)Reviewed locally (Claude subagent + Codex companion), no bots pinged. Critical-only bar.
No critical Two non-blocking cleanups applied in
Gates green: |
This was referenced Jul 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Opt-in
AO_KEEP_DAEMONenv var: when set, the desktop app spawns its daemon without the OS-native supervisor link and skips the orphan-cleanup kill on exit, so the daemon persists across app quit and stops only on an explicitao stop. Default (unset) is byte-for-byte unchanged — the daemon self-stops shortly after the app quits.Beyond "power users with long sessions," the persistent-daemon mode is a prerequisite for a detached / remote topology (a headless daemon in a container, attached from the desktop over an SSH tunnel): without it, closing the desktop window arms-then-fires the supervisor watchdog and kills every running agent session in the container.
Closes #2230.
History / why a new PR
This supersedes the combined #2621 for the #2230 part, splitting it per the one-issue-per-PR review feedback. It also continues the #2231 review thread (closed when its head branch was accidentally removed during a fork cleanup, not for any code reason). All four @illegalcall review points from July 11 on #2231 are addressed in this branch:
running.jsonowner record ("persistent"vs"app"), never the current Electron process env. A daemon spawned keep-alive stays persistent even when the app is later reopened withoutAO_KEEP_DAEMON.shouldLinkOnAttach("persistent") === falseis encoded as a regression test.keepDaemonLogFdis closed on the spawn-throw path and after a successful spawn (the child holds its own inherited copy via stdio), so each keep-alive start/stop cycle no longer accumulates descriptors.~/.ao/daemon.log(stdin ignored) and the child isunref()'d, so Electron-owned pipes no longer kill the kept-alive daemon on the next stderr log write.keepDaemonAliveuses an explicit allowlist (1/true/yes/on);off/noand any unrecognized string keep the default self-stop behavior.Rebased clean onto current
main(5532fa1b); the keyboard-shortcut refactor (PR #2795) that landed in the meantime is left untouched.Mechanism
daemonEnv()stampsAO_OWNER="persistent"underAO_KEEP_DAEMON(else"app"). The daemon writesAO_OWNERverbatim intorunning.json(server.go); the attach path reads that durable record (not this Electron process's env, which differs across launches) and skips the supervisor re-link for"persistent".~/.ao/daemon.logand the child isunref()'d; the port-bound supervisor link and the stdio port-scanners are skipped (port discovery falls back to therunning.jsonhandshake).process.on("exit")orphan-cleanup kill is skipped under the flag.Files:
frontend/src/main.ts,frontend/src/main/daemon-owner.ts(.test.ts),frontend/src/shared/daemon-discovery.ts,backend/internal/runfile/runfile.go,docs/cli/README.md.Verification
gofmtclean;go build ./...ok;runfileGo suite passes.tsc --noEmitclean (one pre-existing upstreamsemvertypes error inescalation-evaluator.ts, unrelated to this PR).daemon-ownertests pass, incl. the cross-launch and allowlist cases.