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 should update GitHub to NeedsReview.
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            &[("last_error", serde_json::json!(msg))],
79        )
80        .await
81        {
82            tracing::warn!(task_id, err = %e, "failed to write max_attempts last_error to store");
83        }
84        return Ok(GuardOutcome::MaxAttempts);
85    }
86
87    Ok(GuardOutcome::Proceed(attempts))
88}
89
90/// Build a minimal `ExternalTask` from store state for prompt building.
91pub async fn build_pseudo_task(
92    task_id: &str,
93    store: &Option<Arc<TaskStore>>,
94    repo: &str,
95) -> ExternalTask {
96    let (task_title, task_body) = store::opt_store_get_task(store, repo, task_id)
97        .await
98        .map(|t| (t.title, t.body))
99        .unwrap_or_else(|| (format!("Task #{task_id}"), String::new()));
100    ExternalTask {
101        id: ExternalId(task_id.to_string()),
102        title: task_title,
103        body: task_body,
104        state: "open".to_string(),
105        labels: vec![],
106        author: String::new(),
107        created_at: String::new(),
108        updated_at: String::new(),
109        url: String::new(),
110    }
111}
112
113/// Prepare the full task: set up worktree, build context and prompts, create invocation.
114#[allow(clippy::too_many_arguments)]
115pub async fn prepare_task(
116    task_id: &str,
117    agent: Option<&str>,
118    model: Option<&str>,
119    backend: Option<&dyn ExternalBackend>,
120    repo: &str,
121    project_dir: &Path,
122    attempts: u32,
123    store: &Option<Arc<TaskStore>>,
124) -> anyhow::Result<TaskInitResult> {
125    // Load title (and model fallback) from store.
126    // `model` param and `route_result.model` can both be None when a prior
127    // ModelUnavailable failover wrote the new model to tasks.model but did not
128    // update task_routes.model.  Reading tasks.model here ensures model_name is
129    // never None when the task already has a model assigned — which matters for
130    // BillingCycleExhausted handling in fallback.rs (model-known → model-level
131    // cooldown only; model-unknown → agent-wide 24 h cooldown).
132    let initial_task = store::opt_store_get_task(store, repo, task_id).await;
133    let title_for_branch = initial_task
134        .as_ref()
135        .map(|t| t.title.clone())
136        .unwrap_or_default();
137    let stored_model = initial_task.and_then(|t| t.model);
138
139    // Set up worktree
140    let wt = worktree::setup_worktree(task_id, &title_for_branch, project_dir, store, repo).await?;
141
142    // Rebase worktree on default branch to pick up latest changes.
143    // This prevents non-fast-forward push failures when the task is re-dispatched.
144    git_ops::rebase_on_default(&wt.work_dir, &wt.default_branch).await;
145
146    // Get routing result — distinguish between "no route" and store errors
147    let route_result = if let Some(ref s) = store {
148        match get_route_result(s, repo, task_id).await {
149            Ok(r) => Some(r),
150            // NoAgent means the router hasn't set a route for this task yet;
151            // fall back to the default agent (caller may re-route later).
152            Err(RouteResultError::NoAgent { .. }) => {
153                tracing::warn!(task_id, "routed task has no agent — using fallback");
154                None
155            }
156            // Any other error indicates a store/IO problem — surface it so the
157            // caller doesn't silently proceed with an incorrect fallback.
158            Err(e) => {
159                tracing::error!(task_id, error = %e, "failed to get route result from store — blocking task");
160                // Persist the error to the task result so operators can see it.
161                let err_msg = format!("failed to read routing result: {e}");
162                if let Err(set_err) = store::store_set_result(
163                    store,
164                    repo,
165                    task_id,
166                    &[("last_error", serde_json::json!(err_msg.clone()))],
167                )
168                .await
169                {
170                    tracing::warn!(task_id, err = %set_err, "failed to write route read error to store");
171                }
172                // Attempt to clean up the worktree we created to avoid leaving
173                // partial artifacts behind.
174                if let Some(s) = store {
175                    let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
176                }
177                return Err(anyhow::anyhow!(err_msg));
178            }
179        }
180    } else {
181        None
182    };
183
184    let agent_name = agent
185        .map(String::from)
186        .or_else(|| route_result.as_ref().map(|r| r.agent.clone()))
187        .unwrap_or_else(|| "claude".to_string());
188
189    let model_name = model
190        .map(String::from)
191        .or_else(|| route_result.as_ref().and_then(|r| r.model.clone()))
192        .or(stored_model);
193
194    let complexity = route_result.as_ref().map(|r| r.complexity.clone());
195
196    // Build a minimal ExternalTask for prompt building
197    let pseudo_task = build_pseudo_task(task_id, store, repo).await;
198    let task_title = pseudo_task.title.clone();
199
200    let selected_skills = route_result
201        .as_ref()
202        .map(|r| r.selected_skills.clone())
203        .unwrap_or_default();
204
205    // Build full context: memory, issue comments, and parent context are all loaded here
206    let ctx = context::build_full_context(
207        &pseudo_task,
208        backend,
209        &wt.work_dir,
210        &wt.default_branch,
211        attempts,
212        &selected_skills,
213        store,
214        repo,
215    )
216    .await;
217
218    // Build prompts
219    let system_prompt = agent::build_system_prompt(
220        &pseudo_task,
221        &ctx,
222        route_result.as_ref(),
223        &wt.default_branch,
224    );
225    let agent_message = agent::build_agent_message(&pseudo_task, &ctx, attempts);
226
227    // Git identity
228    let git_name = config::get("git.name").unwrap_or_else(|_| format!("{agent_name}[bot]"));
229    let git_email = config::get("git.email")
230        .unwrap_or_else(|_| format!("{agent_name}[bot]@users.noreply.github.com"));
231
232    // Output file in per-task attempt directory (attempt = attempts + 1, set below)
233    let next_attempt = attempts + 1;
234    let attempt_dir = match crate::home::task_attempt_dir_async(repo, task_id, next_attempt).await {
235        Ok(dir) => dir,
236        Err(e) => {
237            if let Some(s) = store {
238                let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
239            }
240            return Err(e);
241        }
242    };
243    let output_file = attempt_dir.join("output.json");
244
245    // Build sandbox disallowed tools
246    let mut disallowed_tools = vec![
247        "Bash(rm *)".to_string(),
248        "Bash(rm -*)".to_string(),
249        "Bash(git push*)".to_string(),
250    ];
251
252    // Sandbox: block access to main project dir
253    if wt.work_dir != wt.main_project_dir {
254        let main_str = wt.main_project_dir.to_string_lossy();
255        disallowed_tools.extend([
256            format!("Bash(cd {main_str}*)"),
257            format!("Read({main_str}/*)"),
258            format!("Write({main_str}/*)"),
259            format!("Edit({main_str}/*)"),
260        ]);
261    }
262
263    if let Ok(orch_home) = crate::home::orch_home() {
264        for path in [
265            orch_home.join("config.yml"),
266            orch_home.join("config.example.yml"),
267        ] {
268            let path_str = path.to_string_lossy();
269            disallowed_tools.extend([
270                format!("Read({path_str})"),
271                format!("Write({path_str})"),
272                format!("Edit({path_str})"),
273            ]);
274        }
275    }
276
277    // Sandbox: block .orch.yml project config (settled architecture: agents must not modify it)
278    {
279        let orch_yml = wt.work_dir.join(".orch.yml");
280        let orch_yml_str = orch_yml.to_string_lossy();
281        disallowed_tools.extend([
282            format!("Write({orch_yml_str})"),
283            format!("Edit({orch_yml_str})"),
284        ]);
285    }
286
287    // Timeout: read base from workflow.timeout_seconds (min 1800s), then apply per-complexity
288    // override from workflow.timeout_by_complexity.{simple,medium,complex} if configured.
289    // The per-complexity value is also floored at the base to prevent accidental regression.
290    let base_timeout: u64 = config::get("workflow.timeout_seconds")
291        .ok()
292        .and_then(|s| s.parse().ok())
293        .unwrap_or(1800)
294        .max(1800);
295    let timeout_seconds: u64 = match complexity.as_deref() {
296        Some("complex") => config::get("workflow.timeout_by_complexity.complex")
297            .ok()
298            .and_then(|s| s.parse().ok())
299            .unwrap_or(base_timeout)
300            .max(base_timeout),
301        Some("medium") => config::get("workflow.timeout_by_complexity.medium")
302            .ok()
303            .and_then(|s| s.parse().ok())
304            .unwrap_or(base_timeout)
305            .max(base_timeout),
306        _ => base_timeout,
307    };
308
309    // Build agent invocation
310    let invocation = agent::AgentInvocation {
311        agent: agent_name.clone(),
312        model: model_name.clone(),
313        work_dir: wt.work_dir.clone(),
314        system_prompt,
315        agent_message,
316        task_id: task_id.to_string(),
317        disallowed_tools,
318        git_author_name: git_name,
319        git_author_email: git_email,
320        output_file,
321        timeout_seconds,
322        repo: repo.to_string(),
323        attempt: next_attempt,
324    };
325
326    // Increment attempts counter — must succeed so max_attempts guard cannot be bypassed
327    if let Err(e) = store::store_set_result(
328        store,
329        repo,
330        task_id,
331        &[("attempts", serde_json::json!(next_attempt))],
332    )
333    .await
334    {
335        if let Some(s) = store {
336            let _ = crate::engine::cleanup::cleanup_task_worktree(task_id, repo, s).await;
337        }
338        return Err(anyhow::anyhow!("failed to persist attempts counter: {e}"));
339    }
340
341    Ok(TaskInitResult {
342        agent_name,
343        model_name,
344        complexity,
345        task_title,
346        wt,
347        invocation,
348        attempt_dir,
349        new_attempts: next_attempt,
350    })
351}