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 .map(str::trim)
127 .find(|m| !m.is_empty())
128 .map(String::from)
129}
130
131#[allow(clippy::too_many_arguments)]
133pub async fn prepare_task(
134 task_id: &str,
135 agent: Option<&str>,
136 model: Option<&str>,
137 backend: Option<&dyn ExternalBackend>,
138 repo: &str,
139 project_dir: &Path,
140 attempts: u32,
141 store: &Option<Arc<TaskStore>>,
142) -> anyhow::Result<TaskInitResult> {
143 let initial_task = store::opt_store_get_task(store, repo, task_id).await;
151 let title_for_branch = initial_task
152 .as_ref()
153 .map(|t| t.title.clone())
154 .unwrap_or_default();
155 let stored_model = initial_task.and_then(|t| t.model);
156
157 let wt = worktree::setup_worktree(task_id, &title_for_branch, project_dir, store, repo).await?;
159
160 git_ops::rebase_on_default(&wt.work_dir, &wt.default_branch).await;
163
164 let route_result = if let Some(ref s) = store {
166 match get_route_result(s, repo, task_id).await {
167 Ok(r) => Some(r),
168 Err(RouteResultError::NoAgent { .. }) => {
171 tracing::warn!(task_id, "routed task has no agent — using fallback");
172 None
173 }
174 Err(e) => {
177 tracing::error!(task_id, error = %e, "failed to get route result from store — blocking task");
178 let err_msg = format!("failed to read routing result: {e}");
180 if let Err(set_err) = store::store_set_result(
181 store,
182 repo,
183 task_id,
184 &[("last_error", serde_json::json!(err_msg.clone()))],
185 )
186 .await
187 {
188 tracing::warn!(task_id, err = %set_err, "failed to write route read error to store");
189 }
190 if let Some(s) = store {
193 let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
194 }
195 return Err(anyhow::anyhow!(err_msg));
196 }
197 }
198 } else {
199 None
200 };
201
202 let agent_name = agent
203 .map(String::from)
204 .or_else(|| route_result.as_ref().map(|r| r.agent.clone()))
205 .unwrap_or_else(|| "claude".to_string());
206
207 let model_name = resolve_model_name(
208 model,
209 route_result.as_ref().and_then(|r| r.model.as_deref()),
210 stored_model.as_deref(),
211 );
212
213 let complexity = route_result.as_ref().map(|r| r.complexity.clone());
214
215 let pseudo_task = build_pseudo_task(task_id, store, repo).await;
217 let task_title = pseudo_task.title.clone();
218
219 let selected_skills = route_result
220 .as_ref()
221 .map(|r| r.selected_skills.clone())
222 .unwrap_or_default();
223
224 let ctx = context::build_full_context(
226 &pseudo_task,
227 backend,
228 &wt.work_dir,
229 &wt.default_branch,
230 attempts,
231 &selected_skills,
232 store,
233 repo,
234 )
235 .await;
236
237 let sandboxed = super::sandbox::enabled(&wt.main_project_dir);
238 let shipcheck = agent::Shipcheck::detect(&wt.work_dir).await;
239 agent::install_shipcheck_hooks(&wt.work_dir, shipcheck, std::path::Path::new("shipcheck"))
240 .await;
241
242 let system_prompt = agent::build_system_prompt(
244 &pseudo_task,
245 &ctx,
246 route_result.as_ref(),
247 &wt.default_branch,
248 shipcheck.for_prompt(sandboxed),
249 );
250 let agent_message = agent::build_agent_message(&pseudo_task, &ctx, attempts);
251
252 let git_name = config::get("git.name").unwrap_or_else(|_| format!("{agent_name}[bot]"));
254 let git_email = config::get("git.email")
255 .unwrap_or_else(|_| format!("{agent_name}[bot]@users.noreply.github.com"));
256
257 let next_attempt = attempts + 1;
259 let attempt_dir = match crate::home::task_attempt_dir_async(repo, task_id, next_attempt).await {
260 Ok(dir) => dir,
261 Err(e) => {
262 if let Some(s) = store {
263 let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
264 }
265 return Err(e);
266 }
267 };
268 let output_file = attempt_dir.join("output.json");
269
270 let mut disallowed_tools = vec![
272 "Bash(rm *)".to_string(),
273 "Bash(rm -*)".to_string(),
274 "Bash(git push*)".to_string(),
275 "Bash(gh pr create*)".to_string(),
278 ];
279 disallowed_tools.extend(git_ops::remote_change_tools());
280
281 if wt.work_dir != wt.main_project_dir {
283 let main_str = wt.main_project_dir.to_string_lossy();
284 disallowed_tools.extend([
285 format!("Bash(cd {main_str}*)"),
286 format!("Read({main_str}/*)"),
287 format!("Write({main_str}/*)"),
288 format!("Edit({main_str}/*)"),
289 ]);
290 }
291
292 if let Ok(orch_home) = crate::home::orch_home() {
293 for path in [
294 orch_home.join("config.yml"),
295 orch_home.join("config.example.yml"),
296 ] {
297 let path_str = path.to_string_lossy();
298 disallowed_tools.extend([
299 format!("Read({path_str})"),
300 format!("Write({path_str})"),
301 format!("Edit({path_str})"),
302 ]);
303 }
304 }
305
306 {
308 let orch_yml = wt.work_dir.join(".orch.yml");
309 let orch_yml_str = orch_yml.to_string_lossy();
310 disallowed_tools.extend([
311 format!("Write({orch_yml_str})"),
312 format!("Edit({orch_yml_str})"),
313 ]);
314 }
315
316 let base_timeout: u64 = config::get("workflow.timeout_seconds")
320 .ok()
321 .and_then(|s| s.parse().ok())
322 .unwrap_or(1800)
323 .max(1800);
324 let timeout_seconds: u64 = match complexity.as_deref() {
325 Some("complex") => config::get("workflow.timeout_by_complexity.complex")
326 .ok()
327 .and_then(|s| s.parse().ok())
328 .unwrap_or(base_timeout)
329 .max(base_timeout),
330 Some("medium") => config::get("workflow.timeout_by_complexity.medium")
331 .ok()
332 .and_then(|s| s.parse().ok())
333 .unwrap_or(base_timeout)
334 .max(base_timeout),
335 _ => base_timeout,
336 };
337
338 let invocation = agent::AgentInvocation {
340 agent: agent_name.clone(),
341 model: model_name.clone(),
342 work_dir: wt.work_dir.clone(),
343 system_prompt,
344 agent_message,
345 task_id: task_id.to_string(),
346 disallowed_tools,
347 git_author_name: git_name,
348 git_author_email: git_email,
349 output_file,
350 timeout_seconds,
351 repo: repo.to_string(),
352 attempt: next_attempt,
353 sandbox: sandboxed,
354 main_project_dir: wt.main_project_dir.clone(),
355 branch: wt.branch.clone(),
356 };
357
358 if let Err(e) = store::store_set_result(
360 store,
361 repo,
362 task_id,
363 &[("attempts", serde_json::json!(next_attempt))],
364 )
365 .await
366 {
367 if let Some(s) = store {
368 let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
369 }
370 return Err(anyhow::anyhow!("failed to persist attempts counter: {e}"));
371 }
372
373 Ok(TaskInitResult {
374 agent_name,
375 model_name,
376 complexity,
377 task_title,
378 wt,
379 invocation,
380 attempt_dir,
381 new_attempts: next_attempt,
382 })
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::store::NewTask;
389
390 #[test]
391 fn resolve_model_name_skips_a_model_cleared_to_empty() {
392 assert_eq!(resolve_model_name(None, None, Some("")), None);
393 assert_eq!(resolve_model_name(None, Some(""), Some("")), None);
394 assert_eq!(
395 resolve_model_name(None, None, Some("sonnet")).as_deref(),
396 Some("sonnet")
397 );
398 assert_eq!(
399 resolve_model_name(Some("opus"), Some("sonnet"), Some("haiku")).as_deref(),
400 Some("opus")
401 );
402 }
403
404 #[tokio::test]
405 async fn max_attempts_writes_block_reason() {
406 let task_store = Arc::new(TaskStore::open_memory().await.unwrap());
407 let store_id = task_store
408 .create(&NewTask {
409 repo: "owner/repo".to_string(),
410 origin: "internal".to_string(),
411 title: "Test".to_string(),
412 ..Default::default()
413 })
414 .await
415 .unwrap();
416 task_store
417 .set_fields(store_id, &[("attempts", serde_json::json!(1000))])
418 .await
419 .unwrap();
420 let store = Some(task_store.clone());
421
422 let outcome = check_guards(&format!("internal:{store_id}"), "owner/repo", &store)
423 .await
424 .unwrap();
425
426 assert!(matches!(outcome, GuardOutcome::MaxAttempts));
427 let task = task_store.get(store_id).await.unwrap();
428 assert!(task
429 .block_reason
430 .is_some_and(|r| r.contains("exceeded max attempts")));
431 }
432}