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::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
29impl std::fmt::Debug for TaskInitResult {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 f.debug_struct("TaskInitResult")
32 .field("agent_name", &self.agent_name)
33 .field("model_name", &self.model_name)
34 .field("complexity", &self.complexity)
35 .field("task_title", &self.task_title)
36 .field("wt", &self.wt)
37 .field("attempt_dir", &self.attempt_dir)
38 .field("new_attempts", &self.new_attempts)
39 .finish_non_exhaustive()
40 }
41}
42
43#[derive(Debug)]
45pub enum GuardOutcome {
46 Proceed(u32),
48 Skip,
50 MaxAttempts,
52}
53
54pub async fn check_guards(
58 task_id: &str,
59 repo: &str,
60 store: &Option<Arc<TaskStore>>,
61) -> anyhow::Result<GuardOutcome> {
62 let attempts: u32 = store::opt_store_get_task(store, repo, task_id)
63 .await
64 .map_or(0, |t| u32::try_from(t.attempts).unwrap_or(0));
65
66 let tmux = TmuxManager::new();
68 let session_name = tmux.session_name(repo, task_id);
69 if tmux.session_blocks_dispatch(&session_name).await {
70 tracing::info!(
71 task_id,
72 session = %session_name,
73 "skipping task: tmux session already exists"
74 );
75 return Ok(GuardOutcome::Skip);
76 }
77
78 let max_attempts: u32 = config::get("workflow.max_attempts")
80 .ok()
81 .and_then(|s| s.parse().ok())
82 .unwrap_or(5);
83
84 if attempts >= max_attempts {
85 tracing::warn!(task_id, attempts, max_attempts, "exceeded max attempts");
86 let msg =
87 format!("exceeded max attempts ({attempts}/{max_attempts}). Use `/retry` to reset.");
88 if let Err(e) = store::store_set_result(
89 store,
90 repo,
91 task_id,
92 &[
93 ("last_error", serde_json::json!(msg)),
94 ("block_reason", serde_json::json!(msg)),
95 ],
96 )
97 .await
98 {
99 tracing::warn!(task_id, err = %e, "failed to write max_attempts last_error to store");
100 }
101 return Ok(GuardOutcome::MaxAttempts);
102 }
103
104 Ok(GuardOutcome::Proceed(attempts))
105}
106
107pub async fn build_pseudo_task(
109 task_id: &str,
110 store: &Option<Arc<TaskStore>>,
111 repo: &str,
112) -> ExternalTask {
113 let (task_title, task_body) = store::opt_store_get_task(store, repo, task_id)
114 .await
115 .map_or_else(
116 || (format!("Task #{task_id}"), String::new()),
117 |t| (t.title, t.body),
118 );
119 ExternalTask {
120 id: ExternalId(task_id.to_string()),
121 title: task_title,
122 body: task_body,
123 state: "open".to_string(),
124 labels: vec![],
125 author: String::new(),
126 created_at: String::new(),
127 updated_at: String::new(),
128 url: String::new(),
129 }
130}
131
132fn resolve_model_name(
135 param: Option<&str>,
136 routed: Option<&str>,
137 stored: Option<&str>,
138) -> Option<String> {
139 [param, routed, stored]
140 .into_iter()
141 .flatten()
142 .map(str::trim)
143 .find(|m| !m.is_empty())
144 .map(String::from)
145}
146
147#[expect(
149 clippy::too_many_arguments,
150 reason = "task setup needs the routing result, backend, project paths and store together"
151)]
152pub async fn prepare_task(
153 task_id: &str,
154 agent: Option<&str>,
155 model: Option<&str>,
156 backend: Option<&dyn ExternalBackend>,
157 repo: &str,
158 project_dir: &Path,
159 attempts: u32,
160 store: &Option<Arc<TaskStore>>,
161) -> anyhow::Result<TaskInitResult> {
162 let initial_task = store::opt_store_get_task(store, repo, task_id).await;
170 let title_for_branch = initial_task
171 .as_ref()
172 .map(|t| t.title.clone())
173 .unwrap_or_default();
174 let stored_model = initial_task.and_then(|t| t.model);
175
176 let wt = worktree::setup_worktree(task_id, &title_for_branch, project_dir, store, repo).await?;
178
179 git_ops::rebase_on_default(&wt.work_dir, &wt.default_branch).await;
182
183 let route_result = if let Some(s) = store {
185 match get_route_result(s, repo, task_id).await {
186 Ok(r) => Some(r),
187 Err(RouteResultError::NoAgent { .. }) => {
190 tracing::warn!(task_id, "routed task has no agent — using fallback");
191 None
192 }
193 Err(e) => {
196 tracing::error!(task_id, error = %e, "failed to get route result from store — blocking task");
197 let err_msg = format!("failed to read routing result: {e}");
199 if let Err(set_err) = store::store_set_result(
200 store,
201 repo,
202 task_id,
203 &[("last_error", serde_json::json!(err_msg.clone()))],
204 )
205 .await
206 {
207 tracing::warn!(task_id, err = %set_err, "failed to write route read error to store");
208 }
209 if let Some(s) = store {
212 let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
213 }
214 return Err(anyhow::anyhow!(err_msg));
215 }
216 }
217 } else {
218 None
219 };
220
221 let agent_name = agent
222 .map(String::from)
223 .or_else(|| route_result.as_ref().map(|r| r.agent.clone()))
224 .unwrap_or_else(|| "claude".to_string());
225
226 let model_name = resolve_model_name(
227 model,
228 route_result.as_ref().and_then(|r| r.model.as_deref()),
229 stored_model.as_deref(),
230 );
231
232 let complexity = route_result.as_ref().map(|r| r.complexity.clone());
233
234 let pseudo_task = build_pseudo_task(task_id, store, repo).await;
236 let task_title = pseudo_task.title.clone();
237
238 let selected_skills = route_result
239 .as_ref()
240 .map(|r| r.selected_skills.clone())
241 .unwrap_or_default();
242
243 let ctx = context::build_full_context(
245 &pseudo_task,
246 backend,
247 &wt.work_dir,
248 &wt.default_branch,
249 attempts,
250 &selected_skills,
251 store,
252 repo,
253 )
254 .await;
255
256 let sandboxed = super::sandbox::enabled(&wt.main_project_dir);
257 let shipcheck = agent::Shipcheck::detect(&wt.work_dir).await;
258 agent::install_shipcheck_hooks(&wt.work_dir, shipcheck, std::path::Path::new("shipcheck"))
259 .await;
260
261 let system_prompt = agent::build_system_prompt(
263 &pseudo_task,
264 &ctx,
265 route_result.as_ref(),
266 &wt.default_branch,
267 shipcheck.for_prompt(sandboxed),
268 );
269 let agent_message = agent::build_agent_message(&pseudo_task, &ctx, attempts);
270
271 let git_name = config::get("git.name").unwrap_or_else(|_| format!("{agent_name}[bot]"));
273 let git_email = config::get("git.email")
274 .unwrap_or_else(|_| format!("{agent_name}[bot]@users.noreply.github.com"));
275
276 let next_attempt = attempts + 1;
278 let attempt_dir = match crate::home::task_attempt_dir_async(repo, task_id, next_attempt).await {
279 Ok(dir) => dir,
280 Err(e) => {
281 if let Some(s) = store {
282 let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
283 }
284 return Err(e);
285 }
286 };
287 let output_file = attempt_dir.join("output.json");
288
289 let mut disallowed_tools = vec![
291 "Bash(rm *)".to_string(),
292 "Bash(rm -*)".to_string(),
293 "Bash(git push*)".to_string(),
294 "Bash(gh pr create*)".to_string(),
297 ];
298 disallowed_tools.extend(git_ops::remote_change_tools());
299
300 if wt.work_dir != wt.main_project_dir {
302 let main_str = wt.main_project_dir.to_string_lossy();
303 disallowed_tools.extend([
304 format!("Bash(cd {main_str}*)"),
305 format!("Read({main_str}/*)"),
306 format!("Write({main_str}/*)"),
307 format!("Edit({main_str}/*)"),
308 ]);
309 }
310
311 if let Ok(orch_home) = crate::home::orch_home() {
312 for path in [
313 orch_home.join("config.yml"),
314 orch_home.join("config.example.yml"),
315 ] {
316 let path_str = path.to_string_lossy();
317 disallowed_tools.extend([
318 format!("Read({path_str})"),
319 format!("Write({path_str})"),
320 format!("Edit({path_str})"),
321 ]);
322 }
323 }
324
325 {
327 let orch_yml = wt.work_dir.join(".orch.yml");
328 let orch_yml_str = orch_yml.to_string_lossy();
329 disallowed_tools.extend([
330 format!("Write({orch_yml_str})"),
331 format!("Edit({orch_yml_str})"),
332 ]);
333 }
334
335 let base_timeout: u64 = config::get("workflow.timeout_seconds")
339 .ok()
340 .and_then(|s| s.parse().ok())
341 .unwrap_or(1800)
342 .max(1800);
343 let timeout_seconds: u64 = match complexity.as_deref() {
344 Some("complex") => config::get("workflow.timeout_by_complexity.complex")
345 .ok()
346 .and_then(|s| s.parse().ok())
347 .unwrap_or(base_timeout)
348 .max(base_timeout),
349 Some("medium") => config::get("workflow.timeout_by_complexity.medium")
350 .ok()
351 .and_then(|s| s.parse().ok())
352 .unwrap_or(base_timeout)
353 .max(base_timeout),
354 _ => base_timeout,
355 };
356
357 let invocation = agent::AgentInvocation {
359 agent: agent_name.clone(),
360 model: model_name.clone(),
361 work_dir: wt.work_dir.clone(),
362 system_prompt,
363 agent_message,
364 task_id: task_id.to_string(),
365 disallowed_tools,
366 git_author_name: git_name,
367 git_author_email: git_email,
368 output_file,
369 timeout_seconds,
370 repo: repo.to_string(),
371 attempt: next_attempt,
372 sandbox: sandboxed,
373 main_project_dir: wt.main_project_dir.clone(),
374 branch: wt.branch.clone(),
375 };
376
377 if let Err(e) = store::store_set_result(
379 store,
380 repo,
381 task_id,
382 &[("attempts", serde_json::json!(next_attempt))],
383 )
384 .await
385 {
386 if let Some(s) = store {
387 let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
388 }
389 return Err(anyhow::anyhow!("failed to persist attempts counter: {e}"));
390 }
391
392 Ok(TaskInitResult {
393 agent_name,
394 model_name,
395 complexity,
396 task_title,
397 wt,
398 invocation,
399 attempt_dir,
400 new_attempts: next_attempt,
401 })
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::store::NewTask;
408
409 #[test]
410 fn resolve_model_name_skips_a_model_cleared_to_empty() {
411 assert_eq!(resolve_model_name(None, None, Some("")), None);
412 assert_eq!(resolve_model_name(None, Some(""), Some("")), None);
413 assert_eq!(
414 resolve_model_name(None, None, Some("sonnet")).as_deref(),
415 Some("sonnet")
416 );
417 assert_eq!(
418 resolve_model_name(Some("opus"), Some("sonnet"), Some("haiku")).as_deref(),
419 Some("opus")
420 );
421 }
422
423 #[tokio::test]
424 async fn max_attempts_writes_block_reason() {
425 let task_store = Arc::new(TaskStore::open_memory().await.unwrap());
426 let store_id = task_store
427 .create(&NewTask {
428 repo: "owner/repo".to_string(),
429 origin: "internal".to_string(),
430 title: "Test".to_string(),
431 ..Default::default()
432 })
433 .await
434 .unwrap();
435 task_store
436 .set_fields(store_id, &[("attempts", serde_json::json!(1000))])
437 .await
438 .unwrap();
439 let store = Some(std::sync::Arc::clone(&task_store));
440
441 let outcome = check_guards(&format!("internal:{store_id}"), "owner/repo", &store)
442 .await
443 .unwrap();
444
445 assert!(matches!(outcome, GuardOutcome::MaxAttempts));
446 let task = task_store.get(store_id).await.unwrap();
447 assert!(
448 task.block_reason
449 .is_some_and(|r| r.contains("exceeded max attempts"))
450 );
451 }
452}