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}