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                // The AIH lock is HELD — log the failure into the
269                // agent's history (the rule), then release via the
270                // registry drop below.
271                let e = Error::AgentNoPriorRequest {
272                    agent_instance_hierarchy: hierarchy.clone(),
273                };
274                let tee = crate::db::logs::ConversationTee::spawn(
275                    ctx.filesystem.state_dir(),
276                );
277                crate::command::agents::spawn::note_error(
278                    &ctx, &tee, Some(&hierarchy), None, &e,
279                )
280                .await;
281                // `registry` drops here → releases the whole family.
282                yield Err(e);
283                return;
284            }
285            Err(e) => {
286                let e: Error = e.into();
287                let tee = crate::db::logs::ConversationTee::spawn(
288                    ctx.filesystem.state_dir(),
289                );
290                crate::command::agents::spawn::note_error(
291                    &ctx, &tee, Some(&hierarchy), None, &e,
292                )
293                .await;
294                yield Err(e);
295                return;
296            }
297        };
298
299        yield Ok(ResponseItem::AgentSpawned(AgentSpawnedResponseItem {
300            r#type: AgentSpawnedType::AgentSpawned,
301            agent_instance_hierarchy: hierarchy.clone(),
302        }));
303
304        // Empty messages: the wake-up turn exists so the agent drains its own
305        // queue (the conduit reads pending rows during the turn), same shape
306        // `run_multi_pass` itself uses on restart. `run_multi_pass` resolves
307        // this AIH's laboratory attachments internally.
308        let inner = crate::command::agents::spawn::run_multi_pass(
309            ctx.clone(),
310            Vec::new(),
311            lookup.agent,
312            None,
313            lookup.continuation,
314            None,
315            vec![crate::db::laboratory_attachments::Target::Aih(hierarchy.clone())],
316            None,
317            registry,
318        );
319        let mut inner = Box::pin(inner);
320        while let Some(item) = inner.next().await {
321            match item {
322                Ok(spawn_item) => {
323                    yield Ok(ResponseItem::Value(ValueResponseItem {
324                        agent_instance_hierarchy: hierarchy.clone(),
325                        value: Box::new(RootResponseItem::Agents(
326                            AgentsResponseItem::Spawn(spawn_item),
327                        )),
328                    }));
329                }
330                Err(e) => yield Err(e),
331            }
332        }
333    }
334}
335
336/// Deliver one un-upgraded (GROUPED) tag. The FIRST item is always
337/// the resolution: `TagActive` (tag lock held — someone else is
338/// already materializing it), `TagSpawned` (tag lock won, fresh
339/// spawn of the group's stored spec starting), or — when the tag
340/// raced to BOUND between the target listing and the lock — the
341/// delegated hierarchy flow's own resolution. The tag claim rides in
342/// the run's registry via `hold_tag_claim`: released the moment the
343/// spawn claims its minted AIH lock, held to stream end otherwise.
344/// The minted AIH arrives as the FIRST inner item (the spawn `Id`)
345/// and keys the `Value` envelopes.
346fn deliver_one_tag(
347    ctx: Context,
348    agent_tag: String,
349) -> impl Stream<Item = Result<ResponseItem, Error>> + Send {
350    async_stream::stream! {
351        let state_dir = ctx.filesystem.state_dir();
352        // Resolve the tag with a fresh lookup — the target list was a snapshot
353        // and the tag may have upgraded (or been deleted) since.
354        let pool = match ctx.db_client().await {
355            Ok(pool) => pool,
356            Err(e) => {
357                yield Err(e);
358                return;
359            }
360        };
361        let (agent, tag_group_id) = match crate::db::tags::lookup(pool, &agent_tag).await {
362            Ok(crate::db::tags::LookupState::Grouped { agent_spec, tag_group_id, .. }) => {
363                (agent_spec, tag_group_id)
364            }
365            Ok(crate::db::tags::LookupState::Bound { agent_instance_hierarchy }) => {
366                // Already upgraded to BOUND — deliver the live hierarchy instead.
367                let mut inner = Box::pin(deliver_one_hierarchy(
368                    ctx.clone(),
369                    agent_instance_hierarchy,
370                ));
371                while let Some(item) = inner.next().await {
372                    yield item;
373                }
374                return;
375            }
376            Ok(crate::db::tags::LookupState::Absent) => {
377                yield Err(Error::TagNotFound(agent_tag));
378                return;
379            }
380            Err(e) => {
381                yield Err(e.into());
382                return;
383            }
384        };
385
386        // Acquire every tag in the group (all-or-nothing) — they upgrade
387        // together, so a live spawn of any of them must hold all of them. A held
388        // member ⇒ already being materialized.
389        let registry = match crate::command::agents::locks::try_acquire_family(
390            ctx.agent_locks(),
391            pool,
392            &state_dir,
393            crate::command::agents::locks::Family::Group(tag_group_id),
394        )
395        .await
396        {
397            Ok(Some(fam)) => {
398                let mut registry = AgentInstanceRegistry::new(state_dir, ctx.agent_locks_arc());
399                registry.hold_tag_claims(fam.tags);
400                registry
401            }
402            Ok(None) => {
403                yield Ok(ResponseItem::TagActive(TagActiveResponseItem {
404                    r#type: TagActiveType::TagActive,
405                    agent_tag,
406                }));
407                return;
408            }
409            Err(e) => {
410                yield Err(e);
411                return;
412            }
413        };
414
415        yield Ok(ResponseItem::TagSpawned(TagSpawnedResponseItem {
416            r#type: TagSpawnedType::TagSpawned,
417            agent_tag: agent_tag.clone(),
418        }));
419
420        // Fresh spawn from the group's stored spec: empty messages (the queued
421        // rows ARE the prompt, drained via the conduit), no continuation, the
422        // tag threaded in so the first conduit read flips the whole group to
423        // BOUND on the minted AIH. `run_multi_pass` resolves this tag's
424        // laboratory attachments internally. (Compute the lab target before the
425        // call so `agent_tag` isn't moved by the `Some(agent_tag)` arg first.)
426        let lab_targets = vec![crate::db::laboratory_attachments::Target::Tag(agent_tag.clone())];
427        let inner = crate::command::agents::spawn::run_multi_pass(
428            ctx.clone(),
429            Vec::new(),
430            agent,
431            None,
432            None,
433            Some(agent_tag),
434            lab_targets,
435            None,
436            registry,
437        );
438        let mut inner = Box::pin(inner);
439        // The minted AIH keys the Value envelopes; the spawn stream's
440        // first item is always its Id, so it's in hand before any
441        // chunk needs wrapping.
442        let mut minted: Option<String> = None;
443        while let Some(item) = inner.next().await {
444            match item {
445                Ok(spawn_item) => {
446                    if let SpawnResponseItem::Id(id) = &spawn_item {
447                        minted = Some(id.clone());
448                    }
449                    let aih = minted.clone().unwrap_or_default();
450                    yield Ok(ResponseItem::Value(ValueResponseItem {
451                        agent_instance_hierarchy: aih,
452                        value: Box::new(RootResponseItem::Agents(
453                            AgentsResponseItem::Spawn(spawn_item),
454                        )),
455                    }));
456                }
457                Err(e) => yield Err(e),
458            }
459        }
460    }
461}
462
463pub mod request_schema {
464    use objectiveai_sdk::cli::command::agents::queue::deliver as sdk;
465    use objectiveai_sdk::cli::command::agents::queue::deliver::request_schema::{
466        Request, Response,
467    };
468
469    use crate::context::Context;
470    use crate::error::Error;
471
472    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
473        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
474    }
475}
476
477pub mod response_schema {
478    use objectiveai_sdk::cli::command::agents::queue::deliver as sdk;
479    use objectiveai_sdk::cli::command::agents::queue::deliver::response_schema::{
480        Request, Response,
481    };
482
483    use crate::context::Context;
484    use crate::error::Error;
485
486    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
487        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::ResponseItem)))
488    }
489}