Skip to main content

objectiveai_cli/command/daemon/
spawn.rs

1//! `daemon spawn` — launcher + resident foreground daemon.
2//!
3//! Launcher (`foreground` unset/false): the exact lock flow `api spawn`
4//! / `viewer spawn` use — `try_read` the lock, re-exec this cli as the
5//! foreground daemon if it isn't held, re-check on child exit.
6//!
7//! Foreground (`foreground:true`): the resident daemon. Under a blocking
8//! init gate it binds the broadcast WebSocket listener and acquires the
9//! singleton lock (publishing the client-connect `ws://` URL as the lock
10//! content, like `objectiveai-api` publishes its `http://` URL), brings
11//! up the [`crate::websockets::daemon_stream`] hub (`/listen` broadcast +
12//! `/execute` in-process runner + fixed-name producer socket), then launches
13//! every `daemon: true` plugin via the SHARED plugin executor
14//! (`plugins::run::execute`) as `<exec> daemon begin` — so each resident
15//! plugin gets the full bidirectional protocol (it can execute nested
16//! commands, exactly like `plugins run` and the conduit's `mcp begin`).
17//! The plugins are leashed to this process; if any exits, the whole
18//! daemon exits (and the OS releases the lock).
19
20use std::pin::Pin;
21
22use futures::{Stream, StreamExt};
23use objectiveai_sdk::cli::command::daemon::spawn::{Request, ResponseItem};
24use objectiveai_sdk::cli::command::plugins::run::{Path as RunPath, Request as RunRequest};
25
26use crate::context::Context;
27use crate::error::Error;
28
29type ItemStream = Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>>;
30
31pub async fn execute(ctx: &Context, request: Request) -> Result<ItemStream, Error> {
32    let foreground = request
33        .dangerous_advanced
34        .as_ref()
35        .and_then(|a| a.foreground)
36        .unwrap_or(false);
37    if foreground {
38        execute_foreground(ctx).await
39    } else {
40        // Non-foreground: the exact lock flow `api spawn` / `viewer
41        // spawn` use — try_read, exec the foreground daemon if not held,
42        // re-check on child exit.
43        spawn(ctx).await?;
44        Ok(Box::pin(futures::stream::once(async move {
45            Ok::<ResponseItem, Error>(ResponseItem { ok: true })
46        })))
47    }
48}
49
50/// Ensure the resident daemon is up, returning its published lock
51/// content. Mirrors [`crate::command::viewer::spawn::spawn`]: re-execs
52/// THIS cli as the foreground daemon via the shared
53/// `spawn_until_lock_published` helper.
54pub async fn spawn(ctx: &Context) -> Result<String, Error> {
55    let lock_dir = ctx.filesystem.state_dir().join("locks");
56    let exe = std::env::current_exe().map_err(|e| Error::Spawn("current_exe".into(), e))?;
57    crate::spawn::spawn_until_lock_published(
58        &exe,
59        &lock_dir,
60        super::DAEMON_LOCK_KEY,
61        |cmd| {
62            cmd.arg("daemon")
63                .arg("spawn")
64                .arg("--dangerous-advanced")
65                .arg("{\"foreground\":true}");
66            crate::spawn::apply_config_env(cmd, &ctx.config);
67            // The resident daemon is a per-state singleton service, not
68            // part of any agent's lineage. Since the producer tee makes
69            // ANY command auto-spawn it, scrub the transient identity
70            // `apply_config_env` just stamped — otherwise whichever
71            // command happens to spawn it first leaks its agent/plugin
72            // identity into the long-lived daemon (and into everything
73            // the daemon itself spawns). The daemon then boots with the
74            // defaults (`agent_instance_hierarchy` = "cli", the rest
75            // unset).
76            for var in [
77                "OBJECTIVEAI_AGENT_INSTANCE_HIERARCHY",
78                "OBJECTIVEAI_AGENT_ID",
79                "OBJECTIVEAI_AGENT_FULL_ID",
80                "OBJECTIVEAI_AGENT_REMOTE",
81                "OBJECTIVEAI_RESPONSE_ID",
82                "OBJECTIVEAI_RESPONSE_IDS",
83                "OBJECTIVEAI_PLUGIN_OWNER",
84                "OBJECTIVEAI_PLUGIN_REPOSITORY",
85                "OBJECTIVEAI_PLUGIN_VERSION",
86            ] {
87                cmd.env_remove(var);
88            }
89            cmd.env_remove(objectiveai_sdk::mcp::MCP_SESSION_ID_ENV);
90        },
91    )
92    .await
93}
94
95/// Foreground: the resident daemon.
96async fn execute_foreground(ctx: &Context) -> Result<ItemStream, Error> {
97    let lock_dir = ctx.filesystem.state_dir().join("locks");
98    let lock_err = |e: std::io::Error| Error::Lockfile {
99        key: super::DAEMON_LOCK_KEY.to_string(),
100        source: e,
101    };
102
103    // First acquire the init gate (blocking), then the singleton lock.
104    let init = objectiveai_sdk::lockfile::wait_acquire(
105        &lock_dir,
106        super::DAEMON_INIT_LOCK_KEY,
107        "initializing",
108    )
109    .await
110    .map_err(lock_err)?;
111
112    // Bind the broadcast WebSocket listener BEFORE claiming the
113    // singleton, so its real (post-`:0`) address can be published as the
114    // lock content. Binding happens under the init gate, which
115    // serializes startup — at most one foreground races here at a time.
116    let ws_listener = match tokio::net::TcpListener::bind((
117        ctx.config.daemon_address.as_str(),
118        ctx.config.daemon_port,
119    ))
120    .await
121    {
122        Ok(listener) => listener,
123        Err(e) => {
124            let _ = init.release();
125            return Err(Error::Spawn("daemon ws bind".into(), e));
126        }
127    };
128    // Build the client-connect URL published in the lock — a `ws://`
129    // URL, mirroring how `objectiveai-api` publishes its `http://` URL:
130    // a wildcard bind (`0.0.0.0` / `::`) maps to loopback so the
131    // published address is actually connectable.
132    let bound = match ws_listener.local_addr() {
133        Ok(addr) => {
134            let connect_ip = match addr.ip() {
135                std::net::IpAddr::V4(v4) if v4.is_unspecified() => {
136                    std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
137                }
138                std::net::IpAddr::V6(v6) if v6.is_unspecified() => {
139                    std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
140                }
141                ip => ip,
142            };
143            format!("ws://{}", std::net::SocketAddr::new(connect_ip, addr.port()))
144        }
145        Err(e) => {
146            let _ = init.release();
147            return Err(Error::Spawn("daemon ws local_addr".into(), e));
148        }
149    };
150
151    // Bind the producer socket BEFORE publishing the lock, so a held lock
152    // guarantees the socket is already listening — a producer then either
153    // connects on the first try or the daemon is dead (no connect retry).
154    let state_dir = ctx.filesystem.state_dir();
155    let socket_listener =
156        match crate::websockets::daemon_stream::bind_socket_listener(&state_dir) {
157            Ok(listener) => listener,
158            Err(e) => {
159                drop(ws_listener);
160                let _ = init.release();
161                return Err(Error::Spawn("daemon socket bind".into(), e));
162            }
163        };
164    // The dedicated agents-status producer socket, bound under the same
165    // init gate as the broadcast socket (a held daemon lock ⇒ it is up).
166    let agents_socket_listener =
167        match crate::websockets::websocket_agents::bind_agents_socket_listener(&state_dir) {
168            Ok(listener) => listener,
169            Err(e) => {
170                drop(ws_listener);
171                drop(socket_listener);
172                let _ = init.release();
173                return Err(Error::Spawn("daemon agents socket bind".into(), e));
174            }
175        };
176    // The dedicated live-conversation producer socket (log-writer tee),
177    // same init-gate guarantee.
178    let conversation_socket_listener =
179        match crate::websockets::websocket_agent_instance::bind_conversation_socket_listener(
180            &state_dir,
181        ) {
182            Ok(listener) => listener,
183            Err(e) => {
184                drop(ws_listener);
185                drop(socket_listener);
186                drop(agents_socket_listener);
187                let _ = init.release();
188                return Err(Error::Spawn("daemon conversation socket bind".into(), e));
189            }
190        };
191
192    // Publish the client-connect `ws://` URL as the lock content (the
193    // `api` / `viewer` spawn convention), so a caller reading the lock
194    // discovers exactly where to connect. Published only now that BOTH the
195    // WebSocket listener and the producer socket are up.
196    let claim = match objectiveai_sdk::lockfile::try_acquire(
197        &lock_dir,
198        super::DAEMON_LOCK_KEY,
199        &bound,
200    )
201    .await
202    {
203        // A sibling daemon already holds the lock — drop our listeners and
204        // bow out.
205        None => {
206            drop(ws_listener);
207            drop(socket_listener);
208            drop(agents_socket_listener);
209            drop(conversation_socket_listener);
210            let _ = init.release();
211            return Ok(Box::pin(futures::stream::empty()));
212        }
213        Some(claim) => claim,
214    };
215    init.release().map_err(lock_err)?;
216
217    // Bring up the broadcast hub: producer streams fed into the
218    // fixed-name local socket fan out to every connected WebSocket client
219    // of the root endpoint. Both listeners share one broadcast channel of
220    // pre-serialized frames; the sender clones they hold keep the channel
221    // open for the daemon's whole life.
222    let (tx, _rx) = tokio::sync::broadcast::channel::<String>(1024);
223    // Optional WS auth: when `DAEMON_SECRET` is set, every connection's
224    // first-message auth preamble must carry a valid signature; when
225    // unset, the server is open (the preamble is consumed regardless).
226    let secret = ctx.config.daemon_secret.clone().map(std::sync::Arc::new);
227    // The live agent-status hub: its own broadcast of `AgentEvent` frames,
228    // fed by AIH-lock announcements on `agents.sock` and watched for
229    // release. Held in scope for the daemon's life (its sender clone keeps
230    // the channel open).
231    let (agents_tx, _agents_rx) = tokio::sync::broadcast::channel::<
232        crate::websockets::websocket_agents::StatusChange,
233    >(1024);
234    let active = crate::websockets::websocket_agents::ActiveAgents::new(
235        state_dir.clone(),
236        agents_tx,
237        ctx.clone(),
238    );
239    // The live-conversation hub: log-writer tee frames arriving on
240    // `conversation.sock` fan out per-AIH to `/agents/instances/{*aih}`
241    // subscribers. Held in scope for the daemon's life.
242    let (conversation_tx, _conversation_rx) = tokio::sync::broadcast::channel::<(
243        std::sync::Arc<str>,
244        std::sync::Arc<str>,
245    )>(1024);
246    let conversations = crate::websockets::websocket_agent_instance::ConversationHub::new(
247        conversation_tx,
248        ctx.clone(),
249    );
250    crate::websockets::daemon_stream::serve_ws(
251        ws_listener,
252        tx.clone(),
253        secret,
254        ctx.clone(),
255        active.clone(),
256        conversations.clone(),
257    );
258    crate::websockets::daemon_stream::serve_socket_listener(socket_listener, tx.clone());
259    crate::websockets::websocket_agents::serve_agents_socket_listener(
260        agents_socket_listener,
261        active.clone(),
262    );
263    crate::websockets::websocket_agent_instance::serve_conversation_socket_listener(
264        conversation_socket_listener,
265        conversations.clone(),
266    );
267    // Best-effort: seed the registry with agents already holding a lock
268    // when the daemon started (off the boot path — no DB round-trip block).
269    tokio::spawn({
270        let active = active.clone();
271        async move {
272            active.reconcile_startup().await;
273        }
274    });
275    // Live tag tracking: broadcast an `Updated` for an agent whenever its
276    // bound tags change (a `tags_changed` NOTIFY from the DB). Resident for
277    // the daemon's life; reconnects on listener error.
278    tokio::spawn(active.clone().watch_tag_changes());
279
280    // Launch every daemon plugin under the SHARED plugin executor, run
281    // as `<exec> daemon begin`. `plugins::run::execute` spawns it leashed
282    // and drives the full nested-command protocol; we consume (drive) its
283    // stream below.
284    let manifests: Vec<crate::filesystem::plugins::Manifest> = ctx
285        .filesystem
286        .list_plugins(0, usize::MAX)
287        .await
288        .into_iter()
289        .filter(|m| m.daemon)
290        .collect();
291    let mut streams = Vec::new();
292    for manifest in manifests {
293        let request = RunRequest {
294            path_type: RunPath::PluginsRun,
295            owner: manifest.owner,
296            name: manifest.name,
297            version: manifest.version,
298            args: vec!["daemon".to_string(), "begin".to_string()],
299            base: Default::default(),
300        };
301        let stream = crate::command::plugins::run::execute(ctx, request).await?;
302        streams.push(stream);
303    }
304
305    let stream = async_stream::stream! {
306        // Hold the lock claim for the daemon's whole life: `LockClaim`
307        // never releases on drop (the OS reclaims the handles on process
308        // exit — exactly the liveness we want).
309        let _claim = claim;
310
311        let mut drains = futures::stream::FuturesUnordered::new();
312        for plugin_stream in streams {
313            drains.push(async move {
314                let mut plugin_stream = plugin_stream;
315                // Draining the stream DRIVES the plugin's protocol —
316                // nested commands run as items are consumed. The stream
317                // ends only when the plugin exits.
318                while plugin_stream.next().await.is_some() {}
319            });
320        }
321
322        // Ready: the launcher's handshake (and the lone item a direct
323        // `daemon spawn --foreground` watcher would see).
324        yield Ok::<ResponseItem, Error>(ResponseItem { ok: true });
325
326        if drains.is_empty() {
327            // No daemon plugins: stay resident so the singleton is held.
328            std::future::pending::<()>().await;
329        } else {
330            // Any plugin exiting ends the whole daemon.
331            let _ = drains.next().await;
332        }
333    };
334    Ok(Box::pin(stream))
335}
336
337pub mod request_schema {
338    use objectiveai_sdk::cli::command::daemon::spawn as sdk;
339    use objectiveai_sdk::cli::command::daemon::spawn::request_schema::{Request, Response};
340
341    use crate::context::Context;
342    use crate::error::Error;
343
344    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
345        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
346    }
347}
348
349pub mod response_schema {
350    use objectiveai_sdk::cli::command::daemon::spawn as sdk;
351    use objectiveai_sdk::cli::command::daemon::spawn::response_schema::{Request, Response};
352
353    use crate::context::Context;
354    use crate::error::Error;
355
356    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
357        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::ResponseItem)))
358    }
359}