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