Skip to main content

objectiveai_sdk/cli/command/command_executor/
binary.rs

1use std::path::PathBuf;
2use std::pin::Pin;
3
4use futures::{Stream, StreamExt};
5use tokio::io::{AsyncBufReadExt, BufReader};
6use tokio::process::Command;
7
8use crate::cli::command::{AgentArguments, CommandExecutor, CommandRequest, CommandResponse};
9
10/// Spawn the `objectiveai` cli binary on disk, feed it the argv from
11/// `request.into_command()`, and stream each stdout JSONL line back as
12/// either a typed `T` or a structured [`crate::cli::Error`].
13///
14/// The binary is resolved at `execute` time (not at construction).
15/// Resolution order:
16///
17/// - [`BinaryExecutor::from_path`] — returns the explicit path verbatim
18///   (used by tests pointing at an out-of-tree build).
19/// - [`BinaryExecutor::new`] with `Some(objectiveai_dir)` —
20///   `<objectiveai_dir>/bin/objectiveai{.exe}`.
21/// - [`BinaryExecutor::new`] with `None` —
22///   `<home>/.objectiveai/bin/objectiveai{.exe}`.
23pub struct BinaryExecutor {
24    /// The layout root (`OBJECTIVEAI_DIR`); the binary inside is
25    /// always `bin/objectiveai{.exe}`. `None` falls back to the
26    /// home-dir default (`~/.objectiveai`).
27    objectiveai_dir: Option<PathBuf>,
28    /// When set, used verbatim and the `objectiveai_dir` lookup is
29    /// skipped entirely. Lets callers (notably tests) point the
30    /// executor at any binary by absolute path without enforcing the
31    /// `<dir>/objectiveai` naming convention.
32    explicit_path: Option<PathBuf>,
33    /// Extra environment variables to set on the spawned child. Stacks
34    /// on top of the parent's environment (the child inherits the rest
35    /// — this map only overrides). Set via [`Self::env`].
36    extra_env: Vec<(String, String)>,
37    /// When `true`, the spawned child is sent a kill signal whenever
38    /// the [`tokio::process::Child`] held in the stream state is
39    /// dropped. Defaults to `false` (the SDK's long-standing
40    /// behaviour: drop-without-kill lets the child finish its work
41    /// orphaned). Set via [`Self::kill_on_drop`]. Test wrappers that
42    /// want to abort hung children (e.g. cli-test
43    /// `HangPreventingBinaryCommandExecutor`) flip this on.
44    kill_on_drop: bool,
45    /// When `true`, spawn the child detached from the parent's console
46    /// / process group so it survives the parent process exiting.
47    /// Unix already does this automatically when the parent dies
48    /// (kernel re-parents to init). Windows needs explicit
49    /// `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags;
50    /// otherwise the child's inherited console closes with the parent
51    /// and the child gets a `CTRL_CLOSE_EVENT`. Defaults to `false`.
52    detach: bool,
53    /// One-shot lockfile claim to hand off to the next spawned child
54    /// ([`crate::lockfile::LockClaim::prepare_transfer`] before the
55    /// spawn, [`crate::lockfile::LockClaim::transfer`] after) — the
56    /// child becomes the sole owner and the lock lives until the
57    /// child exits. Consumed by the first `execute`; interior
58    /// mutability because [`CommandExecutor::execute`] takes `&self`.
59    /// Set via [`Self::transfer_lock`].
60    #[cfg(feature = "lockfile")]
61    transfer_locks: std::sync::Mutex<Vec<crate::lockfile::LockClaim>>,
62}
63
64impl BinaryExecutor {
65    pub fn new(objectiveai_dir: Option<impl Into<PathBuf>>) -> Self {
66        Self {
67            objectiveai_dir: objectiveai_dir.map(Into::into),
68            explicit_path: None,
69            extra_env: Vec::new(),
70            kill_on_drop: false,
71            detach: false,
72            #[cfg(feature = "lockfile")]
73            transfer_locks: std::sync::Mutex::new(Vec::new()),
74        }
75    }
76
77    /// Construct an executor that spawns the binary at `binary`
78    /// directly, regardless of file name. Skips the `objectiveai`
79    /// name lookup so tests can target a `target/debug/objectiveai-cli`
80    /// build without renaming or symlinking.
81    pub fn from_path(binary: impl Into<PathBuf>) -> Self {
82        Self {
83            objectiveai_dir: None,
84            explicit_path: Some(binary.into()),
85            extra_env: Vec::new(),
86            kill_on_drop: false,
87            detach: false,
88            #[cfg(feature = "lockfile")]
89            transfer_locks: std::sync::Mutex::new(Vec::new()),
90        }
91    }
92
93    /// Hand `claim` off to the next spawned child: ownership of the
94    /// lock transfers into the child process, which keeps it until it
95    /// exits (the parent retains nothing). Accumulates with any prior
96    /// claim(s); all are consumed by the first `execute`. On spawn
97    /// failure the claims are released; on transfer failure the child
98    /// is killed best-effort, the failing claim plus every not-yet-
99    /// transferred claim are released, and `execute` returns
100    /// [`Error::LockTransfer`] — either way the lock slots are retryable.
101    #[cfg(feature = "lockfile")]
102    pub fn transfer_lock(mut self, claim: crate::lockfile::LockClaim) -> Self {
103        self.transfer_locks
104            .get_mut()
105            .expect("transfer_locks mutex poisoned")
106            .push(claim);
107        self
108    }
109
110    /// Hand a whole set of claims off to the next spawned child — see
111    /// [`Self::transfer_lock`]. The entire family transfers into the child,
112    /// which keeps them until it exits.
113    #[cfg(feature = "lockfile")]
114    pub fn transfer_locks(mut self, claims: Vec<crate::lockfile::LockClaim>) -> Self {
115        self.transfer_locks
116            .get_mut()
117            .expect("transfer_locks mutex poisoned")
118            .extend(claims);
119        self
120    }
121
122    /// Set an environment variable on every child the executor spawns.
123    /// Stacks on top of the parent's env; intended for tests that need
124    /// to pin a per-instance `OBJECTIVEAI_DIR`/`OBJECTIVEAI_STATE` or `OBJECTIVEAI_ADDRESS`
125    /// without racing other parallel tests via `std::env::set_var`.
126    pub fn env(
127        mut self,
128        key: impl Into<String>,
129        value: impl Into<String>,
130    ) -> Self {
131        self.extra_env.push((key.into(), value.into()));
132        self
133    }
134
135    /// When `true`, the spawned [`tokio::process::Child`] held in the
136    /// stream state is sent a kill signal when the stream is dropped.
137    /// Defaults to `false`. Wrappers that detect hangs (e.g. the
138    /// cli-test `HangPreventingBinaryCommandExecutor`) toggle this on
139    /// so dropping the inner stream tears the cli child down.
140    pub fn kill_on_drop(mut self, on: bool) -> Self {
141        self.kill_on_drop = on;
142        self
143    }
144
145    /// When `true`, the child is spawned detached from the parent's
146    /// console / process group so it survives parent exit. Pairs
147    /// naturally with `kill_on_drop = false` (the default): drop the
148    /// stream + exit the parent, the child keeps running orphaned.
149    ///
150    /// - **Windows**: sets `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`
151    ///   on the `CreateProcessW` call. `DETACHED_PROCESS` is what
152    ///   actually makes orphan survival work — without it the child
153    ///   inherits the parent's console and receives `CTRL_CLOSE_EVENT`
154    ///   when the parent's console window closes. The process-group
155    ///   flag is belt-and-suspenders signal isolation.
156    /// - **Unix**: no-op. The kernel automatically re-parents the
157    ///   child to init when the parent exits.
158    ///
159    /// Defaults to `false`.
160    pub fn detach(mut self, on: bool) -> Self {
161        self.detach = on;
162        self
163    }
164
165    fn binary_path(&self) -> Result<PathBuf, Error> {
166        if let Some(p) = &self.explicit_path {
167            return Ok(p.clone());
168        }
169        let dir = match &self.objectiveai_dir {
170            Some(d) => d.clone(),
171            None => dirs::home_dir()
172                .ok_or(Error::NoHomeDir)?
173                .join(".objectiveai"),
174        };
175        let name = if cfg!(windows) { "objectiveai.exe" } else { "objectiveai" };
176        Ok(dir.join("bin").join(name))
177    }
178}
179
180#[derive(Debug, thiserror::Error)]
181pub enum Error {
182    /// `dirs::home_dir()` returned `None` and no `objectiveai_dir` was set.
183    #[error("no home directory and no objectiveai_dir set")]
184    NoHomeDir,
185    /// Failed to spawn the binary at the resolved path.
186    #[error("failed to spawn cli binary: {0}")]
187    Spawn(std::io::Error),
188    /// Child stdout was unexpectedly absent after spawn.
189    #[error("cli binary child has no stdout handle")]
190    NoStdout,
191    /// Reading stdout line failed.
192    #[error("read cli binary stdout: {0}")]
193    Io(std::io::Error),
194    /// Stdout produced a line that didn't deserialize as either the
195    /// structured `cli::Error` or `T`.
196    #[error("decode cli binary stdout line: {0}")]
197    Json(serde_json::Error),
198    /// Structured error emitted by the cli binary on stdout.
199    #[error("{0}")]
200    Cli(crate::cli::Error),
201    /// `execute_one` was called but the stream produced no items.
202    #[error("cli binary stream produced no items")]
203    Empty,
204    /// Transferring the lockfile claim into the spawned child failed.
205    /// The child was killed best-effort and the claim released.
206    #[cfg(feature = "lockfile")]
207    #[error("transfer lockfile claim into cli binary child: {0}")]
208    LockTransfer(std::io::Error),
209}
210
211/// Per-line untagged decode. `Err` is listed first so serde tries it
212/// before `Ok` — `cli::Error`'s `type:"error"` constant short-circuits
213/// every non-error wire shape, then `Ok(T)` is the fallthrough.
214#[derive(serde::Deserialize)]
215#[serde(untagged)]
216enum Line<T> {
217    Err(crate::cli::Error),
218    Ok(T),
219}
220
221impl<T> From<Line<T>> for Result<T, Error> {
222    fn from(line: Line<T>) -> Self {
223        match line {
224            Line::Err(e) => Err(Error::Cli(e)),
225            Line::Ok(t) => Ok(t),
226        }
227    }
228}
229
230impl CommandExecutor for BinaryExecutor {
231    type Error = Error;
232    type Stream<T>
233        = Pin<Box<dyn Stream<Item = Result<T, Error>> + Send>>
234    where
235        T: Send + 'static;
236
237    async fn execute<R, T>(
238        &self,
239        request: R,
240        agent_arguments: Option<&AgentArguments>,
241    ) -> Result<Self::Stream<T>, Error>
242    where
243        R: CommandRequest + Send + serde::Serialize,
244        T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
245    {
246        // Dispatch the typed request as JSON via the cli's top-level
247        // `--request` flag — request -> argv lowering no longer exists.
248        let argv = vec![
249            "--request".to_string(),
250            serde_json::to_string(&request).map_err(Error::Json)?,
251        ];
252        let binary = self.binary_path()?;
253
254        let mut command = Command::new(&binary);
255        command
256            .args(&argv)
257            .stdin(std::process::Stdio::null())
258            .stdout(std::process::Stdio::piped())
259            .stderr(std::process::Stdio::inherit())
260            .kill_on_drop(self.kill_on_drop);
261        for (k, v) in &self.extra_env {
262            command.env(k, v);
263        }
264        // Windows detach. Required for orphan-survival when the
265        // parent will exit before the child finishes — without
266        // `DETACHED_PROCESS` the child inherits the parent's
267        // console and dies on `CTRL_CLOSE_EVENT` when the
268        // parent's console closes. We've hit this bug twice in
269        // the past; preserve the flag. Unix gets re-parent-to-init
270        // for free.
271        #[cfg(windows)]
272        if self.detach {
273            const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
274            const DETACHED_PROCESS: u32 = 0x0000_0008;
275            command.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
276        }
277        // Per-call agent identity override. When `Some`, every field
278        // gets applied atomically: `Some(v)` → set, `None` →
279        // env_remove so the parent's value can't leak through. When
280        // the bag itself is `None`, parent env is inherited
281        // untouched.
282        if let Some(args) = agent_arguments {
283            args.apply_to_command(&mut command);
284        }
285        // Lockfile-claim handoff, step 1 of 2: arm the command so the
286        // child inherits/duplicates each claim's handles at spawn.
287        // `prepare_transfer` accumulates (env + unix CLOEXEC hooks stack),
288        // so multiple claims hand off to the one child.
289        #[cfg(feature = "lockfile")]
290        let transfer_claims: Vec<crate::lockfile::LockClaim> = std::mem::take(
291            &mut *self
292                .transfer_locks
293                .lock()
294                .expect("transfer_locks mutex poisoned"),
295        );
296        #[cfg(feature = "lockfile")]
297        for claim in &transfer_claims {
298            // Arms the command: CLOEXEC-clear (unix) + the inherited-lock
299            // env so the child adopts + re-acquires the claim instantly.
300            claim.prepare_transfer(&mut command);
301        }
302        let spawned = command.spawn();
303        // Step 2 of 2: complete (or unwind) the handoff. Dropping a
304        // claim does NOT release it (ManuallyDrop), so every failure
305        // path must release explicitly to keep the lock slots
306        // retryable. On a mid-set transfer failure the already-handed
307        // claims belong to the child; release the failing claim + every
308        // remaining (un-transferred) one and kill the child.
309        #[cfg(feature = "lockfile")]
310        let spawned = match spawned {
311            Ok(child) => {
312                let mut remaining = transfer_claims.into_iter();
313                let mut transfer_err = None;
314                for claim in remaining.by_ref() {
315                    if let Err((claim, e)) = claim.transfer(&child) {
316                        let _ = claim.release();
317                        transfer_err = Some(e);
318                        break;
319                    }
320                }
321                if let Some(e) = transfer_err {
322                    for claim in remaining {
323                        let _ = claim.release();
324                    }
325                    let mut child = child;
326                    let _ = child.start_kill();
327                    return Err(Error::LockTransfer(e));
328                }
329                Ok(child)
330            }
331            Err(e) => {
332                for claim in transfer_claims {
333                    let _ = claim.release();
334                }
335                Err(e)
336            }
337        };
338        let mut child = spawned.map_err(Error::Spawn)?;
339
340        let stdout = child.stdout.take().ok_or(Error::NoStdout)?;
341        let lines = BufReader::new(stdout).lines();
342
343        // Carry `child` in the unfold state so its handle isn't dropped
344        // mid-stream. tokio's Child defaults to kill_on_drop = false,
345        // so on stream drop the child remains running until it finishes.
346        let stream = futures::stream::unfold(
347            (child, lines),
348            |(child, mut lines)| async move {
349                match lines.next_line().await {
350                    Ok(Some(line)) => {
351                        let item = match serde_json::from_str::<Line<T>>(&line) {
352                            Ok(line) => line.into(),
353                            Err(e) => Err(Error::Json(e)),
354                        };
355                        Some((item, (child, lines)))
356                    }
357                    Ok(None) => None,
358                    Err(e) => Some((Err(Error::Io(e)), (child, lines))),
359                }
360            },
361        );
362
363        Ok(Box::pin(stream))
364    }
365
366    async fn execute_one<R, T>(
367        &self,
368        request: R,
369        agent_arguments: Option<&AgentArguments>,
370    ) -> Result<T, Error>
371    where
372        R: CommandRequest + Send + serde::Serialize,
373        T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
374    {
375        let mut stream = self.execute::<R, T>(request, agent_arguments).await?;
376        stream.next().await.ok_or(Error::Empty)?
377    }
378}