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