diff --git a/Cargo.lock b/Cargo.lock index 152ccb47..c315397a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,7 @@ dependencies = [ "clap", "futures", "httparse", + "libc", "nix 0.29.0", "qrcode", "serde", diff --git a/README.md b/README.md index 24eb8064..1c0e6bbd 100644 --- a/README.md +++ b/README.md @@ -62,29 +62,32 @@ curl -fsSL https://raw.githubusercontent.com/zarvis-ai/agentd/main/install.sh | Pin a version or change the directory with `CONSTRUCT_VERSION=v0.2.0` / `CONSTRUCT_BIN_DIR=/usr/local/bin`. -### 3. Start the daemon +### 3. Open the fleet TUI ```sh -construct daemon run +construct ``` -Leave this running. It owns sessions, persists state, and exposes the local IPC -socket used by clients. (`constructd` is a back-compat alias for the same -daemon — `constructd run` and `construct daemon run` are equivalent.) +If no daemon is running yet, `construct` auto-starts one in the background and +attaches — there's no separate daemon step. (Opt out with +`CONSTRUCT_NO_AUTOSTART=1`, e.g. in scripts that manage the daemon themselves.) -### 4. Open the fleet TUI +Use `?` for help and `M-x` for the command palette. From the TUI you can create +sessions, switch between agents, send input, inspect diffs, and interrupt or stop +work without leaving the flow. -In a second shell: +To run the daemon explicitly instead (e.g. on a server, or under a process +supervisor): ```sh -construct +construct daemon run ``` -Use `?` for help and `M-x` for the command palette. From the TUI you can create -sessions, switch between agents, send input, inspect diffs, and interrupt or stop -work without leaving the flow. +It owns sessions, persists state, and exposes the local IPC socket used by +clients. (`constructd` is a back-compat alias — `constructd run` and +`construct daemon run` are equivalent.) -### 5. Start crack the matrix +### 4. Start crack the matrix Happy hacking. Chase the dream idea from your terminal: ask Codex, Claude Code, Antigravity, and [smith](docs/smith.md) to dive into the hard parts, then keep diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 463f9c4d..efb0f78e 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -391,12 +391,52 @@ async fn main() -> Result<()> { async fn connect(socket: &std::path::Path) -> Result> { Client::connect(socket).await.with_context(|| { format!( - "connect to daemon at {} (is `agentd` running?)", + "connect to daemon at {} (start one with `construct daemon run`)", socket.display() ) }) } async fn run_tui(socket: PathBuf) -> Result<()> { + // The TUI is the default `construct` entry point, so make it "just work" + // when no daemon is running yet: auto-start one in the background. + ensure_daemon_running(&socket).await; app::run_with_socket(socket).await } + +/// Ensure a daemon is listening on `socket`, auto-starting one in the +/// background if not. Best-effort: on any failure we fall through and let +/// `run_with_socket`'s own connect surface the original error. Set +/// `CONSTRUCT_NO_AUTOSTART=1` to opt out (e.g. scripts that manage the daemon +/// themselves). Concurrent auto-starts are safe — the daemon's single-instance +/// lock lets only one survive. +async fn ensure_daemon_running(socket: &std::path::Path) { + use std::time::Duration; + + if socket_is_live(socket) { + return; + } + if std::env::var("CONSTRUCT_NO_AUTOSTART").as_deref() == Ok("1") { + return; + } + if let Err(e) = agentd::spawn_detached_daemon(Some(socket)) { + tracing::warn!(error = %e, "failed to auto-start construct daemon"); + return; + } + tracing::info!(socket = %socket.display(), "no daemon running; auto-started one"); + + // The daemon binds the socket early in startup; poll for readiness (~5s). + for _ in 0..50 { + tokio::time::sleep(Duration::from_millis(100)).await; + if socket_is_live(socket) { + return; + } + } + tracing::warn!(socket = %socket.display(), "auto-started daemon not ready yet; continuing"); +} + +/// Cheap readiness probe: can we open the IPC socket? A stale socket file +/// (the daemon is gone) fails to connect, so this correctly reports "not live". +fn socket_is_live(socket: &std::path::Path) -> bool { + std::os::unix::net::UnixStream::connect(socket).is_ok() +} diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 003bf31d..e6ed6028 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -27,6 +27,7 @@ uuid.workspace = true chrono.workspace = true toml.workspace = true nix.workspace = true +libc = "0.2" which.workspace = true futures.workspace = true base64.workspace = true diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 019f08da..e1895930 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -79,6 +79,35 @@ pub async fn run(socket_override: Option) -> Result<()> { std::fs::create_dir_all(&paths.runtime_dir).ok(); std::fs::create_dir_all(&paths.config_dir).ok(); + // Resolve the socket path early so the single-instance lock can key off + // it: two daemons on the *same* socket must not coexist (the second would + // unlink and steal the first's socket in `server::serve`), while daemons + // on *different* sockets are independent. + let socket_path = socket_override.unwrap_or_else(|| paths.socket()); + + // Single-instance guard. Auto-start (a client spawning the daemon when it + // finds no live socket — see `spawn_detached_daemon`) means two `construct` + // launches can race to start a daemon. Whoever wins this exclusive + // advisory lock binds the socket; the loser exits cleanly instead of + // stealing the socket. Held for the process lifetime — dropped on exit, + // and released across the restart `exec()` (the fd is CLOEXEC) so the new + // image re-acquires it. + let _singleton = match acquire_singleton_lock(&socket_path) { + Ok(Some(guard)) => guard, + Ok(None) => { + tracing::info!( + socket = %socket_path.display(), + "another construct daemon already owns this socket; exiting" + ); + eprintln!( + "construct: a daemon already owns {}; not starting another.", + socket_path.display() + ); + return Ok(()); + } + Err(e) => return Err(e.context("acquire daemon single-instance lock")), + }; + let config = config::Config::load_or_default(&paths)?; tracing::info!( adapters = config.adapters.len(), @@ -124,8 +153,6 @@ pub async fn run(socket_override: Option) -> Result<()> { }); } - let socket_path = socket_override.unwrap_or_else(|| paths.socket()); - // Always expose the browser UI on localhost without remote-control // credentials. This is intentionally local-only: `/remote-control` // remains the opt-in public tunnel path and still layers token + @@ -270,6 +297,99 @@ pub async fn run(socket_override: Option) -> Result<()> { } } +/// RAII holder for the daemon single-instance advisory lock. The lock is +/// released when this is dropped (process exit) or when the fd is closed +/// (across the restart `exec()` — the fd is CLOEXEC by default). +pub struct SingletonGuard { + _file: std::fs::File, +} + +/// Try to acquire the exclusive single-instance lock for `socket_path`. The +/// lock file sits next to the socket (`.lock`), so daemons on +/// different sockets don't contend. Returns `Ok(Some(guard))` when acquired, +/// `Ok(None)` when another daemon already holds it, or `Err` on an unexpected +/// I/O failure. +fn acquire_singleton_lock(socket_path: &std::path::Path) -> Result> { + use std::os::unix::io::AsRawFd; + + let mut lock_path = socket_path.as_os_str().to_owned(); + lock_path.push(".lock"); + let lock_path = PathBuf::from(lock_path); + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .open(&lock_path) + .with_context(|| format!("open lock file {}", lock_path.display()))?; + + // Non-blocking exclusive advisory lock; EWOULDBLOCK means another live + // daemon holds it. + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if rc == 0 { + return Ok(Some(SingletonGuard { _file: file })); + } + let err = std::io::Error::last_os_error(); + match err.raw_os_error() { + Some(libc::EWOULDBLOCK) => Ok(None), + _ => Err(anyhow::Error::new(err).context(format!("flock {}", lock_path.display()))), + } +} + +/// Spawn the construct daemon as a detached background process. Used by the +/// client to auto-start a daemon when none is listening: runs ` +/// daemon run [--socket ]` in a new session (so it survives the +/// spawning TUI's exit and terminal SIGHUP), with stdio redirected to the +/// daemon log (falling back to `/dev/null`). +/// +/// Safe to call from multiple clients concurrently — the single-instance lock +/// in [`run`] ensures only one of the spawned daemons survives; the rest exit +/// immediately. +pub fn spawn_detached_daemon(socket: Option<&std::path::Path>) -> std::io::Result<()> { + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + + let exe = std::env::current_exe()?; + let mut cmd = Command::new(exe); + cmd.arg("daemon").arg("run"); + if let Some(s) = socket { + cmd.arg("--socket").arg(s); + } + cmd.stdin(Stdio::null()); + + // Redirect logs to a file so an auto-started daemon stays debuggable; + // discard them if the log can't be opened. + let paths = Paths::discover(); + std::fs::create_dir_all(&paths.state_dir).ok(); + match std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(paths.state_dir.join("daemon.log")) + { + Ok(log) => { + let log2 = log.try_clone()?; + cmd.stdout(log).stderr(log2); + } + Err(_) => { + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + } + } + + // New session: detach from the spawning process's controlling terminal and + // process group so the daemon isn't taken down when the TUI exits. + unsafe { + cmd.pre_exec(|| { + // Ignore errors (e.g. EPERM if already a session leader). + let _ = nix::unistd::setsid(); + Ok(()) + }); + } + + cmd.spawn()?; + Ok(()) +} + fn warn_legacy_paths(current: &Paths) { if let Some(msg) = legacy_migration_notice(current) { eprint!("{}", msg); diff --git a/specs/0026-single-binary-daemon-and-client.md b/specs/0026-single-binary-daemon-and-client.md index 8feb2100..1498582b 100644 --- a/specs/0026-single-binary-daemon-and-client.md +++ b/specs/0026-single-binary-daemon-and-client.md @@ -54,20 +54,34 @@ multiple daemons. must happen before socket discovery and before tracing init, so the daemon's verbose log filter applies in daemon mode and the client's quiet filter applies otherwise. +- **The client auto-starts a daemon when none is live.** Since one binary does + both, running the TUI with no daemon is a setup mistake, not a user intent — + so the TUI spawns a detached `construct daemon run` in the background and + waits for the socket, instead of erroring out. Opt out with + `CONSTRUCT_NO_AUTOSTART=1`. Auto-start is best-effort: on failure the normal + connect error still surfaces. +- **A single-instance lock makes concurrent starts safe.** Auto-start means two + `construct` launches (or a stray second `daemon run`) can race to start a + daemon. The daemon takes an exclusive advisory file lock keyed to its socket + path (`.lock`) before binding; the loser exits cleanly rather than + unlinking and stealing the live socket. The lock is held for the process + lifetime and released across the restart `exec()` (CLOEXEC fd), so the + re-execed image re-acquires it. Daemons on *different* sockets don't contend. ## Non-Goals - Not merging the adapter binaries or the MCP bridge into the unified binary. Adapters are an independent process/plugin boundary and stay separate. -- Not adding auto-spawn of the daemon from the client. Daemon startup stays - explicit so two concurrent `construct` invocations can never race to bind the - IPC socket (the bind path unlinks any existing socket before binding, so an - accidental second daemon would steal a live socket). +- Not auto-starting a daemon for one-shot control subcommands (`list`, `ping`, + …). Only the interactive TUI auto-starts one; a scripted one-shot that finds + no daemon should fail fast rather than leave a lingering background daemon. ## Examples -- Start the system: run the daemon in one place (`construct daemon run`), attach - one or more clients elsewhere (`construct`). +- First run: `construct` with no daemon running auto-starts one in the + background and attaches — no separate `construct daemon run` step needed. +- Start the system explicitly: run the daemon in one place + (`construct daemon run`), attach one or more clients elsewhere (`construct`). - Upgrade in place, then restart the running daemon: the daemon re-execs itself at the same path and rebinds the socket without losing sessions, regardless of whether it was launched as `construct daemon` or `constructd`.