Skip to content

feat(desktop): AO_KEEP_DAEMON to keep the daemon alive after the app closes (#2230)#2847

Open
axisrow wants to merge 2 commits into
AgentWrapper:mainfrom
axisrow:feat/keep-daemon-alive-flag
Open

feat(desktop): AO_KEEP_DAEMON to keep the daemon alive after the app closes (#2230)#2847
axisrow wants to merge 2 commits into
AgentWrapper:mainfrom
axisrow:feat/keep-daemon-alive-flag

Conversation

@axisrow

@axisrow axisrow commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Opt-in AO_KEEP_DAEMON 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.

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:

  • Cross-launch regression (persist the keep decision with the daemon): the keep decision is read from the daemon's durable running.json owner record ("persistent" vs "app"), never the current Electron process env. A daemon spawned keep-alive stays persistent even when the app is later reopened without AO_KEEP_DAEMON. shouldLinkOnAttach("persistent") === false is encoded as a regression test.
  • fd leak: the parent's keepDaemonLogFd is 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.
  • stdio lifetime: 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.
  • truthy parsing: keepDaemonAlive uses an explicit allowlist (1/true/yes/on); off/no and 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() stamps AO_OWNER="persistent" under AO_KEEP_DAEMON (else "app"). The daemon writes AO_OWNER verbatim into running.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".
  • Under the flag the daemon's stdio is redirected to ~/.ao/daemon.log and the child is unref()'d; the port-bound supervisor link and the stdio port-scanners are skipped (port discovery falls back to the running.json handshake).
  • The 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

  • gofmt clean; go build ./... ok; runfile Go suite passes.
  • tsc --noEmit clean (one pre-existing upstream semver types error in escalation-evaluator.ts, unrelated to this PR).
  • 26 daemon-owner tests pass, incl. the cross-launch and allowlist cases.

axisrow and others added 2 commits July 20, 2026 01:32
…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>
@axisrow

axisrow commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

🔍 Local review (cycle 1)

Reviewed locally (Claude subagent + Codex companion), no bots pinged. Critical-only bar.

Verdict Reviewer Finding Location
approve claude no critical findings — fd leak, stdio lifetime, allowlist parsing, cross-launch durability, exit-guard all pass verification
SKIP (out of scope) codex auto-update + persistent daemon: a keep-alive daemon survives quitAndInstall, so the relaunched UI can attach to an old bundled backend (path-based identity check, no version negotiation) main.ts:1483

No critical FIX in scope. The Codex finding is the same P1 raised on the combined PR (#2621) — inherent to persistent mode, not a bug in this PR's opt-in env-var. It does not affect self-build (auto-updater disabled via !app.isPackaged), and the proper fix (version negotiation or kill-on-update) is a separate piece of work. Best tracked as its own follow-up; not a blocker for #2230.

Two non-blocking cleanups applied in 8d24a94c:

# Cleanup Applied
minor-1 Warn when the keep-daemon log redirect fails (openSync throws) — previously fell back to stdio ignore silently, leaving a long-lived daemon with zero log output. Mirrors the supervisor-link-skip warning. ✅ main.ts
minor-2 Fix doc-comment typo: "stopping only on an explicit ao stop" (was ao start). ✅ main.ts

Gates green: tsc --noEmit clean (one pre-existing upstream semver types error in escalation-evaluator.ts, unrelated), 26 daemon-owner tests pass.

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.

Option to keep the daemon running after the desktop app closes

1 participant