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.
148    let caller = ctx.config.agent_instance_hierarchy.clone();
149    let targets = db::message_queue::list_delivery_targets(ctx.db_client().await?, &caller).await?;
150    let mut hierarchies: Vec<String> = Vec::new();
151    let mut tags: Vec<String> = Vec::new();
152    for target in targets {
153        match target {
154            db::message_queue::DeliveryTarget::Hierarchy { agent_instance_hierarchy } => {
155                if agent_instance_hierarchy != caller
156                    && !hierarchies.contains(&agent_instance_hierarchy)
157                {
158                    hierarchies.push(agent_instance_hierarchy);
159                }
160            }
161            db::message_queue::DeliveryTarget::GroupedTag { agent_tag } => {
162                if !tags.contains(&agent_tag) {
163                    tags.push(agent_tag);
164                }
165            }
166        }
167    }
168
169    let n = hierarchies.len() + tags.len();
170    let mut select_all = futures::stream::SelectAll::new();
171    let mut idx = 0usize;
172    for hierarchy in hierarchies {
173        let i = idx;
174        idx += 1;
175        let tagged = deliver_one_hierarchy(ctx.clone(), hierarchy).map(move |item| (i, item));
176        select_all.push(Box::pin(tagged) as TaggedStream);
177    }
178    for tag in tags {
179        let i = idx;
180        idx += 1;
181        let tagged = deliver_one_tag(ctx.clone(), tag).map(move |item| (i, item));
182        select_all.push(Box::pin(tagged) as TaggedStream);
183    }
184
185    let out = async_stream::stream! {
186        if n == 0 {
187            yield Ok(ResponseItem::AllAgentsActive(AllAgentsActive::AllAgentsActive));
188            return;
189        }
190        let mut seen: HashSet<usize> = HashSet::new();
191        let mut merged = select_all;
192        while let Some((idx, item)) = merged.next().await {
193            let first = seen.insert(idx);
194            yield item;
195            // Every per-target stream's first item is its resolution —
196            // once all have resolved, every agent is either already
197            // active or freshly spawned. Spawn output keeps flowing
198            // after the marker; only the detached parent stops here.
199            if first && seen.len() == n {
200                yield Ok(ResponseItem::AllAgentsActive(AllAgentsActive::AllAgentsActive));
201            }
202        }
203    };
204    Ok(Box::pin(out))
205}
206
207/// Deliver one AIH. The FIRST item is always the resolution:
208/// `AgentActive` (lock held by a live owner), `AgentSpawned` (lock
209/// won, spawn starting), or a setup `Err` (lock won but no prior
210/// session). On a win, the claim is preseeded into the run's
211/// [`AgentInstanceRegistry`] — released when that stream (and the
212/// registry inside it) drops, i.e. per-target, never held for the
213/// slowest.
214fn deliver_one_hierarchy(
215    ctx: Context,
216    hierarchy: String,
217) -> impl Stream<Item = Result<ResponseItem, Error>> + Send {
218    async_stream::stream! {
219        let state_dir = ctx.filesystem.state_dir();
220        let (dir, key) =
221            crate::command::agents::locks::agent_instance_lock(&state_dir, &hierarchy);
222        let Some(claim) = objectiveai_sdk::lockfile::try_acquire(&dir, &key, "").await else {
223            yield Ok(ResponseItem::AgentActive(AgentActiveResponseItem {
224                r#type: AgentActiveType::AgentActive,
225                agent_instance_hierarchy: hierarchy,
226            }));
227            return;
228        };
229
230        let pool = match ctx.db_client().await {
231            Ok(pool) => pool,
232            Err(e) => {
233                let _ = claim.release();
234                yield Err(e);
235                return;
236            }
237        };
238        let lookup = match crate::db::logs::lookup_session(pool, &hierarchy).await {
239            Ok(Some(lookup)) => lookup,
240            Ok(None) => {
241                // SDK claims don't release on drop — free the slot
242                // before bailing.
243                let _ = claim.release();
244                yield Err(Error::AgentNoPriorRequest {
245                    agent_instance_hierarchy: hierarchy,
246                });
247                return;
248            }
249            Err(e) => {
250                let _ = claim.release();
251                yield Err(e.into());
252                return;
253            }
254        };
255
256        let mut registry = AgentInstanceRegistry::new(state_dir);
257        registry.preseed(hierarchy.clone(), claim);
258
259        yield Ok(ResponseItem::AgentSpawned(AgentSpawnedResponseItem {
260            r#type: AgentSpawnedType::AgentSpawned,
261            agent_instance_hierarchy: hierarchy.clone(),
262        }));
263
264        // Empty messages: the wake-up turn exists so the agent drains
265        // its own queue (the conduit reads pending rows during the
266        // turn), same shape `run_multi_pass` itself uses on restart.
267        let params = AgentCompletionCreateParams {
268            messages: Vec::new(),
269            provider: None,
270            agent: lookup.agent,
271            response_format: None,
272            seed: None,
273            stream: Some(true),
274            continuation: lookup.continuation,
275        };
276        let inner = crate::command::agents::spawn::run_multi_pass(
277            ctx.clone(),
278            params,
279            None,
280            registry,
281        );
282        let mut inner = Box::pin(inner);
283        while let Some(item) = inner.next().await {
284            match item {
285                Ok(spawn_item) => {
286                    yield Ok(ResponseItem::Value(ValueResponseItem {
287                        agent_instance_hierarchy: hierarchy.clone(),
288                        value: Box::new(RootResponseItem::Agents(
289                            AgentsResponseItem::Spawn(spawn_item),
290                        )),
291                    }));
292                }
293                Err(e) => yield Err(e),
294            }
295        }
296    }
297}
298
299/// Deliver one un-upgraded (GROUPED) tag. The FIRST item is always
300/// the resolution: `TagActive` (tag lock held — someone else is
301/// already materializing it), `TagSpawned` (tag lock won, fresh
302/// spawn of the group's stored spec starting), or — when the tag
303/// raced to BOUND between the target listing and the lock — the
304/// delegated hierarchy flow's own resolution. The tag claim rides in
305/// the run's registry via `hold_tag_claim`: released the moment the
306/// spawn claims its minted AIH lock, held to stream end otherwise.
307/// The minted AIH arrives as the FIRST inner item (the spawn `Id`)
308/// and keys the `Value` envelopes.
309fn deliver_one_tag(
310    ctx: Context,
311    agent_tag: String,
312) -> impl Stream<Item = Result<ResponseItem, Error>> + Send {
313    async_stream::stream! {
314        let state_dir = ctx.filesystem.state_dir();
315        let (dir, key) =
316            crate::command::agents::locks::agent_tag_lock(&state_dir, &agent_tag);
317        let Some(claim) = objectiveai_sdk::lockfile::try_acquire(&dir, &key, "").await else {
318            yield Ok(ResponseItem::TagActive(TagActiveResponseItem {
319                r#type: TagActiveType::TagActive,
320                agent_tag,
321            }));
322            return;
323        };
324
325        // Re-resolve under the lock — the target list was a snapshot
326        // and the tag may have been upgraded (or deleted) since.
327        let pool = match ctx.db_client().await {
328            Ok(pool) => pool,
329            Err(e) => {
330                let _ = claim.release();
331                yield Err(e);
332                return;
333            }
334        };
335        let agent = match crate::db::tags::lookup(pool, &agent_tag).await {
336            Ok(crate::db::tags::LookupState::Grouped { agent_spec, .. }) => {
337                agent_spec
338            }
339            Ok(crate::db::tags::LookupState::Bound { agent_instance_hierarchy }) => {
340                // Raced to BOUND — the tag lock has no further job;
341                // deliver the live hierarchy instead.
342                let _ = claim.release();
343                let mut inner = Box::pin(deliver_one_hierarchy(
344                    ctx.clone(),
345                    agent_instance_hierarchy,
346                ));
347                while let Some(item) = inner.next().await {
348                    yield item;
349                }
350                return;
351            }
352            Ok(crate::db::tags::LookupState::Absent) => {
353                let _ = claim.release();
354                yield Err(Error::TagNotFound(agent_tag));
355                return;
356            }
357            Err(e) => {
358                let _ = claim.release();
359                yield Err(e.into());
360                return;
361            }
362        };
363
364        let mut registry = AgentInstanceRegistry::new(state_dir);
365        registry.hold_tag_claim(claim);
366
367        yield Ok(ResponseItem::TagSpawned(TagSpawnedResponseItem {
368            r#type: TagSpawnedType::TagSpawned,
369            agent_tag: agent_tag.clone(),
370        }));
371
372        // Fresh spawn from the group's stored spec: empty messages
373        // (the queued rows ARE the prompt, drained via the conduit),
374        // no continuation, the tag threaded in so the first conduit
375        // read flips the whole group to BOUND on the minted AIH.
376        let params = AgentCompletionCreateParams {
377            messages: Vec::new(),
378            provider: None,
379            agent,
380            response_format: None,
381            seed: None,
382            stream: Some(true),
383            continuation: None,
384        };
385        let inner = crate::command::agents::spawn::run_multi_pass(
386            ctx.clone(),
387            params,
388            Some(agent_tag),
389            registry,
390        );
391        let mut inner = Box::pin(inner);
392        // The minted AIH keys the Value envelopes; the spawn stream's
393        // first item is always its Id, so it's in hand before any
394        // chunk needs wrapping.
395        let mut minted: Option<String> = None;
396        while let Some(item) = inner.next().await {
397            match item {
398                Ok(spawn_item) => {
399                    if let SpawnResponseItem::Id(id) = &spawn_item {
400                        minted = Some(id.clone());
401                    }
402                    let aih = minted.clone().unwrap_or_default();
403                    yield Ok(ResponseItem::Value(ValueResponseItem {
404                        agent_instance_hierarchy: aih,
405                        value: Box::new(RootResponseItem::Agents(
406                            AgentsResponseItem::Spawn(spawn_item),
407                        )),
408                    }));
409                }
410                Err(e) => yield Err(e),
411            }
412        }
413    }
414}
415
416pub mod request_schema {
417    use objectiveai_sdk::cli::command::agents::queue::deliver as sdk;
418    use objectiveai_sdk::cli::command::agents::queue::deliver::request_schema::{
419        Request, Response,
420    };
421
422    use crate::context::Context;
423    use crate::error::Error;
424
425    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
426        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
427    }
428}
429
430pub mod response_schema {
431    use objectiveai_sdk::cli::command::agents::queue::deliver as sdk;
432    use objectiveai_sdk::cli::command::agents::queue::deliver::response_schema::{
433        Request, Response,
434    };
435
436    use crate::context::Context;
437    use crate::error::Error;
438
439    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
440        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::ResponseItem)))
441    }
442}