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(
366 store,
367 repo,
368 task_id,
369 &[
370 ("attempts", serde_json::json!(next_attempt)),
371 ("last_error", serde_json::json!("")),
372 ],
373 )
374 .await
375 {
376 if let Some(s) = store {
377 let _ = cleanup::cleanup_task_worktree(task_id, repo, s).await;
378 }
379 return Err(anyhow::anyhow!("failed to persist attempts counter: {e}"));
380 }
381
382 Ok(TaskInitResult {
383 agent_name,
384 model_name,
385 complexity,
386 task_title,
387 wt,
388 invocation,
389 attempt_dir,
390 new_attempts: next_attempt,
391 })
392}
393
394#[cfg(test)]
395mod tests {
396 use std::env;
397 use std::mem;
398 use std::process::Command;
399
400 use super::*;
401 use crate::engine::runner::{WeightSignal, needs_review_signal};
402 use crate::engine::tick::display_status_for;
403 use crate::store::NewTask;
404
405 const FAILOVER_ERROR: &str = "claude timed out after 1804s, clearing agent/model for re-route";
406
407 async fn prepare_run(seed_error: &str) -> (Arc<TaskStore>, i64) {
409 let orch_home = tempfile::tempdir().unwrap();
410 unsafe { env::set_var("ORCH_HOME", orch_home.path()) };
412 mem::forget(orch_home);
413
414 let project = tempfile::tempdir().unwrap();
415 for args in [
416 &["init", "-b", "main"][..],
417 &["config", "user.email", "t@t.com"],
418 &["config", "user.name", "T"],
419 &["commit", "--allow-empty", "-m", "base"],
420 ] {
421 let out = Command::new("git")
422 .args(args)
423 .current_dir(project.path())
424 .output()
425 .unwrap();
426 assert!(out.status.success(), "git {args:?} failed");
427 }
428
429 let task_store = Arc::new(TaskStore::open_memory().await.unwrap());
430 let store_id = task_store
431 .create(&NewTask {
432 repo: "owner/repo".to_string(),
433 origin: "internal".to_string(),
434 title: "Check the report".to_string(),
435 ..Default::default()
436 })
437 .await
438 .unwrap();
439 task_store
440 .set_fields(store_id, &[("last_error", serde_json::json!(seed_error))])
441 .await
442 .unwrap();
443 let task_id = format!("internal:{store_id}");
444 let store = Some(Arc::clone(&task_store));
445
446 prepare_task(
447 &task_id,
448 Some("claude"),
449 Some("sonnet"),
450 None,
451 "owner/repo",
452 project.path(),
453 0,
454 &store,
455 )
456 .await
457 .expect("prepare_task should succeed");
458 (task_store, store_id)
459 }
460
461 async fn stored_last_error(store: &TaskStore, store_id: i64) -> String {
462 store.get(store_id).await.unwrap().last_error
463 }
464
465 #[tokio::test]
466 async fn no_code_task_is_done_after_a_failover_when_the_fallback_run_succeeds() {
467 let (store, id) = prepare_run(FAILOVER_ERROR).await;
468
469 let last_error = stored_last_error(&store, id).await;
470 assert_eq!(last_error, "", "starting a run clears the failover error");
471 let signal = needs_review_signal(&last_error, "codex");
473 assert!(matches!(signal, WeightSignal::Success { .. }));
474 assert_eq!(display_status_for(&signal, true, false), "done");
475 }
476
477 #[tokio::test]
478 async fn no_code_task_is_done_after_a_first_run_with_no_commits() {
479 let (store, id) = prepare_run("").await;
480
481 let signal = needs_review_signal(&stored_last_error(&store, id).await, "claude");
482 assert!(matches!(signal, WeightSignal::Success { .. }));
483 assert_eq!(display_status_for(&signal, true, false), "done");
484 }
485
486 #[tokio::test]
487 async fn timed_out_run_with_no_fallback_stays_in_needs_review() {
488 let (store, id) = prepare_run(FAILOVER_ERROR).await;
489
490 store
492 .set_fields(
493 id,
494 &[(
495 "last_error",
496 serde_json::json!("claude timed out after 1804s, no fallback agents"),
497 )],
498 )
499 .await
500 .unwrap();
501
502 let signal = needs_review_signal(&stored_last_error(&store, id).await, "claude");
503 assert!(matches!(signal, WeightSignal::Failed { .. }));
504 assert_eq!(display_status_for(&signal, true, false), "needs_review");
505 }
506
507 #[test]
508 fn resolve_model_name_skips_a_model_cleared_to_empty() {
509 assert_eq!(resolve_model_name(None, None, Some("")), None);
510 assert_eq!(resolve_model_name(None, Some(""), Some("")), None);
511 assert_eq!(
512 resolve_model_name(None, None, Some("sonnet")).as_deref(),
513 Some("sonnet")
514 );
515 assert_eq!(
516 resolve_model_name(Some("opus"), Some("sonnet"), Some("haiku")).as_deref(),
517 Some("opus")
518 );
519 }
520
521 #[tokio::test]
522 async fn max_attempts_writes_block_reason() {
523 let task_store = Arc::new(TaskStore::open_memory().await.unwrap());
524 let store_id = task_store
525 .create(&NewTask {
526 repo: "owner/repo".to_string(),
527 origin: "internal".to_string(),
528 title: "Test".to_string(),
529 ..Default::default()
530 })
531 .await
532 .unwrap();
533 task_store
534 .set_fields(store_id, &[("attempts", serde_json::json!(1000))])
535 .await
536 .unwrap();
537 let store = Some(Arc::clone(&task_store));
538
539 let outcome = check_guards(&format!("internal:{store_id}"), "owner/repo", &store)
540 .await
541 .unwrap();
542
543 assert!(matches!(outcome, GuardOutcome::MaxAttempts));
544 let task = task_store.get(store_id).await.unwrap();
545 assert!(
546 task.block_reason
547 .is_some_and(|r| r.contains("exceeded max attempts"))
548 );
549 }
550}