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