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::RouterConfig;
20use crate::engine::RunTimeouts;
21use crate::engine::cleanup;
22use crate::home;
23
24pub struct TaskInitResult {
26 pub agent_name: String,
27 pub model_name: Option<String>,
28 pub complexity: Option<String>,
29 pub task_title: String,
30 pub wt: worktree::WorktreeSetup,
31 pub invocation: agent::AgentInvocation,
32 pub attempt_dir: PathBuf,
33 pub new_attempts: u32,
34}
35
36impl Debug for TaskInitResult {
37 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
38 f.debug_struct("TaskInitResult")
39 .field("agent_name", &self.agent_name)
40 .field("model_name", &self.model_name)
41 .field("complexity", &self.complexity)
42 .field("task_title", &self.task_title)
43 .field("wt", &self.wt)
44 .field("attempt_dir", &self.attempt_dir)
45 .field("new_attempts", &self.new_attempts)
46 .finish_non_exhaustive()
47 }
48}
49
50#[derive(Debug)]
52pub enum GuardOutcome {
53 Proceed(u32),
55 Skip,
57 MaxAttempts,
59}
60
61pub async fn check_guards(
65 task_id: &str,
66 repo: &str,
67 store: &Option<Arc<TaskStore>>,
68) -> anyhow::Result<GuardOutcome> {
69 let attempts: u32 = store::opt_store_get_task(store, repo, task_id)
70 .await
71 .map_or(0, |t| u32::try_from(t.attempts).unwrap_or(0));
72
73 let tmux = TmuxManager::new();
75 let session_name = tmux.session_name(repo, task_id);
76 if tmux.session_blocks_dispatch(&session_name).await {
77 tracing::info!(
78 task_id,
79 session = %session_name,
80 "skipping task: tmux session already exists"
81 );
82 return Ok(GuardOutcome::Skip);
83 }
84
85 let max_attempts: u32 = config::get("workflow.max_attempts")
87 .ok()
88 .and_then(|s| s.parse().ok())
89 .unwrap_or(5);
90
91 if attempts >= max_attempts {
92 tracing::warn!(task_id, attempts, max_attempts, "exceeded max attempts");
93 let msg =
94 format!("exceeded max attempts ({attempts}/{max_attempts}). Use `/retry` to reset.");
95 if let Err(e) = store::store_set_result(
96 store,
97 repo,
98 task_id,
99 &[
100 ("last_error", serde_json::json!(msg)),
101 ("block_reason", serde_json::json!(msg)),
102 ],
103 )
104 .await
105 {
106 tracing::warn!(task_id, err = %e, "failed to write max_attempts last_error to store");
107 }
108 return Ok(GuardOutcome::MaxAttempts);
109 }
110
111 Ok(GuardOutcome::Proceed(attempts))
112}
113
114pub async fn build_pseudo_task(
116 task_id: &str,
117 store: &Option<Arc<TaskStore>>,
118 repo: &str,
119) -> ExternalTask {
120 let (task_title, task_body) = store::opt_store_get_task(store, repo, task_id)
121 .await
122 .map_or_else(
123 || (format!("Task #{task_id}"), String::new()),
124 |t| (t.title, t.body),
125 );
126 ExternalTask {
127 id: ExternalId(task_id.to_string()),
128 title: task_title,
129 body: task_body,
130 state: "open".to_string(),
131 labels: vec![],
132 author: String::new(),
133 created_at: String::new(),
134 updated_at: String::new(),
135 url: String::new(),
136 }
137}
138
139fn resolve_model_name(
142 param: Option<&str>,
143 routed: Option<&str>,
144 stored: Option<&str>,
145) -> Option<String> {
146 [param, routed, stored]
147 .into_iter()
148 .flatten()
149 .map(str::trim)
150 .find(|m| !m.is_empty())
151 .map(String::from)
152}
153
154#[expect(
156 clippy::too_many_arguments,
157 reason = "task setup needs the routing result, backend, project paths and store together"
158)]
159pub async fn prepare_task(
160 task_id: &str,
161 agent: Option<&str>,
162 model: Option<&str>,
163 backend: Option<&dyn ExternalBackend>,
164 repo: &str,
165 project_dir: &Path,
166 attempts: u32,
167 store: &Option<Arc<TaskStore>>,
168) -> anyhow::Result<TaskInitResult> {
169 let initial_task = store::opt_store_get_task(store, repo, task_id).await;
177 let title_for_branch = initial_task
178 .as_ref()
179 .map(|t| t.title.clone())
180 .unwrap_or_default();
181 let stored_model = initial_task.and_then(|t| t.model);
182
183 let wt = worktree::setup_worktree(task_id, &title_for_branch, project_dir, store, repo).await?;
185
186 git_ops::rebase_on_default(&wt.work_dir, &wt.default_branch).await;
189
190 let route_result = if let Some(s) = store {
192 match get_route_result(s, repo, task_id).await {
193 Ok(r) => Some(r),
194 Err(RouteResultError::NoAgent { .. }) => {
197 tracing::warn!(task_id, "routed task has no agent — using fallback");
198 None
199 }
200 Err(e) => {
203 tracing::error!(task_id, error = %e, "failed to get route result from store — blocking task");
204 let err_msg = format!("failed to read routing result: {e}");
206 if let Err(set_err) = store::store_set_result(
207 store,
208 repo,
209 task_id,
210 &[("last_error", serde_json::json!(err_msg.clone()))],
211 )
212 .await
213 {
214 tracing::warn!(task_id, err = %set_err, "failed to write route read error to store");
215 }
216 if let Some(s) = store {
219 let _ = cleanup::cleanup_task_worktree(task_id, repo, s).await;
220 }
221 return Err(anyhow::anyhow!(err_msg));
222 }
223 }
224 } else {
225 None
226 };
227
228 let agent_name = agent
229 .map(String::from)
230 .or_else(|| route_result.as_ref().map(|r| r.agent.clone()))
231 .unwrap_or_else(|| "claude".to_string());
232
233 let model_name = resolve_model_name(
234 model,
235 route_result.as_ref().and_then(|r| r.model.as_deref()),
236 stored_model.as_deref(),
237 );
238
239 let complexity = route_result.as_ref().map(|r| r.complexity.clone());
240
241 let pseudo_task = build_pseudo_task(task_id, store, repo).await;
243 let task_title = pseudo_task.title.clone();
244
245 let selected_skills = route_result
246 .as_ref()
247 .map(|r| r.selected_skills.clone())
248 .unwrap_or_default();
249
250 let ctx = context::build_full_context(
252 &pseudo_task,
253 backend,
254 &wt.work_dir,
255 &wt.default_branch,
256 attempts,
257 &selected_skills,
258 store,
259 repo,
260 )
261 .await;
262
263 let sandboxed = super::sandbox::enabled(&wt.main_project_dir);
264 let shipcheck = agent::Shipcheck::detect(&wt.work_dir).await;
265 agent::install_shipcheck_hooks(&wt.work_dir, shipcheck, Path::new("shipcheck")).await;
266
267 let system_prompt = agent::build_system_prompt(
269 &pseudo_task,
270 &ctx,
271 route_result.as_ref(),
272 &wt.default_branch,
273 shipcheck.for_prompt(sandboxed),
274 );
275 let agent_message = agent::build_agent_message(&pseudo_task, &ctx, attempts);
276
277 let git_name = config::get("git.name").unwrap_or_else(|_| format!("{agent_name}[bot]"));
279 let git_email = config::get("git.email")
280 .unwrap_or_else(|_| format!("{agent_name}[bot]@users.noreply.github.com"));
281
282 let next_attempt = attempts + 1;
284 let attempt_dir = match home::task_attempt_dir_async(repo, task_id, next_attempt).await {
285 Ok(dir) => dir,
286 Err(e) => {
287 if let Some(s) = store {
288 let _ = cleanup::cleanup_task_worktree(task_id, repo, s).await;
289 }
290 return Err(e);
291 }
292 };
293 let output_file = attempt_dir.join("output.json");
294
295 let mut disallowed_tools = vec![
297 "Bash(rm *)".to_string(),
298 "Bash(rm -*)".to_string(),
299 "Bash(git push*)".to_string(),
300 "Bash(gh pr create*)".to_string(),
303 ];
304 disallowed_tools.extend(git_ops::remote_change_tools());
305
306 if wt.work_dir != wt.main_project_dir {
308 let main_str = wt.main_project_dir.to_string_lossy();
309 disallowed_tools.extend([
310 format!("Bash(cd {main_str}*)"),
311 format!("Read({main_str}/*)"),
312 format!("Write({main_str}/*)"),
313 format!("Edit({main_str}/*)"),
314 ]);
315 }
316
317 if let Ok(orch_home) = home::orch_home() {
318 for path in [
319 orch_home.join("config.yml"),
320 orch_home.join("config.example.yml"),
321 ] {
322 let path_str = path.to_string_lossy();
323 disallowed_tools.extend([
324 format!("Read({path_str})"),
325 format!("Write({path_str})"),
326 format!("Edit({path_str})"),
327 ]);
328 }
329 }
330
331 {
333 let orch_yml = wt.work_dir.join(".orch.yml");
334 let orch_yml_str = orch_yml.to_string_lossy();
335 disallowed_tools.extend([
336 format!("Write({orch_yml_str})"),
337 format!("Edit({orch_yml_str})"),
338 ]);
339 }
340
341 let timeout_tier = match (complexity.as_deref(), model_name.as_deref()) {
344 (Some(c), Some(m)) if !m.is_empty() => {
345 Some(RouterConfig::from_config().tier_of_model(&agent_name, m, c))
346 }
347 (c, _) => c.map(String::from),
348 };
349 let timeout_seconds = RunTimeouts::from_config().for_complexity(timeout_tier.as_deref());
350
351 let invocation = agent::AgentInvocation {
353 agent: agent_name.clone(),
354 model: model_name.clone(),
355 work_dir: wt.work_dir.clone(),
356 system_prompt,
357 agent_message,
358 task_id: task_id.to_string(),
359 disallowed_tools,
360 git_author_name: git_name,
361 git_author_email: git_email,
362 output_file,
363 timeout_seconds,
364 repo: repo.to_string(),
365 attempt: next_attempt,
366 sandbox: sandboxed,
367 main_project_dir: wt.main_project_dir.clone(),
368 branch: wt.branch.clone(),
369 };
370
371 if let Err(e) = store::store_set_result(
374 store,
375 repo,
376 task_id,
377 &[
378 ("attempts", serde_json::json!(next_attempt)),
379 ("last_error", serde_json::json!("")),
380 ],
381 )
382 .await
383 {
384 if let Some(s) = store {
385 let _ = cleanup::cleanup_task_worktree(task_id, repo, s).await;
386 }
387 return Err(anyhow::anyhow!("failed to persist attempts counter: {e}"));
388 }
389
390 Ok(TaskInitResult {
391 agent_name,
392 model_name,
393 complexity,
394 task_title,
395 wt,
396 invocation,
397 attempt_dir,
398 new_attempts: next_attempt,
399 })
400}
401
402#[cfg(test)]
403mod tests {
404 use std::env;
405 use std::mem;
406 use std::process::Command;
407
408 use super::*;
409 use crate::engine::runner::{WeightSignal, needs_review_signal};
410 use crate::engine::tick::display_status_for;
411 use crate::store::NewTask;
412
413 const FAILOVER_ERROR: &str = "claude timed out after 1804s, clearing agent/model for re-route";
414
415 async fn prepare_run(seed_error: &str) -> (Arc<TaskStore>, i64) {
417 let orch_home = tempfile::tempdir().unwrap();
418 unsafe { env::set_var("ORCH_HOME", orch_home.path()) };
420 mem::forget(orch_home);
421
422 let project = tempfile::tempdir().unwrap();
423 for args in [
424 &["init", "-b", "main"][..],
425 &["config", "user.email", "t@t.com"],
426 &["config", "user.name", "T"],
427 &["commit", "--allow-empty", "-m", "base"],
428 ] {
429 let out = Command::new("git")
430 .args(args)
431 .current_dir(project.path())
432 .output()
433 .unwrap();
434 assert!(out.status.success(), "git {args:?} failed");
435 }
436
437 let task_store = Arc::new(TaskStore::open_memory().await.unwrap());
438 let store_id = task_store
439 .create(&NewTask {
440 repo: "owner/repo".to_string(),
441 origin: "internal".to_string(),
442 title: "Check the report".to_string(),
443 ..Default::default()
444 })
445 .await
446 .unwrap();
447 task_store
448 .set_fields(store_id, &[("last_error", serde_json::json!(seed_error))])
449 .await
450 .unwrap();
451 let task_id = format!("internal:{store_id}");
452 let store = Some(Arc::clone(&task_store));
453
454 prepare_task(
455 &task_id,
456 Some("claude"),
457 Some("sonnet"),
458 None,
459 "owner/repo",
460 project.path(),
461 0,
462 &store,
463 )
464 .await
465 .expect("prepare_task should succeed");
466 (task_store, store_id)
467 }
468
469 async fn stored_last_error(store: &TaskStore, store_id: i64) -> String {
470 store.get(store_id).await.unwrap().last_error
471 }
472
473 #[tokio::test]
474 async fn no_code_task_is_done_after_a_failover_when_the_fallback_run_succeeds() {
475 let (store, id) = prepare_run(FAILOVER_ERROR).await;
476
477 let last_error = stored_last_error(&store, id).await;
478 assert_eq!(last_error, "", "starting a run clears the failover error");
479 let signal = needs_review_signal(&last_error, "codex");
481 assert!(matches!(signal, WeightSignal::Success { .. }));
482 assert_eq!(display_status_for(&signal, true, false), "done");
483 }
484
485 #[tokio::test]
486 async fn no_code_task_is_done_after_a_first_run_with_no_commits() {
487 let (store, id) = prepare_run("").await;
488
489 let signal = needs_review_signal(&stored_last_error(&store, id).await, "claude");
490 assert!(matches!(signal, WeightSignal::Success { .. }));
491 assert_eq!(display_status_for(&signal, true, false), "done");
492 }
493
494 #[tokio::test]
495 async fn timed_out_run_with_no_fallback_stays_in_needs_review() {
496 let (store, id) = prepare_run(FAILOVER_ERROR).await;
497
498 store
500 .set_fields(
501 id,
502 &[(
503 "last_error",
504 serde_json::json!("claude timed out after 1804s, no fallback agents"),
505 )],
506 )
507 .await
508 .unwrap();
509
510 let signal = needs_review_signal(&stored_last_error(&store, id).await, "claude");
511 assert!(matches!(signal, WeightSignal::Failed { .. }));
512 assert_eq!(display_status_for(&signal, true, false), "needs_review");
513 }
514
515 #[test]
516 fn resolve_model_name_skips_a_model_cleared_to_empty() {
517 assert_eq!(resolve_model_name(None, None, Some("")), None);
518 assert_eq!(resolve_model_name(None, Some(""), Some("")), None);
519 assert_eq!(
520 resolve_model_name(None, None, Some("sonnet")).as_deref(),
521 Some("sonnet")
522 );
523 assert_eq!(
524 resolve_model_name(Some("opus"), Some("sonnet"), Some("haiku")).as_deref(),
525 Some("opus")
526 );
527 }
528
529 #[tokio::test]
530 async fn max_attempts_writes_block_reason() {
531 let task_store = Arc::new(TaskStore::open_memory().await.unwrap());
532 let store_id = task_store
533 .create(&NewTask {
534 repo: "owner/repo".to_string(),
535 origin: "internal".to_string(),
536 title: "Test".to_string(),
537 ..Default::default()
538 })
539 .await
540 .unwrap();
541 task_store
542 .set_fields(store_id, &[("attempts", serde_json::json!(1000))])
543 .await
544 .unwrap();
545 let store = Some(Arc::clone(&task_store));
546
547 let outcome = check_guards(&format!("internal:{store_id}"), "owner/repo", &store)
548 .await
549 .unwrap();
550
551 assert!(matches!(outcome, GuardOutcome::MaxAttempts));
552 let task = task_store.get(store_id).await.unwrap();
553 assert!(
554 task.block_reason
555 .is_some_and(|r| r.contains("exceeded max attempts"))
556 );
557 }
558}