Skip to main content

vv_agent/runtime/sub_task_manager/
submission.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::panic::{catch_unwind, AssertUnwindSafe};
4use std::sync::Arc;
5use std::thread;
6
7use crate::types::{AgentStatus, SubTaskOutcome};
8use crate::workspace::WorkspaceBackend;
9
10use super::helpers::{normalize_failed_outcome, now_iso, panic_payload_to_string};
11use super::manager::SubTaskManager;
12use super::record::ManagedSubTask;
13use super::types::{SubTaskLineage, SubTaskSubmissionContext};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub(crate) enum SubTaskSubmitError {
17    AlreadyRunning { task_id: String },
18    SpawnFailed { task_id: String, error: String },
19}
20
21impl SubTaskSubmitError {
22    pub(crate) fn error_code(&self) -> &'static str {
23        match self {
24            Self::AlreadyRunning { .. } => "sub_task_already_running",
25            Self::SpawnFailed { .. } => "sub_task_submit_failed",
26        }
27    }
28}
29
30impl fmt::Display for SubTaskSubmitError {
31    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            Self::AlreadyRunning { task_id } => {
34                write!(formatter, "Sub-task {task_id} is already running.")
35            }
36            Self::SpawnFailed { task_id, error } => {
37                write!(
38                    formatter,
39                    "Sub-task {task_id} thread failed to spawn: {error}"
40                )
41            }
42        }
43    }
44}
45
46impl SubTaskManager {
47    pub fn submit(
48        &self,
49        task_id: impl Into<String>,
50        session_id: impl Into<String>,
51        agent_name: impl Into<String>,
52        task_title: impl Into<String>,
53        runner: impl FnOnce() -> SubTaskOutcome + Send + 'static,
54    ) -> Result<(), String> {
55        self.submit_with_workspace(task_id, session_id, agent_name, task_title, None, runner)
56    }
57
58    pub fn submit_with_workspace(
59        &self,
60        task_id: impl Into<String>,
61        session_id: impl Into<String>,
62        agent_name: impl Into<String>,
63        task_title: impl Into<String>,
64        workspace_backend: Option<Arc<dyn WorkspaceBackend>>,
65        runner: impl FnOnce() -> SubTaskOutcome + Send + 'static,
66    ) -> Result<(), String> {
67        self.submit_with_context(
68            task_id,
69            session_id,
70            agent_name,
71            task_title,
72            SubTaskSubmissionContext {
73                workspace_backend,
74                lineage: SubTaskLineage::default(),
75            },
76            runner,
77        )
78    }
79
80    pub fn submit_with_context(
81        &self,
82        task_id: impl Into<String>,
83        session_id: impl Into<String>,
84        agent_name: impl Into<String>,
85        task_title: impl Into<String>,
86        context: SubTaskSubmissionContext,
87        runner: impl FnOnce() -> SubTaskOutcome + Send + 'static,
88    ) -> Result<(), String> {
89        self.submit_with_context_detailed(
90            task_id, session_id, agent_name, task_title, context, runner,
91        )
92        .map_err(|error| error.to_string())
93    }
94
95    pub(crate) fn submit_with_context_detailed(
96        &self,
97        task_id: impl Into<String>,
98        session_id: impl Into<String>,
99        agent_name: impl Into<String>,
100        task_title: impl Into<String>,
101        context: SubTaskSubmissionContext,
102        runner: impl FnOnce() -> SubTaskOutcome + Send + 'static,
103    ) -> Result<(), SubTaskSubmitError> {
104        let SubTaskSubmissionContext {
105            workspace_backend,
106            lineage,
107        } = context;
108        let task_id = task_id.into();
109        let session_id = session_id.into();
110        let agent_name = agent_name.into();
111        let task_title = task_title.into();
112        let mut task_records = self.tasks.lock().expect("sub-task manager poisoned");
113        if task_records
114            .get(&task_id)
115            .is_some_and(ManagedSubTask::is_running)
116        {
117            return Err(SubTaskSubmitError::AlreadyRunning { task_id });
118        }
119        let previous_record = task_records.insert(
120            task_id.clone(),
121            ManagedSubTask {
122                task_id: task_id.clone(),
123                session_id: session_id.clone(),
124                agent_name: agent_name.clone(),
125                task_title,
126                workspace_backend,
127                session: None,
128                outcome: None,
129                resolved: BTreeMap::new(),
130                current_cycle_index: None,
131                recent_activity: None,
132                latest_cycle: None,
133                latest_tool_call: None,
134                parent_run_id: lineage.parent_run_id,
135                parent_tool_call_id: lineage.parent_tool_call_id,
136                running: true,
137                worker_owned_run: true,
138                handle: None,
139                updated_at: now_iso(),
140                session_generation: 0,
141                manager_listener_generation: None,
142            },
143        );
144
145        let tasks = self.tasks.clone();
146        let task_id_for_thread = task_id.clone();
147        let spawn_result = catch_unwind(AssertUnwindSafe(|| {
148            thread::Builder::new()
149                .name(format!("vv-agent-sub-task-{session_id}"))
150                .spawn(move || {
151                    let outcome = catch_unwind(AssertUnwindSafe(runner));
152                    let mut tasks = tasks.lock().expect("sub-task manager poisoned");
153                    if let Some(record) = tasks.get_mut(&task_id_for_thread) {
154                        let outcome = normalize_failed_outcome(match outcome {
155                            Ok(outcome) => outcome,
156                            Err(payload) => SubTaskOutcome {
157                                task_id: record.task_id.clone(),
158                                agent_name: record.agent_name.clone(),
159                                status: AgentStatus::Failed,
160                                session_id: Some(record.session_id.clone()),
161                                final_answer: None,
162                                wait_reason: None,
163                                error: Some(panic_payload_to_string(payload.as_ref())),
164                                error_code: Some("sub_task_failed".to_string()),
165                                completion_reason: Some(crate::types::CompletionReason::Failed),
166                                completion_tool_name: None,
167                                partial_output: None,
168                                cycles: 0,
169                                todo_list: Vec::new(),
170                                resolved: record.resolved.clone(),
171                            },
172                        });
173                        if !outcome.resolved.is_empty() {
174                            record.resolved = outcome.resolved.clone();
175                        }
176                        record.update_from_outcome(&outcome);
177                        record.outcome = Some(outcome);
178                        record.updated_at = now_iso();
179                        record.running = false;
180                        record.worker_owned_run = false;
181                    }
182                })
183        }));
184        let handle = match spawn_result {
185            Ok(Ok(handle)) => handle,
186            Ok(Err(error)) => {
187                restore_after_spawn_failure(&mut task_records, &task_id, previous_record);
188                return Err(SubTaskSubmitError::SpawnFailed {
189                    task_id,
190                    error: error.to_string(),
191                });
192            }
193            Err(payload) => {
194                let error = panic_payload_to_string(payload.as_ref());
195                restore_after_spawn_failure(&mut task_records, &task_id, previous_record);
196                return Err(SubTaskSubmitError::SpawnFailed { task_id, error });
197            }
198        };
199
200        if let Some(record) = task_records.get_mut(&task_id) {
201            record.handle = Some(handle);
202            record.updated_at = now_iso();
203        }
204        Ok(())
205    }
206
207    pub fn record_outcome(&self, task_id: &str, outcome: SubTaskOutcome) {
208        self.record_outcome_with_context(task_id, outcome, None, SubTaskLineage::default());
209    }
210
211    pub fn record_outcome_with_context(
212        &self,
213        task_id: &str,
214        outcome: SubTaskOutcome,
215        workspace_backend: Option<Arc<dyn WorkspaceBackend>>,
216        lineage: SubTaskLineage,
217    ) {
218        let outcome = normalize_failed_outcome(outcome);
219        let mut tasks = self.tasks.lock().expect("sub-task manager poisoned");
220        let task_id = task_id.to_string();
221        match tasks.get_mut(&task_id) {
222            Some(record) => {
223                if workspace_backend.is_some() {
224                    record.workspace_backend = workspace_backend;
225                }
226                if lineage.parent_run_id.is_some() {
227                    record.parent_run_id = lineage.parent_run_id;
228                }
229                if lineage.parent_tool_call_id.is_some() {
230                    record.parent_tool_call_id = lineage.parent_tool_call_id;
231                }
232                record.session_id = outcome
233                    .session_id
234                    .clone()
235                    .unwrap_or_else(|| record.session_id.clone());
236                record.agent_name = outcome.agent_name.clone();
237                if !outcome.resolved.is_empty() {
238                    record.resolved = outcome.resolved.clone();
239                }
240                if record.running && record.worker_owned_run {
241                    record.updated_at = now_iso();
242                    return;
243                }
244                record.running = false;
245                record.worker_owned_run = false;
246                record.update_from_outcome(&outcome);
247                record.outcome = Some(outcome);
248                record.updated_at = now_iso();
249            }
250            None => {
251                let mut record = ManagedSubTask {
252                    session_id: outcome.session_id.clone().unwrap_or_default(),
253                    agent_name: outcome.agent_name.clone(),
254                    task_title: String::new(),
255                    workspace_backend,
256                    session: None,
257                    outcome: None,
258                    resolved: outcome.resolved.clone(),
259                    current_cycle_index: None,
260                    recent_activity: None,
261                    latest_cycle: None,
262                    latest_tool_call: None,
263                    parent_run_id: lineage.parent_run_id,
264                    parent_tool_call_id: lineage.parent_tool_call_id,
265                    task_id: task_id.clone(),
266                    running: false,
267                    worker_owned_run: false,
268                    handle: None,
269                    updated_at: now_iso(),
270                    session_generation: 0,
271                    manager_listener_generation: None,
272                };
273                record.update_from_outcome(&outcome);
274                record.outcome = Some(outcome);
275                tasks.insert(task_id.clone(), record);
276            }
277        }
278    }
279}
280
281fn restore_after_spawn_failure(
282    task_records: &mut BTreeMap<String, ManagedSubTask>,
283    task_id: &str,
284    previous_record: Option<ManagedSubTask>,
285) {
286    task_records.remove(task_id);
287    if let Some(previous_record) = previous_record {
288        task_records.insert(task_id.to_string(), previous_record);
289    }
290}