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 &[("last_error", serde_json::json!(msg))],
79 )
80 .await
81 {
82 tracing::warn!(task_id, err = %e, "failed to write max_attempts last_error to store");
83 }
84 return Ok(GuardOutcome::MaxAttempts);
85 }
86
87 Ok(GuardOutcome::Proceed(attempts))
88}
89
90pub async fn build_pseudo_task(
92 task_id: &str,
93 store: &Option<Arc<TaskStore>>,
94 repo: &str,
95) -> ExternalTask {
96 let (task_title, task_body) = store::opt_store_get_task(store, repo, task_id)
97 .await
98 .map(|t| (t.title, t.body))
99 .unwrap_or_else(|| (format!("Task #{task_id}"), String::new()));
100 ExternalTask {
101 id: ExternalId(task_id.to_string()),
102 title: task_title,
103 body: task_body,
104 state: "open".to_string(),
105 labels: vec![],
106 author: String::new(),
107 created_at: String::new(),
108 updated_at: String::new(),
109 url: String::new(),
110 }
111}
112
113#[allow(clippy::too_many_arguments)]
115pub async fn prepare_task(
116 task_id: &str,
117 agent: Option<&str>,
118 model: Option<&str>,
119 backend: Option<&dyn ExternalBackend>,
120 repo: &str,
121 project_dir: &Path,
122 attempts: u32,
123 store: &Option<Arc<TaskStore>>,
124) -> anyhow::Result<TaskInitResult> {
125 let initial_task = store::opt_store_get_task(store, repo, task_id).await;
133 let title_for_branch = initial_task
134 .as_ref()
135 .map(|t| t.title.clone())
136 .unwrap_or_default();
137 let stored_model = initial_task.and_then(|t| t.model);
138
139 let wt = worktree::setup_worktree(task_id, &title_for_branch, project_dir, store, repo).await?;
141
142 git_ops::rebase_on_default(&wt.work_dir, &wt.default_branch).await;
145
146 let route_result = if let Some(ref s) = store {
148 match get_route_result(s, repo, task_id).await {
149 Ok(r) => Some(r),
150 Err(RouteResultError::NoAgent { .. }) => {
153 tracing::warn!(task_id, "routed task has no agent — using fallback");
154 None
155 }
156 Err(e) => {
159 tracing::error!(task_id, error = %e, "failed to get route result from store — blocking task");
160 let err_msg = format!("failed to read routing result: {e}");
162 if let Err(set_err) = store::store_set_result(
163 store,
164 repo,
165 task_id,
166 &[("last_error", serde_json::json!(err_msg.clone()))],
167 )
168 .await
169 {
170 tracing::warn!(task_id, err = %set_err, "failed to write route read error to store");
171 }
172 if let Some(s) = store {
175 let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
176 }
177 return Err(anyhow::anyhow!(err_msg));
178 }
179 }
180 } else {
181 None
182 };
183
184 let agent_name = agent
185 .map(String::from)
186 .or_else(|| route_result.as_ref().map(|r| r.agent.clone()))
187 .unwrap_or_else(|| "claude".to_string());
188
189 let model_name = model
190 .map(String::from)
191 .or_else(|| route_result.as_ref().and_then(|r| r.model.clone()))
192 .or(stored_model);
193
194 let complexity = route_result.as_ref().map(|r| r.complexity.clone());
195
196 let pseudo_task = build_pseudo_task(task_id, store, repo).await;
198 let task_title = pseudo_task.title.clone();
199
200 let selected_skills = route_result
201 .as_ref()
202 .map(|r| r.selected_skills.clone())
203 .unwrap_or_default();
204
205 let ctx = context::build_full_context(
207 &pseudo_task,
208 backend,
209 &wt.work_dir,
210 &wt.default_branch,
211 attempts,
212 &selected_skills,
213 store,
214 repo,
215 )
216 .await;
217
218 let system_prompt = agent::build_system_prompt(
220 &pseudo_task,
221 &ctx,
222 route_result.as_ref(),
223 &wt.default_branch,
224 );
225 let agent_message = agent::build_agent_message(&pseudo_task, &ctx, attempts);
226
227 let git_name = config::get("git.name").unwrap_or_else(|_| format!("{agent_name}[bot]"));
229 let git_email = config::get("git.email")
230 .unwrap_or_else(|_| format!("{agent_name}[bot]@users.noreply.github.com"));
231
232 let next_attempt = attempts + 1;
234 let attempt_dir = match crate::home::task_attempt_dir_async(repo, task_id, next_attempt).await {
235 Ok(dir) => dir,
236 Err(e) => {
237 if let Some(s) = store {
238 let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
239 }
240 return Err(e);
241 }
242 };
243 let output_file = attempt_dir.join("output.json");
244
245 let mut disallowed_tools = vec![
247 "Bash(rm *)".to_string(),
248 "Bash(rm -*)".to_string(),
249 "Bash(git push*)".to_string(),
250 ];
251
252 if wt.work_dir != wt.main_project_dir {
254 let main_str = wt.main_project_dir.to_string_lossy();
255 disallowed_tools.extend([
256 format!("Bash(cd {main_str}*)"),
257 format!("Read({main_str}/*)"),
258 format!("Write({main_str}/*)"),
259 format!("Edit({main_str}/*)"),
260 ]);
261 }
262
263 if let Ok(orch_home) = crate::home::orch_home() {
264 for path in [
265 orch_home.join("config.yml"),
266 orch_home.join("config.example.yml"),
267 ] {
268 let path_str = path.to_string_lossy();
269 disallowed_tools.extend([
270 format!("Read({path_str})"),
271 format!("Write({path_str})"),
272 format!("Edit({path_str})"),
273 ]);
274 }
275 }
276
277 {
279 let orch_yml = wt.work_dir.join(".orch.yml");
280 let orch_yml_str = orch_yml.to_string_lossy();
281 disallowed_tools.extend([
282 format!("Write({orch_yml_str})"),
283 format!("Edit({orch_yml_str})"),
284 ]);
285 }
286
287 let base_timeout: u64 = config::get("workflow.timeout_seconds")
291 .ok()
292 .and_then(|s| s.parse().ok())
293 .unwrap_or(1800)
294 .max(1800);
295 let timeout_seconds: u64 = match complexity.as_deref() {
296 Some("complex") => config::get("workflow.timeout_by_complexity.complex")
297 .ok()
298 .and_then(|s| s.parse().ok())
299 .unwrap_or(base_timeout)
300 .max(base_timeout),
301 Some("medium") => config::get("workflow.timeout_by_complexity.medium")
302 .ok()
303 .and_then(|s| s.parse().ok())
304 .unwrap_or(base_timeout)
305 .max(base_timeout),
306 _ => base_timeout,
307 };
308
309 let invocation = agent::AgentInvocation {
311 agent: agent_name.clone(),
312 model: model_name.clone(),
313 work_dir: wt.work_dir.clone(),
314 system_prompt,
315 agent_message,
316 task_id: task_id.to_string(),
317 disallowed_tools,
318 git_author_name: git_name,
319 git_author_email: git_email,
320 output_file,
321 timeout_seconds,
322 repo: repo.to_string(),
323 attempt: next_attempt,
324 };
325
326 if let Err(e) = store::store_set_result(
328 store,
329 repo,
330 task_id,
331 &[("attempts", serde_json::json!(next_attempt))],
332 )
333 .await
334 {
335 if let Some(s) = store {
336 let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
337 }
338 return Err(anyhow::anyhow!("failed to persist attempts counter: {e}"));
339 }
340
341 Ok(TaskInitResult {
342 agent_name,
343 model_name,
344 complexity,
345 task_title,
346 wt,
347 invocation,
348 attempt_dir,
349 new_attempts: next_attempt,
350 })
351}