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::{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
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
29/// Outcome of the guard check.
30pub enum GuardOutcome {
31    /// Task should proceed; returns current attempt count.
32    Proceed(u32),
33    /// Task should be skipped (already running, status=needs_review, etc.).
34    Skip,
35    /// Task has exceeded max attempts, caller marks it blocked.
36    MaxAttempts,
37}
38
39/// Check task guards and return the outcome.
40///
41/// Also checks for existing tmux sessions to prevent duplicate dispatch.
42pub 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    // Guard: check if tmux session already exists (prevents duplicate dispatch)
53    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    // Check max attempts
65    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
93/// Build a minimal `ExternalTask` from store state for prompt building.
94pub 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/// The router clears a stale model by writing an empty string, and `--model ""` makes the claude
117/// API answer 400, so an empty name at any level means no model
118fn resolve_model_name(
119    param: Option<&str>,
120    routed: Option<&str>,
121    stored: Option<&str>,
122) -> Option<String> {
123    [param, routed, stored]
124        .into_iter()
125        .flatten()
126        .map(str::trim)
127        .find(|m| !m.is_empty())
128        .map(String::from)
129}
130
131/// Prepare the full task: set up worktree, build context and prompts, create invocation.
132#[allow(clippy::too_many_arguments)]
133pub async fn prepare_task(
134    task_id: &str,
135    agent: Option<&str>,
136    model: Option<&str>,
137    backend: Option<&dyn ExternalBackend>,
138    repo: &str,
139    project_dir: &Path,
140    attempts: u32,
141    store: &Option<Arc<TaskStore>>,
142) -> anyhow::Result<TaskInitResult> {
143    // Load title (and model fallback) from store.
144    // `model` param and `route_result.model` can both be None when a prior
145    // ModelUnavailable failover wrote the new model to tasks.model but did not
146    // update task_routes.model.  Reading tasks.model here ensures model_name is
147    // never None when the task already has a model assigned — which matters for
148    // BillingCycleExhausted handling in fallback.rs (model-known → model-level
149    // cooldown only; model-unknown → agent-wide 24 h cooldown).
150    let initial_task = store::opt_store_get_task(store, repo, task_id).await;
151    let title_for_branch = initial_task
152        .as_ref()
153        .map(|t| t.title.clone())
154        .unwrap_or_default();
155    let stored_model = initial_task.and_then(|t| t.model);
156
157    // Set up worktree
158    let wt = worktree::setup_worktree(task_id, &title_for_branch, project_dir, store, repo).await?;
159
160    // Rebase worktree on default branch to pick up latest changes.
161    // This prevents non-fast-forward push failures when the task is re-dispatched.
162    git_ops::rebase_on_default(&wt.work_dir, &wt.default_branch).await;
163
164    // Get routing result — distinguish between "no route" and store errors
165    let route_result = if let Some(ref s) = store {
166        match get_route_result(s, repo, task_id).await {
167            Ok(r) => Some(r),
168            // NoAgent means the router hasn't set a route for this task yet;
169            // fall back to the default agent (caller may re-route later).
170            Err(RouteResultError::NoAgent { .. }) => {
171                tracing::warn!(task_id, "routed task has no agent — using fallback");
172                None
173            }
174            // Any other error indicates a store/IO problem — surface it so the
175            // caller doesn't silently proceed with an incorrect fallback.
176            Err(e) => {
177                tracing::error!(task_id, error = %e, "failed to get route result from store — blocking task");
178                // Persist the error to the task result so operators can see it.
179                let err_msg = format!("failed to read routing result: {e}");
180                if let Err(set_err) = store::store_set_result(
181                    store,
182                    repo,
183                    task_id,
184                    &[("last_error", serde_json::json!(err_msg.clone()))],
185                )
186                .await
187                {
188                    tracing::warn!(task_id, err = %set_err, "failed to write route read error to store");
189                }
190                // Attempt to clean up the worktree we created to avoid leaving
191                // partial artifacts behind.
192                if let Some(s) = store {
193                    let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
194                }
195                return Err(anyhow::anyhow!(err_msg));
196            }
197        }
198    } else {
199        None
200    };
201
202    let agent_name = agent
203        .map(String::from)
204        .or_else(|| route_result.as_ref().map(|r| r.agent.clone()))
205        .unwrap_or_else(|| "claude".to_string());
206
207    let model_name = resolve_model_name(
208        model,
209        route_result.as_ref().and_then(|r| r.model.as_deref()),
210        stored_model.as_deref(),
211    );
212
213    let complexity = route_result.as_ref().map(|r| r.complexity.clone());
214
215    // Build a minimal ExternalTask for prompt building
216    let pseudo_task = build_pseudo_task(task_id, store, repo).await;
217    let task_title = pseudo_task.title.clone();
218
219    let selected_skills = route_result
220        .as_ref()
221        .map(|r| r.selected_skills.clone())
222        .unwrap_or_default();
223
224    // Build full context: memory, issue comments, and parent context are all loaded here
225    let ctx = context::build_full_context(
226        &pseudo_task,
227        backend,
228        &wt.work_dir,
229        &wt.default_branch,
230        attempts,
231        &selected_skills,
232        store,
233        repo,
234    )
235    .await;
236
237    let sandboxed = super::sandbox::enabled(&wt.main_project_dir);
238    let shipcheck = agent::Shipcheck::detect(&wt.work_dir).await;
239    agent::install_shipcheck_hooks(&wt.work_dir, shipcheck, std::path::Path::new("shipcheck"))
240        .await;
241
242    // Build prompts
243    let system_prompt = agent::build_system_prompt(
244        &pseudo_task,
245        &ctx,
246        route_result.as_ref(),
247        &wt.default_branch,
248        shipcheck.for_prompt(sandboxed),
249    );
250    let agent_message = agent::build_agent_message(&pseudo_task, &ctx, attempts);
251
252    // Git identity
253    let git_name = config::get("git.name").unwrap_or_else(|_| format!("{agent_name}[bot]"));
254    let git_email = config::get("git.email")
255        .unwrap_or_else(|_| format!("{agent_name}[bot]@users.noreply.github.com"));
256
257    // Output file in per-task attempt directory (attempt = attempts + 1, set below)
258    let next_attempt = attempts + 1;
259    let attempt_dir = match crate::home::task_attempt_dir_async(repo, task_id, next_attempt).await {
260        Ok(dir) => dir,
261        Err(e) => {
262            if let Some(s) = store {
263                let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
264            }
265            return Err(e);
266        }
267    };
268    let output_file = attempt_dir.join("output.json");
269
270    // Build sandbox disallowed tools
271    let mut disallowed_tools = vec![
272        "Bash(rm *)".to_string(),
273        "Bash(rm -*)".to_string(),
274        "Bash(git push*)".to_string(),
275        // Pushing and opening the PR are orch's steps. An agent that tries either hits a
276        // permission prompt or a rejection and reports it as its own failure (#3783)
277        "Bash(gh pr create*)".to_string(),
278    ];
279    disallowed_tools.extend(git_ops::remote_change_tools());
280
281    // Sandbox: block access to main project dir
282    if wt.work_dir != wt.main_project_dir {
283        let main_str = wt.main_project_dir.to_string_lossy();
284        disallowed_tools.extend([
285            format!("Bash(cd {main_str}*)"),
286            format!("Read({main_str}/*)"),
287            format!("Write({main_str}/*)"),
288            format!("Edit({main_str}/*)"),
289        ]);
290    }
291
292    if let Ok(orch_home) = crate::home::orch_home() {
293        for path in [
294            orch_home.join("config.yml"),
295            orch_home.join("config.example.yml"),
296        ] {
297            let path_str = path.to_string_lossy();
298            disallowed_tools.extend([
299                format!("Read({path_str})"),
300                format!("Write({path_str})"),
301                format!("Edit({path_str})"),
302            ]);
303        }
304    }
305
306    // Sandbox: block .orch.yml project config (settled architecture: agents must not modify it)
307    {
308        let orch_yml = wt.work_dir.join(".orch.yml");
309        let orch_yml_str = orch_yml.to_string_lossy();
310        disallowed_tools.extend([
311            format!("Write({orch_yml_str})"),
312            format!("Edit({orch_yml_str})"),
313        ]);
314    }
315
316    // Timeout: read base from workflow.timeout_seconds (min 1800s), then apply per-complexity
317    // override from workflow.timeout_by_complexity.{simple,medium,complex} if configured.
318    // The per-complexity value is also floored at the base to prevent accidental regression.
319    let base_timeout: u64 = config::get("workflow.timeout_seconds")
320        .ok()
321        .and_then(|s| s.parse().ok())
322        .unwrap_or(1800)
323        .max(1800);
324    let timeout_seconds: u64 = match complexity.as_deref() {
325        Some("complex") => config::get("workflow.timeout_by_complexity.complex")
326            .ok()
327            .and_then(|s| s.parse().ok())
328            .unwrap_or(base_timeout)
329            .max(base_timeout),
330        Some("medium") => config::get("workflow.timeout_by_complexity.medium")
331            .ok()
332            .and_then(|s| s.parse().ok())
333            .unwrap_or(base_timeout)
334            .max(base_timeout),
335        _ => base_timeout,
336    };
337
338    // Build agent invocation
339    let invocation = agent::AgentInvocation {
340        agent: agent_name.clone(),
341        model: model_name.clone(),
342        work_dir: wt.work_dir.clone(),
343        system_prompt,
344        agent_message,
345        task_id: task_id.to_string(),
346        disallowed_tools,
347        git_author_name: git_name,
348        git_author_email: git_email,
349        output_file,
350        timeout_seconds,
351        repo: repo.to_string(),
352        attempt: next_attempt,
353        sandbox: sandboxed,
354        main_project_dir: wt.main_project_dir.clone(),
355        branch: wt.branch.clone(),
356    };
357
358    // Increment attempts counter — must succeed so max_attempts guard cannot be bypassed
359    if let Err(e) = store::store_set_result(
360        store,
361        repo,
362        task_id,
363        &[("attempts", serde_json::json!(next_attempt))],
364    )
365    .await
366    {
367        if let Some(s) = store {
368            let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
369        }
370        return Err(anyhow::anyhow!("failed to persist attempts counter: {e}"));
371    }
372
373    Ok(TaskInitResult {
374        agent_name,
375        model_name,
376        complexity,
377        task_title,
378        wt,
379        invocation,
380        attempt_dir,
381        new_attempts: next_attempt,
382    })
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::store::NewTask;
389
390    #[test]
391    fn resolve_model_name_skips_a_model_cleared_to_empty() {
392        assert_eq!(resolve_model_name(None, None, Some("")), None);
393        assert_eq!(resolve_model_name(None, Some(""), Some("")), None);
394        assert_eq!(
395            resolve_model_name(None, None, Some("sonnet")).as_deref(),
396            Some("sonnet")
397        );
398        assert_eq!(
399            resolve_model_name(Some("opus"), Some("sonnet"), Some("haiku")).as_deref(),
400            Some("opus")
401        );
402    }
403
404    #[tokio::test]
405    async fn max_attempts_writes_block_reason() {
406        let task_store = Arc::new(TaskStore::open_memory().await.unwrap());
407        let store_id = task_store
408            .create(&NewTask {
409                repo: "owner/repo".to_string(),
410                origin: "internal".to_string(),
411                title: "Test".to_string(),
412                ..Default::default()
413            })
414            .await
415            .unwrap();
416        task_store
417            .set_fields(store_id, &[("attempts", serde_json::json!(1000))])
418            .await
419            .unwrap();
420        let store = Some(task_store.clone());
421
422        let outcome = check_guards(&format!("internal:{store_id}"), "owner/repo", &store)
423            .await
424            .unwrap();
425
426        assert!(matches!(outcome, GuardOutcome::MaxAttempts));
427        let task = task_store.get(store_id).await.unwrap();
428        assert!(task
429            .block_reason
430            .is_some_and(|r| r.contains("exceeded max attempts")));
431    }
432}