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    // Also clears last_error, so a failover's error never reads as this run's outcome
365    if let Err(e) = store::store_set_result(
366        store,
367        repo,
368        task_id,
369        &[
370            ("attempts", serde_json::json!(next_attempt)),
371            ("last_error", serde_json::json!("")),
372        ],
373    )
374    .await
375    {
376        if let Some(s) = store {
377            let _ = cleanup::cleanup_task_worktree(task_id, repo, s).await;
378        }
379        return Err(anyhow::anyhow!("failed to persist attempts counter: {e}"));
380    }
381
382    Ok(TaskInitResult {
383        agent_name,
384        model_name,
385        complexity,
386        task_title,
387        wt,
388        invocation,
389        attempt_dir,
390        new_attempts: next_attempt,
391    })
392}
393
394#[cfg(test)]
395mod tests {
396    use std::env;
397    use std::mem;
398    use std::process::Command;
399
400    use super::*;
401    use crate::engine::runner::{WeightSignal, needs_review_signal};
402    use crate::engine::tick::display_status_for;
403    use crate::store::NewTask;
404
405    const FAILOVER_ERROR: &str = "claude timed out after 1804s, clearing agent/model for re-route";
406
407    /// Seeds a no-code task with `seed_error`, then runs `prepare_task` for its next run
408    async fn prepare_run(seed_error: &str) -> (Arc<TaskStore>, i64) {
409        let orch_home = tempfile::tempdir().unwrap();
410        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
411        unsafe { env::set_var("ORCH_HOME", orch_home.path()) };
412        mem::forget(orch_home);
413
414        let project = tempfile::tempdir().unwrap();
415        for args in [
416            &["init", "-b", "main"][..],
417            &["config", "user.email", "t@t.com"],
418            &["config", "user.name", "T"],
419            &["commit", "--allow-empty", "-m", "base"],
420        ] {
421            let out = Command::new("git")
422                .args(args)
423                .current_dir(project.path())
424                .output()
425                .unwrap();
426            assert!(out.status.success(), "git {args:?} failed");
427        }
428
429        let task_store = Arc::new(TaskStore::open_memory().await.unwrap());
430        let store_id = task_store
431            .create(&NewTask {
432                repo: "owner/repo".to_string(),
433                origin: "internal".to_string(),
434                title: "Check the report".to_string(),
435                ..Default::default()
436            })
437            .await
438            .unwrap();
439        task_store
440            .set_fields(store_id, &[("last_error", serde_json::json!(seed_error))])
441            .await
442            .unwrap();
443        let task_id = format!("internal:{store_id}");
444        let store = Some(Arc::clone(&task_store));
445
446        prepare_task(
447            &task_id,
448            Some("claude"),
449            Some("sonnet"),
450            None,
451            "owner/repo",
452            project.path(),
453            0,
454            &store,
455        )
456        .await
457        .expect("prepare_task should succeed");
458        (task_store, store_id)
459    }
460
461    async fn stored_last_error(store: &TaskStore, store_id: i64) -> String {
462        store.get(store_id).await.unwrap().last_error
463    }
464
465    #[tokio::test]
466    async fn no_code_task_is_done_after_a_failover_when_the_fallback_run_succeeds() {
467        let (store, id) = prepare_run(FAILOVER_ERROR).await;
468
469        let last_error = stored_last_error(&store, id).await;
470        assert_eq!(last_error, "", "starting a run clears the failover error");
471        // The fallback agent finishes with no commits and no PR, so nothing writes last_error
472        let signal = needs_review_signal(&last_error, "codex");
473        assert!(matches!(signal, WeightSignal::Success { .. }));
474        assert_eq!(display_status_for(&signal, true, false), "done");
475    }
476
477    #[tokio::test]
478    async fn no_code_task_is_done_after_a_first_run_with_no_commits() {
479        let (store, id) = prepare_run("").await;
480
481        let signal = needs_review_signal(&stored_last_error(&store, id).await, "claude");
482        assert!(matches!(signal, WeightSignal::Success { .. }));
483        assert_eq!(display_status_for(&signal, true, false), "done");
484    }
485
486    #[tokio::test]
487    async fn timed_out_run_with_no_fallback_stays_in_needs_review() {
488        let (store, id) = prepare_run(FAILOVER_ERROR).await;
489
490        // handle_error stores this when the run times out and no fallback agent is left
491        store
492            .set_fields(
493                id,
494                &[(
495                    "last_error",
496                    serde_json::json!("claude timed out after 1804s, no fallback agents"),
497                )],
498            )
499            .await
500            .unwrap();
501
502        let signal = needs_review_signal(&stored_last_error(&store, id).await, "claude");
503        assert!(matches!(signal, WeightSignal::Failed { .. }));
504        assert_eq!(display_status_for(&signal, true, false), "needs_review");
505    }
506
507    #[test]
508    fn resolve_model_name_skips_a_model_cleared_to_empty() {
509        assert_eq!(resolve_model_name(None, None, Some("")), None);
510        assert_eq!(resolve_model_name(None, Some(""), Some("")), None);
511        assert_eq!(
512            resolve_model_name(None, None, Some("sonnet")).as_deref(),
513            Some("sonnet")
514        );
515        assert_eq!(
516            resolve_model_name(Some("opus"), Some("sonnet"), Some("haiku")).as_deref(),
517            Some("opus")
518        );
519    }
520
521    #[tokio::test]
522    async fn max_attempts_writes_block_reason() {
523        let task_store = Arc::new(TaskStore::open_memory().await.unwrap());
524        let store_id = task_store
525            .create(&NewTask {
526                repo: "owner/repo".to_string(),
527                origin: "internal".to_string(),
528                title: "Test".to_string(),
529                ..Default::default()
530            })
531            .await
532            .unwrap();
533        task_store
534            .set_fields(store_id, &[("attempts", serde_json::json!(1000))])
535            .await
536            .unwrap();
537        let store = Some(Arc::clone(&task_store));
538
539        let outcome = check_guards(&format!("internal:{store_id}"), "owner/repo", &store)
540            .await
541            .unwrap();
542
543        assert!(matches!(outcome, GuardOutcome::MaxAttempts));
544        let task = task_store.get(store_id).await.unwrap();
545        assert!(
546            task.block_reason
547                .is_some_and(|r| r.contains("exceeded max attempts"))
548        );
549    }
550}