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}