Skip to main content

objectiveai_cli/command/agents/queue/
deliver.rs

1//! `agents queue deliver` — wake every queue-pending target in the
2//! caller's subtree.
3//!
4//! Targets come from `db::message_queue::list_delivery_targets`, in
5//! two kinds: AIHs with active queued prompts (direct rows + rows
6//! parked against BOUND tags, resolved; deduped, caller itself
7//! excluded), and un-upgraded (GROUPED) tags whose group parent sits
8//! in the subtree. Per target, the matching lock
9//! ([`crate::command::agents::locks`]) is try-acquired with no
10//! waiting:
11//!
12//! - lock held by a live owner → `AgentActive {aih}` / `TagActive
13//!   {tag}` — the agent is already running (or the tag is already
14//!   being materialized) and will drain its own queue;
15//! - lock won → `AgentSpawned {aih}` / `TagSpawned {tag}`, then the
16//!   SAME spawn machinery `agents spawn` / `agents message` use
17//!   (`spawn::run_multi_pass`, empty messages; AIHs resume via the
18//!   stored continuation, tags spawn fresh from the group's stored
19//!   agent spec with the tag threaded into the conduit upgrade)
20//!   streams the agent's output as `Value {aih, value}` envelopes.
21//!   An AIH claim is preseeded into the run's
22//!   [`AgentInstanceRegistry`], so the lock is released the moment
23//!   THAT task's stream ends — never held for the slowest. A tag
24//!   claim goes in via `hold_tag_claim`: released as soon as the
25//!   spawn claims its minted AIH lock (first chunk, just before the
26//!   `Id` first item), held to stream end otherwise. For tag spawns
27//!   the minted AIH isn't known up front — it arrives as the FIRST
28//!   inner item (the spawn `Id`), which also keys the `Value`
29//!   envelopes.
30//!
31//! Each per-target stream's FIRST item is always its resolution
32//! (`AgentActive` / `AgentSpawned` / `TagActive` / `TagSpawned` / a
33//! setup `Err`); once every target has resolved, the bare string
34//! `"AllAgentsActive"` is emitted mid-stream and spawn output keeps
35//! flowing after it.
36//!
37//! Mode split on `dangerous_advanced.stream_spawns` (mirrors
38//! `agents spawn`'s `stream`): unset/false re-execs this binary as a
39//! DETACHED ORPHAN with `stream_spawns=true` and yields the child's
40//! status items (spawn `Value` output is skipped) up to and including
41//! `AllAgentsActive`, then returns — the orphan keeps running the
42//! spawns to completion.
43
44use std::collections::HashSet;
45use std::pin::Pin;
46
47use futures::{Stream, StreamExt};
48use objectiveai_sdk::agent::completions::request::AgentCompletionCreateParams;
49use objectiveai_sdk::cli::command::ResponseItem as RootResponseItem;
50use objectiveai_sdk::cli::command::agents::ResponseItem as AgentsResponseItem;
51use objectiveai_sdk::cli::command::agents::queue::deliver::{
52    AgentActiveResponseItem, AgentActiveType, AgentSpawnedResponseItem, AgentSpawnedType,
53    AllAgentsActive, Request, RequestDangerousAdvanced, ResponseItem, TagActiveResponseItem,
54    TagActiveType, TagSpawnedResponseItem, TagSpawnedType, ValueResponseItem,
55};
56use objectiveai_sdk::cli::command::agents::spawn::ResponseItem as SpawnResponseItem;
57use objectiveai_sdk::cli::command::{BinaryExecutor, CommandExecutor};
58
59use crate::context::Context;
60use crate::db;
61use crate::error::Error;
62use crate::websockets::agent_registry::AgentInstanceRegistry;
63
64type ItemStream = Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>>;
65
66/// Internal merge item: each per-target stream is tagged with its
67/// index so the outer driver can tell when every target has resolved
68/// (each stream's first item is its resolution by construction).
69type TaggedStream =
70    Pin<Box<dyn Stream<Item = (usize, Result<ResponseItem, Error>)> + Send>>;
71
72pub async fn execute(ctx: &Context, request: Request) -> Result<ItemStream, Error> {
73    if request
74        .dangerous_advanced
75        .as_ref()
76        .and_then(|adv| adv.stream_spawns)
77        == Some(true)
78    {
79        execute_streaming(ctx, request).await
80    } else {
81        execute_detached(request).await
82    }
83}
84
85/// Default mode: re-invoke `objectiveai-cli agents queue deliver` as
86/// a detached subprocess with `stream_spawns = true`, yield the
87/// child's STATUS items (`AgentActive` / `AgentSpawned` /
88/// `TagActive` / `TagSpawned` — `Value` spawn output is skipped) up
89/// to and including `AllAgentsActive`, and return.
90/// The subprocess outlives this call — its `tokio::process::Child`
91/// handle is dropped without kill (the SDK's `BinaryExecutor` default
92/// + Windows `DETACHED_PROCESS` flag), so the spawns run to
93/// completion as an orphan.
94async fn execute_detached(request: Request) -> Result<ItemStream, Error> {
95    let mut child_request = request;
96    match child_request.dangerous_advanced.as_mut() {
97        Some(adv) => adv.stream_spawns = Some(true),
98        None => {
99            child_request.dangerous_advanced = Some(RequestDangerousAdvanced {
100                stream_spawns: Some(true),
101            })
102        }
103    }
104    // Re-exec of this CLI — strip the parent-only envelope fields.
105    crate::command::reexec::strip_inherited(&mut child_request.base);
106
107    let exe = std::env::current_exe()
108        .map_err(|e| Error::Spawn("current_exe".into(), e))?;
109    let executor = BinaryExecutor::from_path(exe).detach(true);
110
111    let mut stream = executor
112        .execute::<Request, ResponseItem>(child_request, None)
113        .await
114        .map_err(|e| Error::Instance(format!(
115            "self-respawn for agents queue deliver: {e}"
116        )))?;
117
118    let out = async_stream::stream! {
119        while let Some(item) = stream.next().await {
120            match item {
121                // Spawn output is the child's business — detached
122                // mode surfaces only the status variants.
123                Ok(ResponseItem::Value(_)) => {}
124                Ok(item) => {
125                    let done = matches!(item, ResponseItem::AllAgentsActive(_));
126                    yield Ok(item);
127                    if done {
128                        // Final item for the detached mode. Dropping
129                        // the stream drops the Child handle without
130                        // kill — the orphan finishes the spawns.
131                        break;
132                    }
133                }
134                Err(e) => yield Err(Error::Instance(format!(
135                    "self-respawn for agents queue deliver: {e}"
136                ))),
137            }
138        }
139    };
140    Ok(Box::pin(out))
141}
142
143/// `stream_spawns = true`: run the full delivery in-process.
144async fn execute_streaming(ctx: &Context, request: Request) -> Result<ItemStream, Error> {
145    // Queue-pending targets in the caller's subtree: AIHs (caller
146    // excluded — deliver targets only strict descendants; the query
147    // is parent-inclusive) and un-upgraded tags. `request.keys`, when
148    // non-empty, restricts the set to targets with a pending
149    // deliverable carrying one of those keys.
150    let caller = ctx.config.agent_instance_hierarchy.clone();
151    let targets = db::message_queue::list_delivery_targets(
152        ctx.db_client().await?,
153        &caller,
154        request.keys.as_deref().unwrap_or(&[]),
155    )
156    .await?;
157    let mut hierarchies: Vec<String> = Vec::new();
158    let mut tags: Vec<String> = Vec::new();
159    for target in targets {
160        match target {
161            db::message_queue::DeliveryTarget::Hierarchy { agent_instance_hierarchy } => {
162                if agent_instance_hierarchy != caller
163                    && !hierarchies.contains(&agent_instance_hierarchy)
164                {
165                    hierarchies.push(agent_instance_hierarchy);
166                }
167            }
168            db::message_queue::DeliveryTarget::GroupedTag { agent_tag } => {
169                if !tags.contains(&agent_tag) {
170                    tags.push(agent_tag);
171                }
172            }
173        }
174    }
175
176    let n = hierarchies.len() + tags.len();
177    let mut select_all = futures::stream::SelectAll::new();
178    let mut idx = 0usize;
179    for hierarchy in hierarchies {
180        let i = idx;
181        idx += 1;
182        let tagged = deliver_one_hierarchy(ctx.clone(), hierarchy).map(move |item| (i, item));
183        select_all.push(Box::pin(tagged) as TaggedStream);
184    }
185    for tag in tags {
186        let i = idx;
187        idx += 1;
188        let tagged = deliver_one_tag(ctx.clone(), tag).map(move |item| (i, item));
189        select_all.push(Box::pin(tagged) as TaggedStream);
190    }
191
192    let out = async_stream::stream! {
193        if n == 0 {
194            yield Ok(ResponseItem::AllAgentsActive(AllAgentsActive::AllAgentsActive));
195            return;
196        }
197        let mut seen: HashSet<usize> = HashSet::new();
198        let mut merged = select_all;
199        while let Some((idx, item)) = merged.next().await {
200            let first = seen.insert(idx);
201            yield item;
202            // Every per-target stream's first item is its resolution —
203            // once all have resolved, every agent is either already
204            // active or freshly spawned. Spawn output keeps flowing
205            // after the marker; only the detached parent stops here.
206            if first && seen.len() == n {
207                yield Ok(ResponseItem::AllAgentsActive(AllAgentsActive::AllAgentsActive));
208            }
209        }
210    };
211    Ok(Box::pin(out))
212}
213
214/// Deliver one AIH. The FIRST item is always the resolution:
215/// `AgentActive` (lock held by a live owner), `AgentSpawned` (lock
216/// won, spawn starting), or a setup `Err` (lock won but no prior
217/// session). On a win, the claim is preseeded into the run's
218/// [`AgentInstanceRegistry`] — released when that stream (and the
219/// registry inside it) drops, i.e. per-target, never held for the
220/// slowest.
221fn deliver_one_hierarchy(
222    ctx: Context,
223    hierarchy: String,
224) -> impl Stream<Item = Result<ResponseItem, Error>> + Send {
225    async_stream::stream! {
226        let state_dir = ctx.filesystem.state_dir();
227        let (dir, key) =
228            crate::command::agents::locks::agent_instance_lock(&state_dir, &hierarchy);
229        let Some(claim) = objectiveai_sdk::lockfile::try_acquire(&dir, &key, "").await else {
230            yield Ok(ResponseItem::AgentActive(AgentActiveResponseItem {
231                r#type: AgentActiveType::AgentActive,
232                agent_instance_hierarchy: hierarchy,
233            }));
234            return;
235        };
236
237        let pool = match ctx.db_client().await {
238            Ok(pool) => pool,
239            Err(e) => {
240                let _ = claim.release();
241                yield Err(e);
242                return;
243            }
244        };
245        let lookup = match crate::db::logs::lookup_session(pool, &hierarchy).await {
246            Ok(Some(lookup)) => lookup,
247            Ok(None) => {
248                // SDK claims don't release on drop — free the slot
249                // before bailing.
250                let _ = claim.release();
251                yield Err(Error::AgentNoPriorRequest {
252                    agent_instance_hierarchy: hierarchy,
253                });
254                return;
255            }
256            Err(e) => {
257                let _ = claim.release();
258                yield Err(e.into());
259                return;
260            }
261        };
262
263        let mut registry = AgentInstanceRegistry::new(state_dir);
264        registry.preseed(hierarchy.clone(), claim);
265
266        yield Ok(ResponseItem::AgentSpawned(AgentSpawnedResponseItem {
267            r#type: AgentSpawnedType::AgentSpawned,
268            agent_instance_hierarchy: hierarchy.clone(),
269        }));
270
271        // Empty messages: the wake-up turn exists so the agent drains
272        // its own queue (the conduit reads pending rows during the
273        // turn), same shape `run_multi_pass` itself uses on restart.
274        let params = AgentCompletionCreateParams {
275            messages: Vec::new(),
276            provider: None,
277            agent: lookup.agent,
278            response_format: None,
279            seed: None,
280            stream: Some(true),
281            continuation: lookup.continuation,
282        };
283        let inner = crate::command::agents::spawn::run_multi_pass(
284            ctx.clone(),
285            params,
286            None,
287            registry,
288        );
289        let mut inner = Box::pin(inner);
290        while let Some(item) = inner.next().await {
291            match item {
292                Ok(spawn_item) => {
293                    yield Ok(ResponseItem::Value(ValueResponseItem {
294                        agent_instance_hierarchy: hierarchy.clone(),
295                        value: Box::new(RootResponseItem::Agents(
296                            AgentsResponseItem::Spawn(spawn_item),
297                        )),
298                    }));
299                }
300                Err(e) => yield Err(e),
301            }
302        }
303    }
304}
305
306/// Deliver one un-upgraded (GROUPED) tag. The FIRST item is always
307/// the resolution: `TagActive` (tag lock held — someone else is
308/// already materializing it), `TagSpawned` (tag lock won, fresh
309/// spawn of the group's stored spec starting), or — when the tag
310/// raced to BOUND between the target listing and the lock — the
311/// delegated hierarchy flow's own resolution. The tag claim rides in
312/// the run's registry via `hold_tag_claim`: released the moment the
313/// spawn claims its minted AIH lock, held to stream end otherwise.
314/// The minted AIH arrives as the FIRST inner item (the spawn `Id`)
315/// and keys the `Value` envelopes.
316fn deliver_one_tag(
317    ctx: Context,
318    agent_tag: String,
319) -> impl Stream<Item = Result<ResponseItem, Error>> + Send {
320    async_stream::stream! {
321        let state_dir = ctx.filesystem.state_dir();
322        let (dir, key) =
323            crate::command::agents::locks::agent_tag_lock(&state_dir, &agent_tag);
324        let Some(claim) = objectiveai_sdk::lockfile::try_acquire(&dir, &key, "").await else {
325            yield Ok(ResponseItem::TagActive(TagActiveResponseItem {
326                r#type: TagActiveType::TagActive,
327                agent_tag,
328            }));
329            return;
330        };
331
332        // Re-resolve under the lock — the target list was a snapshot
333        // and the tag may have been upgraded (or deleted) since.
334        let pool = match ctx.db_client().await {
335            Ok(pool) => pool,
336            Err(e) => {
337                let _ = claim.release();
338                yield Err(e);
339                return;
340            }
341        };
342        let agent = match crate::db::tags::lookup(pool, &agent_tag).await {
343            Ok(crate::db::tags::LookupState::Grouped { agent_spec, .. }) => {
344                agent_spec
345            }
346            Ok(crate::db::tags::LookupState::Bound { agent_instance_hierarchy }) => {
347                // Raced to BOUND — the tag lock has no further job;
348                // deliver the live hierarchy instead.
349                let _ = claim.release();
350                let mut inner = Box::pin(deliver_one_hierarchy(
351                    ctx.clone(),
352                    agent_instance_hierarchy,
353                ));
354                while let Some(item) = inner.next().await {
355                    yield item;
356                }
357                return;
358            }
359            Ok(crate::db::tags::LookupState::Absent) => {
360                let _ = claim.release();
361                yield Err(Error::TagNotFound(agent_tag));
362                return;
363            }
364            Err(e) => {
365                let _ = claim.release();
366                yield Err(e.into());
367                return;
368            }
369        };
370
371        let mut registry = AgentInstanceRegistry::new(state_dir);
372        registry.hold_tag_claim(claim);
373
374        yield Ok(ResponseItem::TagSpawned(TagSpawnedResponseItem {
375            r#type: TagSpawnedType::TagSpawned,
376            agent_tag: agent_tag.clone(),
377        }));
378
379        // Fresh spawn from the group's stored spec: empty messages
380        // (the queued rows ARE the prompt, drained via the conduit),
381        // no continuation, the tag threaded in so the first conduit
382        // read flips the whole group to BOUND on the minted AIH.
383        let params = AgentCompletionCreateParams {
384            messages: Vec::new(),
385            provider: None,
386            agent,
387            response_format: None,
388            seed: None,
389            stream: Some(true),
390            continuation: None,
391        };
392        let inner = crate::command::agents::spawn::run_multi_pass(
393            ctx.clone(),
394            params,
395            Some(agent_tag),
396            registry,
397        );
398        let mut inner = Box::pin(inner);
399        // The minted AIH keys the Value envelopes; the spawn stream's
400        // first item is always its Id, so it's in hand before any
401        // chunk needs wrapping.
402        let mut minted: Option<String> = None;
403        while let Some(item) = inner.next().await {
404            match item {
405                Ok(spawn_item) => {
406                    if let SpawnResponseItem::Id(id) = &spawn_item {
407                        minted = Some(id.clone());
408                    }
409                    let aih = minted.clone().unwrap_or_default();
410                    yield Ok(ResponseItem::Value(ValueResponseItem {
411                        agent_instance_hierarchy: aih,
412                        value: Box::new(RootResponseItem::Agents(
413                            AgentsResponseItem::Spawn(spawn_item),
414                        )),
415                    }));
416                }
417                Err(e) => yield Err(e),
418            }
419        }
420    }
421}
422
423pub mod request_schema {
424    use objectiveai_sdk::cli::command::agents::queue::deliver as sdk;
425    use objectiveai_sdk::cli::command::agents::queue::deliver::request_schema::{
426        Request, Response,
427    };
428
429    use crate::context::Context;
430    use crate::error::Error;
431
432    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
433        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
434    }
435}
436
437pub mod response_schema {
438    use objectiveai_sdk::cli::command::agents::queue::deliver as sdk;
439    use objectiveai_sdk::cli::command::agents::queue::deliver::response_schema::{
440        Request, Response,
441    };
442
443    use crate::context::Context;
444    use crate::error::Error;
445
446    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
447        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::ResponseItem)))
448    }
449}