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