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 timeout_seconds =
337 crate::engine::RunTimeouts::from_config().for_complexity(complexity.as_deref());
338
339 let invocation = agent::AgentInvocation {
341 agent: agent_name.clone(),
342 model: model_name.clone(),
343 work_dir: wt.work_dir.clone(),
344 system_prompt,
345 agent_message,
346 task_id: task_id.to_string(),
347 disallowed_tools,
348 git_author_name: git_name,
349 git_author_email: git_email,
350 output_file,
351 timeout_seconds,
352 repo: repo.to_string(),
353 attempt: next_attempt,
354 sandbox: sandboxed,
355 main_project_dir: wt.main_project_dir.clone(),
356 branch: wt.branch.clone(),
357 };
358
359 if let Err(e) = store::store_set_result(
361 store,
362 repo,
363 task_id,
364 &[("attempts", serde_json::json!(next_attempt))],
365 )
366 .await
367 {
368 if let Some(s) = store {
369 let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
370 }
371 return Err(anyhow::anyhow!("failed to persist attempts counter: {e}"));
372 }
373
374 Ok(TaskInitResult {
375 agent_name,
376 model_name,
377 complexity,
378 task_title,
379 wt,
380 invocation,
381 attempt_dir,
382 new_attempts: next_attempt,
383 })
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::store::NewTask;
390
391 #[test]
392 fn resolve_model_name_skips_a_model_cleared_to_empty() {
393 assert_eq!(resolve_model_name(None, None, Some("")), None);
394 assert_eq!(resolve_model_name(None, Some(""), Some("")), None);
395 assert_eq!(
396 resolve_model_name(None, None, Some("sonnet")).as_deref(),
397 Some("sonnet")
398 );
399 assert_eq!(
400 resolve_model_name(Some("opus"), Some("sonnet"), Some("haiku")).as_deref(),
401 Some("opus")
402 );
403 }
404
405 #[tokio::test]
406 async fn max_attempts_writes_block_reason() {
407 let task_store = Arc::new(TaskStore::open_memory().await.unwrap());
408 let store_id = task_store
409 .create(&NewTask {
410 repo: "owner/repo".to_string(),
411 origin: "internal".to_string(),
412 title: "Test".to_string(),
413 ..Default::default()
414 })
415 .await
416 .unwrap();
417 task_store
418 .set_fields(store_id, &[("attempts", serde_json::json!(1000))])
419 .await
420 .unwrap();
421 let store = Some(std::sync::Arc::clone(&task_store));
422
423 let outcome = check_guards(&format!("internal:{store_id}"), "owner/repo", &store)
424 .await
425 .unwrap();
426
427 assert!(matches!(outcome, GuardOutcome::MaxAttempts));
428 let task = task_store.get(store_id).await.unwrap();
429 assert!(
430 task.block_reason
431 .is_some_and(|r| r.contains("exceeded max attempts"))
432 );
433 }
434}