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