1use crate::backends::{ExternalBackend, ExternalId, ExternalTask};
7use crate::config;
8use crate::engine::router::{RouteResultError, get_route_result};
9use crate::store;
10use crate::store::TaskStore;
11use crate::tmux::TmuxManager;
12use std::fmt;
13use std::fmt::Debug;
14use std::fmt::Formatter;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use super::{agent, context, git_ops, worktree};
19use crate::engine::RunTimeouts;
20use crate::engine::cleanup;
21use crate::home;
22
23pub struct TaskInitResult {
25 pub agent_name: String,
26 pub model_name: Option<String>,
27 pub complexity: Option<String>,
28 pub task_title: String,
29 pub wt: worktree::WorktreeSetup,
30 pub invocation: agent::AgentInvocation,
31 pub attempt_dir: PathBuf,
32 pub new_attempts: u32,
33}
34
35impl Debug for TaskInitResult {
36 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
37 f.debug_struct("TaskInitResult")
38 .field("agent_name", &self.agent_name)
39 .field("model_name", &self.model_name)
40 .field("complexity", &self.complexity)
41 .field("task_title", &self.task_title)
42 .field("wt", &self.wt)
43 .field("attempt_dir", &self.attempt_dir)
44 .field("new_attempts", &self.new_attempts)
45 .finish_non_exhaustive()
46 }
47}
48
49#[derive(Debug)]
51pub enum GuardOutcome {
52 Proceed(u32),
54 Skip,
56 MaxAttempts,
58}
59
60pub async fn check_guards(
64 task_id: &str,
65 repo: &str,
66 store: &Option<Arc<TaskStore>>,
67) -> anyhow::Result<GuardOutcome> {
68 let attempts: u32 = store::opt_store_get_task(store, repo, task_id)
69 .await
70 .map_or(0, |t| u32::try_from(t.attempts).unwrap_or(0));
71
72 let tmux = TmuxManager::new();
74 let session_name = tmux.session_name(repo, task_id);
75 if tmux.session_blocks_dispatch(&session_name).await {
76 tracing::info!(
77 task_id,
78 session = %session_name,
79 "skipping task: tmux session already exists"
80 );
81 return Ok(GuardOutcome::Skip);
82 }
83
84 let max_attempts: u32 = config::get("workflow.max_attempts")
86 .ok()
87 .and_then(|s| s.parse().ok())
88 .unwrap_or(5);
89
90 if attempts >= max_attempts {
91 tracing::warn!(task_id, attempts, max_attempts, "exceeded max attempts");
92 let msg =
93 format!("exceeded max attempts ({attempts}/{max_attempts}). Use `/retry` to reset.");
94 if let Err(e) = store::store_set_result(
95 store,
96 repo,
97 task_id,
98 &[
99 ("last_error", serde_json::json!(msg)),
100 ("block_reason", serde_json::json!(msg)),
101 ],
102 )
103 .await
104 {
105 tracing::warn!(task_id, err = %e, "failed to write max_attempts last_error to store");
106 }
107 return Ok(GuardOutcome::MaxAttempts);
108 }
109
110 Ok(GuardOutcome::Proceed(attempts))
111}
112
113pub async fn build_pseudo_task(
115 task_id: &str,
116 store: &Option<Arc<TaskStore>>,
117 repo: &str,
118) -> ExternalTask {
119 let (task_title, task_body) = store::opt_store_get_task(store, repo, task_id)
120 .await
121 .map_or_else(
122 || (format!("Task #{task_id}"), String::new()),
123 |t| (t.title, t.body),
124 );
125 ExternalTask {
126 id: ExternalId(task_id.to_string()),
127 title: task_title,
128 body: task_body,
129 state: "open".to_string(),
130 labels: vec![],
131 author: String::new(),
132 created_at: String::new(),
133 updated_at: String::new(),
134 url: String::new(),
135 }
136}
137
138fn resolve_model_name(
141 param: Option<&str>,
142 routed: Option<&str>,
143 stored: Option<&str>,
144) -> Option<String> {
145 [param, routed, stored]
146 .into_iter()
147 .flatten()
148 .map(str::trim)
149 .find(|m| !m.is_empty())
150 .map(String::from)
151}
152
153#[expect(
155 clippy::too_many_arguments,
156 reason = "task setup needs the routing result, backend, project paths and store together"
157)]
158pub async fn prepare_task(
159 task_id: &str,
160 agent: Option<&str>,
161 model: Option<&str>,
162 backend: Option<&dyn ExternalBackend>,
163 repo: &str,
164 project_dir: &Path,
165 attempts: u32,
166 store: &Option<Arc<TaskStore>>,
167) -> anyhow::Result<TaskInitResult> {
168 let initial_task = store::opt_store_get_task(store, repo, task_id).await;
176 let title_for_branch = initial_task
177 .as_ref()
178 .map(|t| t.title.clone())
179 .unwrap_or_default();
180 let stored_model = initial_task.and_then(|t| t.model);
181
182 let wt = worktree::setup_worktree(task_id, &title_for_branch, project_dir, store, repo).await?;
184
185 git_ops::rebase_on_default(&wt.work_dir, &wt.default_branch).await;
188
189 let route_result = if let Some(s) = store {
191 match get_route_result(s, repo, task_id).await {
192 Ok(r) => Some(r),
193 Err(RouteResultError::NoAgent { .. }) => {
196 tracing::warn!(task_id, "routed task has no agent — using fallback");
197 None
198 }
199 Err(e) => {
202 tracing::error!(task_id, error = %e, "failed to get route result from store — blocking task");
203 let err_msg = format!("failed to read routing result: {e}");
205 if let Err(set_err) = store::store_set_result(
206 store,
207 repo,
208 task_id,
209 &[("last_error", serde_json::json!(err_msg.clone()))],
210 )
211 .await
212 {
213 tracing::warn!(task_id, err = %set_err, "failed to write route read error to store");
214 }
215 if let Some(s) = store {
218 let _ = cleanup::cleanup_task_worktree(task_id, repo, s).await;
219 }
220 return Err(anyhow::anyhow!(err_msg));
221 }
222 }
223 } else {
224 None
225 };
226
227 let agent_name = agent
228 .map(String::from)
229 .or_else(|| route_result.as_ref().map(|r| r.agent.clone()))
230 .unwrap_or_else(|| "claude".to_string());
231
232 let model_name = resolve_model_name(
233 model,
234 route_result.as_ref().and_then(|r| r.model.as_deref()),
235 stored_model.as_deref(),
236 );
237
238 let complexity = route_result.as_ref().map(|r| r.complexity.clone());
239
240 let pseudo_task = build_pseudo_task(task_id, store, repo).await;
242 let task_title = pseudo_task.title.clone();
243
244 let selected_skills = route_result
245 .as_ref()
246 .map(|r| r.selected_skills.clone())
247 .unwrap_or_default();
248
249 let ctx = context::build_full_context(
251 &pseudo_task,
252 backend,
253 &wt.work_dir,
254 &wt.default_branch,
255 attempts,
256 &selected_skills,
257 store,
258 repo,
259 )
260 .await;
261
262 let sandboxed = super::sandbox::enabled(&wt.main_project_dir);
263 let shipcheck = agent::Shipcheck::detect(&wt.work_dir).await;
264 agent::install_shipcheck_hooks(&wt.work_dir, shipcheck, Path::new("shipcheck")).await;
265
266 let system_prompt = agent::build_system_prompt(
268 &pseudo_task,
269 &ctx,
270 route_result.as_ref(),
271 &wt.default_branch,
272 shipcheck.for_prompt(sandboxed),
273 );
274 let agent_message = agent::build_agent_message(&pseudo_task, &ctx, attempts);
275
276 let git_name = config::get("git.name").unwrap_or_else(|_| format!("{agent_name}[bot]"));
278 let git_email = config::get("git.email")
279 .unwrap_or_else(|_| format!("{agent_name}[bot]@users.noreply.github.com"));
280
281 let next_attempt = attempts + 1;
283 let attempt_dir = match home::task_attempt_dir_async(repo, task_id, next_attempt).await {
284 Ok(dir) => dir,
285 Err(e) => {
286 if let Some(s) = store {
287 let _ = cleanup::cleanup_task_worktree(task_id, repo, s).await;
288 }
289 return Err(e);
290 }
291 };
292 let output_file = attempt_dir.join("output.json");
293
294 let mut disallowed_tools = vec![
296 "Bash(rm *)".to_string(),
297 "Bash(rm -*)".to_string(),
298 "Bash(git push*)".to_string(),
299 "Bash(gh pr create*)".to_string(),
302 ];
303 disallowed_tools.extend(git_ops::remote_change_tools());
304
305 if wt.work_dir != wt.main_project_dir {
307 let main_str = wt.main_project_dir.to_string_lossy();
308 disallowed_tools.extend([
309 format!("Bash(cd {main_str}*)"),
310 format!("Read({main_str}/*)"),
311 format!("Write({main_str}/*)"),
312 format!("Edit({main_str}/*)"),
313 ]);
314 }
315
316 if let Ok(orch_home) = home::orch_home() {
317 for path in [
318 orch_home.join("config.yml"),
319 orch_home.join("config.example.yml"),
320 ] {
321 let path_str = path.to_string_lossy();
322 disallowed_tools.extend([
323 format!("Read({path_str})"),
324 format!("Write({path_str})"),
325 format!("Edit({path_str})"),
326 ]);
327 }
328 }
329
330 {
332 let orch_yml = wt.work_dir.join(".orch.yml");
333 let orch_yml_str = orch_yml.to_string_lossy();
334 disallowed_tools.extend([
335 format!("Write({orch_yml_str})"),
336 format!("Edit({orch_yml_str})"),
337 ]);
338 }
339
340 let timeout_seconds = RunTimeouts::from_config().for_complexity(complexity.as_deref());
342
343 let invocation = agent::AgentInvocation {
345 agent: agent_name.clone(),
346 model: model_name.clone(),
347 work_dir: wt.work_dir.clone(),
348 system_prompt,
349 agent_message,
350 task_id: task_id.to_string(),
351 disallowed_tools,
352 git_author_name: git_name,
353 git_author_email: git_email,
354 output_file,
355 timeout_seconds,
356 repo: repo.to_string(),
357 attempt: next_attempt,
358 sandbox: sandboxed,
359 main_project_dir: wt.main_project_dir.clone(),
360 branch: wt.branch.clone(),
361 };
362
363 if let Err(e) = store::store_set_result(
365 store,
366 repo,
367 task_id,
368 &[("attempts", serde_json::json!(next_attempt))],
369 )
370 .await
371 {
372 if let Some(s) = store {
373 let _ = cleanup::cleanup_task_worktree(task_id, repo, s).await;
374 }
375 return Err(anyhow::anyhow!("failed to persist attempts counter: {e}"));
376 }
377
378 Ok(TaskInitResult {
379 agent_name,
380 model_name,
381 complexity,
382 task_title,
383 wt,
384 invocation,
385 attempt_dir,
386 new_attempts: next_attempt,
387 })
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use crate::store::NewTask;
394
395 #[test]
396 fn resolve_model_name_skips_a_model_cleared_to_empty() {
397 assert_eq!(resolve_model_name(None, None, Some("")), None);
398 assert_eq!(resolve_model_name(None, Some(""), Some("")), None);
399 assert_eq!(
400 resolve_model_name(None, None, Some("sonnet")).as_deref(),
401 Some("sonnet")
402 );
403 assert_eq!(
404 resolve_model_name(Some("opus"), Some("sonnet"), Some("haiku")).as_deref(),
405 Some("opus")
406 );
407 }
408
409 #[tokio::test]
410 async fn max_attempts_writes_block_reason() {
411 let task_store = Arc::new(TaskStore::open_memory().await.unwrap());
412 let store_id = task_store
413 .create(&NewTask {
414 repo: "owner/repo".to_string(),
415 origin: "internal".to_string(),
416 title: "Test".to_string(),
417 ..Default::default()
418 })
419 .await
420 .unwrap();
421 task_store
422 .set_fields(store_id, &[("attempts", serde_json::json!(1000))])
423 .await
424 .unwrap();
425 let store = Some(Arc::clone(&task_store));
426
427 let outcome = check_guards(&format!("internal:{store_id}"), "owner/repo", &store)
428 .await
429 .unwrap();
430
431 assert!(matches!(outcome, GuardOutcome::MaxAttempts));
432 let task = task_store.get(store_id).await.unwrap();
433 assert!(
434 task.block_reason
435 .is_some_and(|r| r.contains("exceeded max attempts"))
436 );
437 }
438}