Skip to main content

netget/cli/
tasks.rs

1//! Scheduled-task execution: the 1s tick every interactive and non-interactive
2//! mode runs, which fires each due task through the model with the action
3//! vocabulary its scope allows.
4
5use tokio::sync::mpsc;
6use tokio_util::sync::CancellationToken;
7
8use crate::llm::OllamaClient;
9use crate::state::app_state::AppState;
10
11/// Execute all tasks that are due (public wrapper for non-interactive mode)
12pub async fn execute_due_tasks_public(
13    state: &AppState,
14    llm_client: &OllamaClient,
15    status_tx: &mpsc::UnboundedSender<String>,
16) {
17    execute_due_tasks(state, llm_client, status_tx, None).await
18}
19
20/// Execute due tasks owned by a service. Closing that service cancels only the
21/// executions it launched, retaining task definitions for other AppState owners.
22pub async fn execute_due_tasks_owned_public(
23    state: &AppState,
24    llm_client: &OllamaClient,
25    status_tx: &mpsc::UnboundedSender<String>,
26    owner: CancellationToken,
27) {
28    execute_due_tasks(state, llm_client, status_tx, Some(owner)).await
29}
30
31/// Execute all tasks that are due
32async fn execute_due_tasks(
33    state: &AppState,
34    llm_client: &OllamaClient,
35    status_tx: &mpsc::UnboundedSender<String>,
36    owner: Option<CancellationToken>,
37) {
38    for task in state.claim_due_tasks().await {
39        // Spawn task execution to avoid blocking
40        let state_clone = state.clone();
41        let llm_clone = llm_client.clone();
42        let status_tx_clone = status_tx.clone();
43        let task_clone = task.clone();
44        let owner = owner.clone();
45
46        state
47            .spawn_scheduled_task(task.id, async move {
48                if let Some(owner) = owner {
49                    let cancelled_state = state_clone.clone();
50                    let id = task_clone.id;
51                    tokio::select! {
52                        biased;
53                        _ = owner.cancelled() => {
54                            cancelled_state.update_task_status(id, crate::state::task::TaskStatus::Failed("execution owner closed".into())).await;
55                        }
56                        _ = execute_single_task(state_clone, llm_clone, status_tx_clone, task_clone) => {}
57                    }
58                } else {
59                    execute_single_task(state_clone, llm_clone, status_tx_clone, task_clone).await;
60                }
61            })
62            .await;
63    }
64}
65
66/// Build the list of actions a scheduled task may invoke.
67///
68/// This mirrors, scope for scope, the action list that
69/// `PromptBuilder::build_task_execution_prompt` advertises to the model, and then applies
70/// the same scripting-mode filter `PromptBuilder::build_action_prompt` applies. The result
71/// is that the set the model is told about and the set `ConversationHandler` validates
72/// against are identical rather than merely overlapping.
73///
74/// Previously an empty `Vec` was handed to the validator, so every action a scheduled task
75/// returned was flagged unknown, retried twice, then `bail!`d — no scheduled task could
76/// ever execute an action.
77async fn build_task_actions(
78    state: &AppState,
79    scope: &crate::state::task::TaskScope,
80    protocol_actions: Vec<crate::llm::actions::ActionDefinition>,
81) -> Vec<crate::llm::actions::ActionDefinition> {
82    use crate::llm::actions::{
83        get_all_tool_actions, get_network_event_common_actions, get_network_event_tool_actions,
84        get_user_input_common_actions,
85    };
86    use crate::llm::prompt::PromptBuilder;
87    use crate::state::task::TaskScope;
88
89    let selected_mode = state.get_selected_scripting_mode().await;
90    let web_search_mode = state.get_web_search_mode().await;
91
92    let actions = match scope {
93        TaskScope::Global => {
94            // Global tasks run in the user-input context, with open_server/open_client enabled.
95            let scripting_env = state.get_scripting_env().await;
96            let mut actions =
97                get_user_input_common_actions(selected_mode, &scripting_env, true, true);
98            actions.extend(get_all_tool_actions(web_search_mode));
99            actions
100        }
101        TaskScope::Server(_) | TaskScope::Connection(_, _) | TaskScope::Client(_) => {
102            // Server-, connection- and client-scoped tasks run in the network-event context.
103            let mut actions = get_network_event_common_actions();
104            actions.extend(protocol_actions);
105            actions.extend(get_network_event_tool_actions(web_search_mode));
106            actions
107        }
108    };
109
110    let has_scripting = selected_mode != crate::state::app_state::ScriptingMode::Off;
111    PromptBuilder::filter_actions_by_scripting_mode(actions, has_scripting)
112}
113
114/// Execute a single task
115async fn execute_single_task(
116    state: AppState,
117    llm_client: OllamaClient,
118    status_tx: mpsc::UnboundedSender<String>,
119    task: crate::state::ScheduledTask,
120) {
121    use crate::llm::prompt::PromptBuilder;
122    use crate::state::task::{TaskExecutionResult, TaskScope};
123
124    let _ = status_tx.send(format!("[TASK] Executing task '{}'", task.name));
125
126    // Get protocol actions if server, connection, or client-scoped
127    let protocol_actions = match &task.scope {
128        TaskScope::Server(server_id) | TaskScope::Connection(server_id, _) => {
129            if let Some(protocol_name) = state.get_protocol_name(*server_id).await {
130                if let Some(protocol) =
131                    crate::protocol::server_registry::registry().get(&protocol_name)
132                {
133                    protocol.get_sync_actions()
134                } else {
135                    Vec::new()
136                }
137            } else {
138                Vec::new()
139            }
140        }
141        TaskScope::Client(client_id) => {
142            if let Some(protocol_name) = state.get_protocol_name_for_client(*client_id).await {
143                if let Some(protocol) =
144                    crate::protocol::client_registry::CLIENT_REGISTRY.get(&protocol_name)
145                {
146                    // Clients union; servers narrow. A client has one LLM entry point, so it
147                    // cannot express an async/sync narrowing and `get_sync_actions()` alone
148                    // hides most of its vocabulary - 81 of 99 client protocols, including
149                    // `disconnect` on nearly all of them, so a scheduled task could not tell
150                    // a client to hang up. This is the same defect `call_llm_for_client` and
151                    // `action_catalog_for_pattern` each had; `client_llm_action_set` is the
152                    // one place the rule lives.
153                    crate::llm::actions::client_trait::client_llm_action_set(
154                        protocol.as_ref(),
155                        &state,
156                        None,
157                    )
158                } else {
159                    Vec::new()
160                }
161            } else {
162                Vec::new()
163            }
164        }
165        TaskScope::Global => Vec::new(),
166    };
167
168    // Actions genuinely available to this task. This MUST match the set advertised by
169    // `PromptBuilder::build_task_execution_prompt` below, because `ConversationHandler`
170    // derives `valid_action_names` from it and rejects anything else as an unknown action.
171    let task_actions = build_task_actions(&state, &task.scope, protocol_actions.clone()).await;
172
173    // Build prompt
174    let prompt = PromptBuilder::build_task_execution_prompt(&state, &task, protocol_actions).await;
175
176    // Get current model, ensuring one is selected
177    let model = match crate::llm::ensure_model_selected(
178        state.get_ollama_model().await,
179        &state.get_ollama_url().await,
180    )
181    .await
182    {
183        Ok(m) => m,
184        Err(e) => {
185            let error_msg = format!("Model selection failed: {}", e);
186            let _ = status_tx.send(format!(
187                "[ERROR] Failed to ensure model is selected for task execution: {}",
188                e
189            ));
190            // Update task status to failed
191            state
192                .update_task_status(
193                    task.id,
194                    crate::state::task::TaskStatus::Failed(error_msg.clone()),
195                )
196                .await;
197            let result = TaskExecutionResult {
198                success: false,
199                actions: Vec::new(),
200                error: Some(error_msg),
201            };
202            state.record_task_execution(task.id, &result).await;
203            return;
204        }
205    };
206
207    // Register task as conversation
208    let conversation_source = match &task.scope {
209        TaskScope::Global => crate::state::app_state::ConversationSource::Task {
210            task_name: task.name.clone(),
211        },
212        TaskScope::Server(server_id) => crate::state::app_state::ConversationSource::Task {
213            task_name: format!("{}#{}", task.name, server_id.as_u32()),
214        },
215        TaskScope::Connection(server_id, conn_id) => {
216            crate::state::app_state::ConversationSource::Task {
217                task_name: format!("{}#{}/{}", task.name, server_id.as_u32(), conn_id),
218            }
219        }
220        TaskScope::Client(client_id) => crate::state::app_state::ConversationSource::Task {
221            task_name: format!("{}@{}", task.name, client_id.as_u32()),
222        },
223    };
224
225    let truncated_instruction = crate::utils::truncate_for_log(&task.instruction, 27);
226
227    // Get rate limiter for scheduled tasks (discards if rate limited)
228    let rate_limiter = state.get_rate_limiter().await;
229
230    // Create conversation handler with tracking
231    let mut conversation = crate::llm::ConversationHandler::new(
232        prompt.clone(),
233        std::sync::Arc::new(llm_client.clone()),
234        model.clone(),
235        rate_limiter,
236        crate::llm::RequestSource::Network, // Scheduled tasks are discarded if rate limited
237    )
238    .with_native_tools(&task_actions)
239    .with_status_tx(status_tx.clone())
240    .with_tracking(state.clone(), conversation_source, truncated_instruction);
241
242    // Add empty user message to trigger generation
243    conversation.add_user_message("Execute the task.".to_string());
244
245    // Generate with conversation handler (handles tracking automatically)
246    let web_search_mode = state.get_web_search_mode().await;
247    let actions = match conversation
248        .generate_with_tools_and_retry(
249            state.get_web_approval_channel().await,
250            web_search_mode,
251            task_actions,
252        )
253        .await
254    {
255        Ok(actions) => actions,
256        Err(e) => {
257            // Execution failed
258            let error = format!("LLM call failed: {}", e);
259            let _ = status_tx.send(format!("[ERROR] Task '{}' failed: {}", task.name, error));
260
261            let result = TaskExecutionResult {
262                success: false,
263                actions: Vec::new(),
264                error: Some(error),
265            };
266
267            handle_task_failure(&state, &status_tx, task, result).await;
268            return;
269        }
270    };
271
272    // Get protocol for execution (if server, connection, or client-scoped)
273    let protocol = match &task.scope {
274        TaskScope::Server(server_id) | TaskScope::Connection(server_id, _) => state
275            .get_protocol_name(*server_id)
276            .await
277            .and_then(|name| crate::protocol::server_registry::registry().get(&name)),
278        TaskScope::Client(_client_id) => {
279            // Client protocols are handled differently - they don't use the server protocol registry
280            // For now, return None as task execution for clients needs client-specific implementation
281            None
282        }
283        TaskScope::Global => None,
284    };
285
286    // Extract server_id and client_id from task scope for context
287    let (server_id, client_id) = match &task.scope {
288        TaskScope::Server(sid) | TaskScope::Connection(sid, _) => (Some(*sid), None),
289        TaskScope::Client(cid) => (None, Some(*cid)),
290        TaskScope::Global => (None, None),
291    };
292
293    // Execute actions with task context
294    match crate::llm::execute_actions(
295        actions.clone(),
296        &state,
297        protocol.as_deref(),
298        server_id,
299        client_id,
300    )
301    .await
302    {
303        Ok(_exec_result) => {
304            // Success
305            let _ = status_tx.send(format!(
306                "[TASK] Task '{}' completed successfully",
307                task.name
308            ));
309
310            let result = TaskExecutionResult {
311                success: true,
312                actions,
313                error: None,
314            };
315
316            handle_task_success(&state, &status_tx, task, result).await;
317        }
318        Err(e) => {
319            // Execution failed
320            let error = format!("Action execution failed: {}", e);
321            let _ = status_tx.send(format!("[ERROR] Task '{}' failed: {}", task.name, error));
322
323            let result = TaskExecutionResult {
324                success: false,
325                actions,
326                error: Some(error),
327            };
328
329            handle_task_failure(&state, &status_tx, task, result).await;
330        }
331    }
332}
333
334/// Handle task success
335async fn handle_task_success(
336    state: &AppState,
337    status_tx: &mpsc::UnboundedSender<String>,
338    task: crate::state::ScheduledTask,
339    result: crate::state::TaskExecutionResult,
340) {
341    use crate::state::task::{TaskStatus, TaskType};
342
343    // Record execution
344    state.record_task_execution(task.id, &result).await;
345
346    match &task.task_type {
347        TaskType::OneShot { .. } => {
348            // One-shot task completed
349            state
350                .update_task_status(task.id, TaskStatus::Completed)
351                .await;
352            state.remove_task(task.id).await;
353            let _ = status_tx.send(format!(
354                "[TASK] One-shot task '{}' completed and removed",
355                task.name
356            ));
357        }
358        TaskType::Recurring {
359            interval_secs,
360            max_executions,
361            executions_count,
362        } => {
363            // This snapshot predates record_task_execution above. Include the run
364            // that just completed, or max_executions=1 schedules a second run.
365            if let Some(max) = max_executions {
366                if executions_count.saturating_add(1) >= *max {
367                    state
368                        .update_task_status(task.id, TaskStatus::Completed)
369                        .await;
370                    state.remove_task(task.id).await;
371                    let _ = status_tx.send(format!(
372                        "[TASK] Recurring task '{}' reached max executions ({}) and removed",
373                        task.name, max
374                    ));
375                    return;
376                }
377            }
378
379            // Schedule next execution
380            let next = match crate::state::task::checked_task_deadline(*interval_secs) {
381                Ok(next) => next,
382                Err(error) => {
383                    state
384                        .update_task_status(task.id, TaskStatus::Failed(error.to_string()))
385                        .await;
386                    let _ = status_tx.send(format!("[ERROR] Task '{}': {}", task.name, error));
387                    return;
388                }
389            };
390            state.update_task_next_execution(task.id, next).await;
391            state
392                .update_task_status(task.id, TaskStatus::Scheduled)
393                .await;
394        }
395    }
396}
397
398/// Handle task failure with exponential backoff retry
399async fn handle_task_failure(
400    state: &AppState,
401    status_tx: &mpsc::UnboundedSender<String>,
402    task: crate::state::ScheduledTask,
403    result: crate::state::TaskExecutionResult,
404) {
405    use crate::state::task::TaskStatus;
406
407    const MAX_FAILURES: u64 = 5;
408    const BACKOFF_BASE_SECS: u64 = 60; // 1 minute base backoff
409
410    // Record execution
411    state.record_task_execution(task.id, &result).await;
412
413    let failure_count = task.failure_count.saturating_add(1);
414
415    if failure_count >= MAX_FAILURES {
416        // Too many failures, disable task
417        state
418            .update_task_status(
419                task.id,
420                TaskStatus::Failed(result.error.unwrap_or_else(|| "Unknown error".to_string())),
421            )
422            .await;
423        state.remove_task(task.id).await;
424        let _ = status_tx.send(format!(
425            "[ERROR] Task '{}' failed {} times, removing from schedule",
426            task.name, MAX_FAILURES
427        ));
428    } else {
429        // Retry with exponential backoff
430        let backoff_secs = BACKOFF_BASE_SECS * 2u64.pow((failure_count - 1) as u32);
431        let next = match crate::state::task::checked_task_deadline(backoff_secs) {
432            Ok(next) => next,
433            Err(error) => {
434                state
435                    .update_task_status(task.id, TaskStatus::Failed(error.to_string()))
436                    .await;
437                return;
438            }
439        };
440
441        state.update_task_next_execution(task.id, next).await;
442        state
443            .update_task_status(task.id, TaskStatus::Scheduled)
444            .await;
445
446        let _ = status_tx.send(format!(
447            "[WARN] Task '{}' failed (attempt {}/{}), retrying in {} seconds",
448            task.name, failure_count, MAX_FAILURES, backoff_secs
449        ));
450    }
451}