vv_agent/runtime/sub_task_manager/
submission.rs1use 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}