Skip to content

feat(daemon): /agentd restart — exec self in place, preserve remote URL + password - #99

Merged
edwin-zvs merged 2 commits into
mainfrom
feat/agentd-restart
May 21, 2026
Merged

edwin-zvs merged 2 commits into
mainfrom
feat/agentd-restart

Conversation

@edwin-zvs

@edwin-zvs edwin-zvs commented May 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds /agentd restart slash command + daemon.restart IPC method. The daemon exec()s its own current binary in place, picking up any on-disk upgrade. cloudflared is detached so the public URL + HTTP Basic password survive the restart — phones reconnect to the same endpoint.

Two-commit story

  1. `feat(daemon): /agentd restart slash command — exec self in place` — slash dispatch + daemon.restart RPC + main-loop exec arm + RemoteSnapshot persistence scaffold.
  2. `feat(daemon): preserve remote URL + password across /agentd restart` — cloudflared detachment (process_group(0) + kill_on_drop(false)), boot-time snapshot restore in the supervisor, adopt-by-PID path in tunnel::run, explicit SIGTERM in /remote-control stop.

How URL + pw preservation works

  • Every change to RemoteState writes through to runtime_dir/remote.json (token, password, port, tunnel URL, cloudflared PID, generated_at).
  • cloudflared spawned with process_group(0) — its own session, survives the daemon's exec().
  • New daemon boot: if remote.json exists, main.rs calls start_remote. The supervisor's bind_and_install runs load_restore_snapshot — deletes the snapshot unless it's both fresh (< 5 min) AND tunnel_pid is alive (kill(pid, 0)). On success, binds the same port, hydrates RemoteState from the snapshot, and tells tunnel::run to adopt via adopt_pid != 0.
  • tunnel::run in adopt mode polls the PID every 2s and skips spawning. When the adopted PID dies, the watcher falls through to the normal spawn loop with a fresh URL.
  • /remote-control stop explicitly SIGTERMs the PID + deletes the snapshot so the next boot doesn't re-adopt.

Trade-off

A daemon SIGKILL leaks the cloudflared subprocess until the next daemon boot's snapshot sweep notices the stale state. URL preservation is worth the brief leak window for the upgrade use case.

Still deferred

  • TUI auto-reconnect on socket close. TUI today shows "agentd: restart in flight (socket closed) — reconnect when ready" and the user re-runs agent. The App holds an Arc<Client> that's hard to swap cleanly mid-loop — file a follow-up issue.
  • Web client. Already auto-reconnects with exponential backoff — with this PR's URL preservation, the phone will reconnect transparently after the restart gap (< 1s on healthy systems).

Binaries

/Users/moon/agentd/.claude/worktrees/agentd-restart/target/debug/agentd  ← relevant
/Users/moon/agentd/.claude/worktrees/agentd-restart/target/debug/agent
/Users/moon/agentd/.claude/worktrees/agentd-restart/target/debug/agentd-adapter-{shell,claude,codex,zarvis}
/Users/moon/agentd/.claude/worktrees/agentd-restart/target/debug/agentd-mcp

Test plan

  • cargo build --workspace clean.
  • cargo test -p agentd — 25 passed.
  • Local-only smoke (AGENTD_REMOTE_NO_TUNNEL=1): URL token + password + port identical pre/post restart; daemon log shows "restored from snapshot — preserving URL + password".
  • PID preserved across exec (verified with ps).
  • daemon.restart reply: new exe path + same PID.
  • Phone, real cloudflared: /remote-control to get a tunnel URL, scan from phone, /agentd restart, observe phone reconnects to same URL with no QR rescan.
  • /remote-control stop after restart: cloudflared SIGTERM'd, snapshot deleted, next boot mints fresh.
  • Daemon SIGKILL leaks cloudflared until next boot (documented trade-off).

Recording

URL + pw preservation across restart is the visible demo. Happy to record once the real-cloudflared smoke is done if you want a phone-side capture.

edwin-zvs added 2 commits May 20, 2026 09:58
Adds a `/agentd restart` slash command (TUI god session) and the
matching `daemon.restart` IPC method. The daemon resolves
`current_exe()` + args, sends a `RestartCommand` to a new
restart-channel arm in main's `tokio::select!`, and exec()s the
binary in place. PID is preserved; on-disk binary upgrades are
picked up by the new process.

Scope for this PR (issue #90, partial):

- `daemon.restart` RPC + client wrapper + TUI slash dispatch.
- `/agentd` added to zarvis adapter's slash autocomplete list.
- Main loop exec self: kernel closes CLOEXEC'd FDs (IPC + WS
  listeners), cloudflared subprocess is taken down via
  `kill_on_drop` as the runtime tears down, and the new process
  rebinds whichever listeners are configured.
- New `RemoteSnapshot` type + `RemoteState::persist()` write-
  through on every state change, persisting `{token, password,
  port, tunnel_url, tunnel_pid, generated_at}` to
  `runtime_dir/remote.json`. The new daemon doesn't read it back
  yet — that's the URL-preservation follow-up below — but the
  snapshot file is in place so the follow-up doesn't need a
  schema migration.

Deferred to follow-up issues (see #90 comments):

- **URL + password preservation across restart.** Requires
  detaching cloudflared into its own process group + adopting the
  still-running subprocess from the new daemon. The persistence
  scaffold is here; the supervisor-side adoption path and
  restore-from-snapshot bind logic remain.
- **TUI auto-reconnect on socket close.** The TUI today shows
  "daemon disconnected" status and the user re-runs `agent` to
  reconnect; auto-reconnect is invasive (the App holds an
  `Arc<Client>` that's hard to swap cleanly).
- **Web client.** Already auto-reconnects with exponential
  backoff — when the new daemon picks up the URL preservation
  work, the phone will reconnect transparently.

The TUI gracefully reports each error class — `BrokenPipe` /
`ConnectionReset` / `closed`-style errors after a successful
restart get a "restart in flight (socket closed) — reconnect when
ready" status instead of a misleading error.

Smoke (local Unix-socket end-to-end):

- `daemon.restart` → reply with new exe + same PID.
- PID preserved across exec (verified with `ps -p <pid>`).
- Daemon log shows two "loaded config" + two "listening socket"
  entries, confirming the runtime tore down and rebuilt.
- Post-exec `ping` succeeds after ~5s (orchestrator-spawn
  timeout dominates in the smoke env where harness adapters
  aren't on PATH; expect <100 ms in real use).
Extends the restart machinery to keep the cloudflared tunnel
URL and HTTP Basic auth password valid across an `exec()`. The
new daemon adopts the still-running cloudflared subprocess by
PID instead of spawning a fresh one, so phones reconnect to
the same `https://*.trycloudflare.com/t/<token>` URL after a
restart. The TUI's `/agentd restart` reply now reports
`tunnel_preserved: true` when adoption is going to succeed.

How it works:

- cloudflared is spawned with `process_group(0)` (its own
  session) and `kill_on_drop` defaulted to `false`. It survives
  the daemon's tokio runtime tear-down during `exec()`.
- Every change to `RemoteState` (`set_port`, `set_tunnel_pid`,
  `set_tunnel_url`) writes through to
  `runtime_dir/remote.json` — that scaffolding was in the
  prior commit. The snapshot captures
  `{version, token, password, port, tunnel_url, tunnel_pid,
  generated_at}`.
- New daemon boot: if `remote.json` exists, `main.rs` calls
  `start_remote(None, …)`. The supervisor's `bind_and_install`
  runs `load_restore_snapshot()`, which deletes the snapshot
  unless it's both fresh (< 5 min old) AND its `tunnel_pid` is
  still alive (`kill(pid, 0)`). On success it binds the same
  port, hydrates `RemoteState` from the snapshot, and tells
  `tunnel::run` to "adopt" via a non-zero `adopt_pid`.
- `tunnel::run` in adopt mode polls the PID every 2s and skips
  the spawn loop entirely. When the adopted PID eventually
  dies (e.g. cloudflared crash, OOM, manual `kill`), the
  watcher falls through to the normal spawn loop with a fresh
  URL — same behavior as a cold start.
- `/remote-control stop` explicitly `SIGTERM`s the recorded
  PID (since `kill_on_drop(false)` no longer takes it down) and
  deletes the snapshot file, so the next daemon boot doesn't
  re-adopt a tunnel the user just asked to stop.
- Daemon SIGINT/SIGTERM still calls `shutdown_adapters`; the
  cloudflared subprocess will be sniped by the cleanup
  triggered on next daemon boot if it's stale (the snapshot's
  liveness check fails → file deleted + fresh mint).

Trade-off explicitly documented: a daemon SIGKILL leaks the
cloudflared subprocess until the next daemon boot's snapshot
sweep. URL preservation is worth the brief leak window.

Smoke (local Unix-socket end-to-end, AGENTD_REMOTE_NO_TUNNEL=1
so no real cloudflared):

  pre:  http://127.0.0.1:65125/t/ed44860fcfbc470abae26a481dbc9247
        loyal.yak.39
  → daemon.restart → exec self →
  post: http://127.0.0.1:65125/t/ed44860fcfbc470abae26a481dbc9247
        loyal.yak.39

Same URL, same password, same port. The daemon log shows
"remote: restored from snapshot — preserving URL + password" on
the new daemon. Real cloudflared smoke pending phone test.
@edwin-zvs edwin-zvs changed the title feat(daemon): /agentd restart — exec self in place (issue #90, partial) feat(daemon): /agentd restart — exec self in place, preserve remote URL + password May 20, 2026
@edwin-zvs
edwin-zvs merged commit 678a2ac into main May 21, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the feat/agentd-restart branch May 21, 2026 05:22
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