Skip to main content

orch/engine/runner/
task_init.rs

1//! Task initialization — guard checks, worktree setup, and invocation building.
2//!
3//! Extracted from `runner/mod.rs`. Contains the first phase of `run()`:
4//! guard checks, worktree creation, context building, and prompt assembly.
5
6use 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
17/// Result of task preparation: everything needed to run an agent session.
18pub 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/// Outcome of the guard check.
44#[derive(Debug)]
45pub enum GuardOutcome {
46    /// Task should proceed; returns current attempt count.
47    Proceed(u32),
48    /// Task should be skipped (already running, status=needs_review, etc.).
49    Skip,
50    /// Task has exceeded max attempts, caller marks it blocked.
51    MaxAttempts,
52}
53
54/// Check task guards and return the outcome.
55///
56/// Also checks for existing tmux sessions to prevent duplicate dispatch.
57pub 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    // Guard: check if tmux session already exists (prevents duplicate dispatch)
67    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    // Check max attempts
79    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
107/// Build a minimal `ExternalTask` from store state for prompt building.
108pub 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
132/// The router clears a stale model by writing an empty string, and `--model ""` makes the claude
133/// API answer 400, so an empty name at any level means no model
134fn 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/// Prepare the full task: set up worktree, build context and prompts, create invocation.
148#[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    // Load title (and model fallback) from store.
163    // `model` param and `route_result.model` can both be None when a prior
164    // ModelUnavailable failover wrote the new model to tasks.model but did not
165    // update task_routes.model.  Reading tasks.model here ensures model_name is
166    // never None when the task already has a model assigned — which matters for
167    // BillingCycleExhausted handling in fallback.rs (model-known → model-level
168    // cooldown only; model-unknown → agent-wide 24 h cooldown).
169    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    // Set up worktree
177    let wt = worktree::setup_worktree(task_id, &title_for_branch, project_dir, store, repo).await?;
178
179    // Rebase worktree on default branch to pick up latest changes.
180    // This prevents non-fast-forward push failures when the task is re-dispatched.
181    git_ops::rebase_on_default(&wt.work_dir, &wt.default_branch).await;
182
183    // Get routing result — distinguish between "no route" and store errors
184    let route_result = if let Some(s) = store {
185        match get_route_result(s, repo, task_id).await {
186            Ok(r) => Some(r),
187            // NoAgent means the router hasn't set a route for this task yet;
188            // fall back to the default agent (caller may re-route later).
189            Err(RouteResultError::NoAgent { .. }) => {
190                tracing::warn!(task_id, "routed task has no agent — using fallback");
191                None
192            }
193            // Any other error indicates a store/IO problem — surface it so the
194            // caller doesn't silently proceed with an incorrect fallback.
195            Err(e) => {
196                tracing::error!(task_id, error = %e, "failed to get route result from store — blocking task");
197                // Persist the error to the task result so operators can see it.
198                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                // Attempt to clean up the worktree we created to avoid leaving
210                // partial artifacts behind.
211                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    // Build a minimal ExternalTask for prompt building
235    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    // Build full context: memory, issue comments, and parent context are all loaded here
244    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    // Build prompts
262    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    // Git identity
272    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    // Output file in per-task attempt directory (attempt = attempts + 1, set below)
277    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    // Build sandbox disallowed tools
290    let mut disallowed_tools = vec![
291        "Bash(rm *)".to_string(),
292        "Bash(rm -*)".to_string(),
293        "Bash(git push*)".to_string(),
294        // Pushing and opening the PR are orch's steps. An agent that tries either hits a
295        // permission prompt or a rejection and reports it as its own failure (#3783)
296        "Bash(gh pr create*)".to_string(),
297    ];
298    disallowed_tools.extend(git_ops::remote_change_tools());
299
300    // Sandbox: block access to main project dir
301    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    // Sandbox: block .orch.yml project config (settled architecture: agents must not modify it)
326    {
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    // Timeout: read base from workflow.timeout_seconds (min 1800s), then apply per-complexity
336    // override from workflow.timeout_by_complexity.{simple,medium,complex} if configured.
337    // The per-complexity value is also floored at the base to prevent accidental regression.
338    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    // Build agent invocation
358    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    // Increment attempts counter — must succeed so max_attempts guard cannot be bypassed
378    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}