Skip to main content

objectiveai_cli/command/tasks/
run.rs

1//! `agents tasks run` — fire every pending schedule in the caller's
2//! own subtree, recording a run row and a full output log per firing.
3//!
4//! Scope is fixed to the caller's own AIH
5//! (`ctx.config.agent_instance_hierarchy`) plus every descendant.
6//! `db::tasks::claim_pending` atomically selects the pending rows AND
7//! inserts their `tasks_runs` rows (advisory-locked, so concurrent
8//! `tasks run` callers never double-fire a schedule); each claimed
9//! row's stored argv is then dispatched through the root `crate::run`
10//! — the same entry the binary and `plugins run`'s nested-command path
11//! use — in parallel. Per-task streams are merged via
12//! [`futures::stream::SelectAll`]; each item is wrapped with the
13//! source schedule's identity (`name` / `aih` / `version` / `plugin`).
14//!
15//! A log-writer listener is spawned at the top of `execute` with an
16//! unbounded receiver; the log wrapper (layer 1, identical in both
17//! modes) sends every envelope (with its claim's `run_id`, which rides
18//! next to the item internally and never hits the wire) into the
19//! channel, and the listener appends each to `tasks_logs` as it
20//! arrives. After the merge is exhausted the wrapper drops the sender
21//! and awaits the listener so every log line is durably written before
22//! the stream ends.
23//!
24//! Output has two modes (layer 2), selected by
25//! `request.dangerous_advanced.stream_all`: when set, every envelope
26//! passes through verbatim (`ResponseItem::Value`); the default engages
27//! the success layer instead — items are swallowed and each task yields
28//! exactly one
29//! `ResponseItem::Success { .., success }` when its stream completes,
30//! `success` being `false` iff the task's final item was an error.
31//! The mode never affects what is logged.
32//!
33//! Each task runs with the schedule's captured identity
34//! (`apply_agent_arguments`) and the plugin that registered it
35//! (`apply_plugin`) re-installed on the run ctx — both
36//! `config.plugin_*` and `ctx.plugin`.
37//!
38//! Pre-stream `Err`s (e.g. the scheduled command failed to parse) are
39//! re-emitted as a single-item error stream in the merged output.
40
41use std::pin::Pin;
42
43use futures::{Stream, StreamExt};
44use objectiveai_sdk::cli::command::AgentArguments;
45use std::collections::HashMap;
46
47use objectiveai_sdk::cli::command::tasks::run::{
48    Plugin, Request, ResponseItem, RunValue, SuccessResponseItem, ValueResponseItem,
49};
50
51use crate::RunStream;
52use crate::context::Context;
53use crate::db;
54use crate::error::Error;
55
56type ItemStream = Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>>;
57
58/// Internal merge event. Items are tagged with their claim's `run_id`
59/// so the log wrapper can hand every line to the log writer — the id
60/// rides next to the item and never appears on the wire. `Done` marks
61/// a task's stream completing (carrying the meta the success layer
62/// needs); `WriterFailed` carries the log-writer finalizer's error
63/// through the layers.
64enum TaskEvent {
65    Item(i64, Result<ResponseItem, Error>),
66    Done(TaskMeta),
67    WriterFailed(Error),
68}
69
70type TaggedStream = Pin<Box<dyn Stream<Item = TaskEvent> + Send>>;
71
72pub async fn execute(ctx: &Context, request: Request) -> Result<ItemStream, Error> {
73    // The log-writer listener: spawned up front with an unbounded
74    // receiver. It appends each (run_id, line) to `tasks_logs` as they
75    // come in, and exits once every sender has dropped.
76    let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel::<(i64, String)>();
77    let writer = tokio::spawn(log_writer_loop(ctx.db_client().await?.clone(), log_rx));
78
79    // Scope is the caller's own AIH plus all descendants — no params.
80    let parent = ctx.config.agent_instance_hierarchy.clone();
81
82    let rows = db::tasks::claim_pending(ctx.db_client().await?, &parent).await?;
83
84    if rows.is_empty() {
85        // Nothing claimed → nothing to log. `log_tx` drops here and
86        // the writer exits on its own.
87        return Ok(Box::pin(futures::stream::empty()));
88    }
89
90    // Kick all per-task dispatches off in parallel. Each future
91    // resolves to `(TaskMeta, Result<RunStream, Error>)` — the meta
92    // is cloned out of the `RunRow` before `run_one` consumes it, and
93    // tags every emitted item below. Pre-stream errors (parse /
94    // handler-rejection) are folded into the merged stream as one-item
95    // error streams.
96    let starts = rows.into_iter().map(|row| {
97        let ctx = ctx.clone();
98        async move {
99            let meta = TaskMeta {
100                run_id: row.run_id,
101                agent_instance_hierarchy: row.agent_instance_hierarchy.clone(),
102                name: row.name.clone(),
103                version: row.version as u64,
104                plugin: row.plugin.clone().map(|p| Plugin {
105                    owner: p.owner,
106                    repository: p.repository,
107                    version: p.version,
108                }),
109            };
110            let stream_result = run_one(&ctx, row).await;
111            (meta, stream_result)
112        }
113    });
114    let results = futures::future::join_all(starts).await;
115
116    let mut select_all = futures::stream::SelectAll::new();
117    for (meta, result) in results {
118        let run_id = meta.run_id;
119        // Each task's stream ends with a `Done` marker carrying its
120        // meta, so the success layer can tell when a task completed.
121        let done = meta.clone();
122        match result {
123            // Typed (no transform): wrap each root item as an
124            // `ExecuteValue`.
125            Ok(RunStream::Execute(stream)) => {
126                let tagged = stream
127                    .map(move |r| {
128                        TaskEvent::Item(
129                            run_id,
130                            r.map(|value| {
131                                ResponseItem::Value(ValueResponseItem {
132                                    agent_instance_hierarchy: meta
133                                        .agent_instance_hierarchy
134                                        .clone(),
135                                    name: meta.name.clone(),
136                                    version: meta.version,
137                                    plugin: meta.plugin.clone(),
138                                    value: RunValue::ExecuteValue(Box::new(value)),
139                                })
140                            }),
141                        )
142                    })
143                    .chain(futures::stream::once(async move {
144                        TaskEvent::Done(done)
145                    }));
146                select_all.push(Box::pin(tagged) as TaggedStream);
147            }
148            // Transformed: wrap each post-transform JSON as an
149            // `ExecuteTransformValue`.
150            Ok(RunStream::ExecuteTransform(stream)) => {
151                let tagged = stream
152                    .map(move |r| {
153                        TaskEvent::Item(
154                            run_id,
155                            r.map(|output| {
156                                ResponseItem::Value(ValueResponseItem {
157                                    agent_instance_hierarchy: meta
158                                        .agent_instance_hierarchy
159                                        .clone(),
160                                    name: meta.name.clone(),
161                                    version: meta.version,
162                                    plugin: meta.plugin.clone(),
163                                    value: RunValue::ExecuteTransformValue(output),
164                                })
165                            }),
166                        )
167                    })
168                    .chain(futures::stream::once(async move {
169                        TaskEvent::Done(done)
170                    }));
171                select_all.push(Box::pin(tagged) as TaggedStream);
172            }
173            Err(e) => {
174                let tagged = futures::stream::once(async move {
175                    TaskEvent::Item(run_id, Err(e))
176                })
177                .chain(futures::stream::once(async move {
178                    TaskEvent::Done(done)
179                }));
180                select_all.push(Box::pin(tagged) as TaggedStream);
181            }
182        }
183    }
184
185    // Layer 1 — the log wrapper, identical regardless of mode: send
186    // each Ok envelope (serialized, keyed by its run_id) to the log
187    // writer and pass every event through. Once the merge is
188    // exhausted, drop the sender and wait for the listener to finish
189    // draining its queue so every line is durably in `tasks_logs`
190    // before the stream ends; a writer failure flows on as a trailing
191    // event.
192    let logged: TaggedStream = Box::pin(async_stream::stream! {
193        let mut merged = select_all;
194        while let Some(event) = merged.next().await {
195            if let TaskEvent::Item(run_id, Ok(envelope)) = &event {
196                if let Ok(line) = serde_json::to_string(envelope) {
197                    // A dead writer (an earlier insert failed) must
198                    // not stop the user-facing stream — its error
199                    // surfaces from the join below.
200                    let _ = log_tx.send((*run_id, line));
201                }
202            }
203            yield event;
204        }
205        drop(log_tx);
206        match writer.await {
207            Ok(Ok(())) => {}
208            Ok(Err(e)) => yield TaskEvent::WriterFailed(e),
209            Err(_) => yield TaskEvent::WriterFailed(Error::WriterPanic),
210        }
211    });
212
213    // Layer 2 — the mode adapter. `stream_all` skips the success layer
214    // entirely: items pass through verbatim and the Done markers vanish.
215    // The default engages the success layer: items are swallowed (each
216    // run's final-item error-ness tracked) and every task yields exactly
217    // one `{.., success}` summary on its Done. `stream_all` is gated
218    // behind `dangerous_advanced` — it can bloat context astronomically.
219    let stream_all = request
220        .dangerous_advanced
221        .as_ref()
222        .and_then(|a| a.stream_all)
223        .unwrap_or(false);
224    let stream: ItemStream = if stream_all {
225        Box::pin(logged.filter_map(|event| async move {
226            match event {
227                TaskEvent::Item(_, item) => Some(item),
228                TaskEvent::Done(_) => None,
229                TaskEvent::WriterFailed(e) => Some(Err(e)),
230            }
231        }))
232    } else {
233        Box::pin(async_stream::stream! {
234            let mut last_err: HashMap<i64, bool> = HashMap::new();
235            let mut inner = logged;
236            while let Some(event) = inner.next().await {
237                match event {
238                    TaskEvent::Item(run_id, item) => {
239                        last_err.insert(run_id, item.is_err());
240                    }
241                    TaskEvent::Done(meta) => {
242                        let success =
243                            !last_err.get(&meta.run_id).copied().unwrap_or(false);
244                        yield Ok(ResponseItem::Success(SuccessResponseItem {
245                            agent_instance_hierarchy: meta.agent_instance_hierarchy,
246                            name: meta.name,
247                            version: meta.version,
248                            plugin: meta.plugin,
249                            success,
250                        }));
251                    }
252                    TaskEvent::WriterFailed(e) => yield Err(e),
253                }
254            }
255        })
256    };
257
258    Ok(stream)
259}
260
261/// Drain the log channel into `tasks_logs` — one INSERT per emitted
262/// item, written as they come in. Returns once every sender has
263/// dropped and the queue is empty; a failed INSERT exits early and the
264/// error surfaces as the run stream's trailing item.
265async fn log_writer_loop(
266    pool: crate::db::Pool,
267    mut rx: tokio::sync::mpsc::UnboundedReceiver<(i64, String)>,
268) -> Result<(), Error> {
269    while let Some((run_id, value)) = rx.recv().await {
270        db::tasks::insert_task_log(&pool, run_id, &value).await?;
271    }
272    Ok(())
273}
274
275/// Envelope metadata for one fired schedule, cloned out of its
276/// `RunRow` before `run_one` consumes the row, then stamped onto every
277/// item that task emits (and onto its terminal `Done` marker, which
278/// the success layer turns into the per-task summary). `run_id` tags
279/// items internally for the log writer and never appears on the wire
280/// envelope.
281#[derive(Clone)]
282struct TaskMeta {
283    run_id: i64,
284    agent_instance_hierarchy: String,
285    name: String,
286    version: u64,
287    plugin: Option<Plugin>,
288}
289
290/// Dispatch one stored schedule through the root `crate::run` — the
291/// same entry `main.rs` and `plugins run`'s nested-command path use —
292/// against a ctx carrying the schedule's captured identity and the
293/// plugin that registered it. `crate::run` parses the argv itself, so
294/// we prepend a placeholder program name (it strips `argv[0]`). The
295/// returned [`RunStream`] tells us whether the scheduled command was
296/// typed or transformed; the caller tags each item accordingly.
297async fn run_one(ctx: &Context, row: db::tasks::RunRow) -> Result<RunStream, Error> {
298    let mut task_ctx = apply_agent_arguments(ctx, &row.agent_arguments);
299    apply_plugin(&mut task_ctx, row.plugin);
300
301    let mut args = vec!["objectiveai-cli".to_string()];
302    args.extend(row.command);
303    crate::run(args, Some(task_ctx)).await
304}
305
306/// Clone `ctx` and overwrite the seven identity fields on its
307/// `Config` from the schedule's saved `AgentArguments`. Mirrors
308/// `CliCommandExecutor::resolve_ctx`'s Some-arm — including the
309/// `"UNKNOWN"` fallback for the non-nullable
310/// `agent_instance_hierarchy` field and the API-client cell detach
311/// (the memoized `HttpClient` must rebuild with the task's identity
312/// headers).
313fn apply_agent_arguments(ctx: &Context, args: &AgentArguments) -> Context {
314    let mut ctx = ctx.clone();
315    ctx.config.agent_instance_hierarchy = args
316        .agent_instance_hierarchy
317        .clone()
318        .unwrap_or_else(|| "UNKNOWN".to_string());
319    ctx.config.agent_id = args.agent_id.clone();
320    ctx.config.agent_full_id = args.agent_full_id.clone();
321    ctx.config.agent_remote = args.agent_remote.clone();
322    ctx.config.response_id = args.response_id.clone();
323    ctx.config.response_ids = args.response_ids.clone();
324    ctx.config.mcp_session_id = args.mcp_session_id.clone();
325    ctx.reset_api_client();
326    ctx
327}
328
329/// Re-install the schedule's registering plugin (if any) on the run
330/// ctx — both `config.plugin_*` (so any subprocess the task spawns
331/// inherits it via `apply_config_env`) and `ctx.plugin`. `None`
332/// overrides whatever plugin the *caller* was running under, so a task
333/// scheduled by a non-plugin never inherits one.
334fn apply_plugin(ctx: &mut Context, plugin: Option<crate::plugin_path::PluginPath>) {
335    match plugin {
336        Some(p) => {
337            ctx.config.plugin_owner = Some(p.owner.clone());
338            ctx.config.plugin_repository = Some(p.repository.clone());
339            ctx.config.plugin_version = Some(p.version.clone());
340            ctx.plugin = Some(p);
341        }
342        None => {
343            ctx.config.plugin_owner = None;
344            ctx.config.plugin_repository = None;
345            ctx.config.plugin_version = None;
346            ctx.plugin = None;
347        }
348    }
349}
350
351pub mod request_schema {
352    use objectiveai_sdk::cli::command::tasks::run as sdk;
353    use objectiveai_sdk::cli::command::tasks::run::request_schema::{
354        Request, Response,
355    };
356
357    use crate::context::Context;
358    use crate::error::Error;
359
360    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
361        Ok(objectiveai_sdk::cli::command::ResponseSchema(
362            schemars::schema_for!(sdk::Request),
363        ))
364    }
365}
366
367pub mod response_schema {
368    use objectiveai_sdk::cli::command::tasks::run as sdk;
369    use objectiveai_sdk::cli::command::tasks::run::response_schema::{
370        Request, Response,
371    };
372
373    use crate::context::Context;
374    use crate::error::Error;
375
376    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
377        Ok(objectiveai_sdk::cli::command::ResponseSchema(
378            schemars::schema_for!(sdk::ResponseItem),
379        ))
380    }
381}