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