Skip to main content

ralph/commands/task/
build.rs

1//! Task building functionality for creating new tasks via runner invocation.
2//!
3//! Responsibilities:
4//! - Build tasks using AI runners via .ralph/prompts/task_builder.md.
5//! - Apply template hints and target contexts when specified.
6//! - Validate queue state before and after runner execution.
7//! - Position new tasks intelligently in the queue.
8//! - Backfill missing task fields (request, timestamps) after creation.
9//!
10//! Not handled here:
11//! - Task updating (see update/mod.rs).
12//! - Refactor task generation (see refactor.rs).
13//! - CLI argument parsing or command routing.
14//! - Direct queue file manipulation outside of runner-driven changes.
15//!
16//! Invariants/assumptions:
17//! - Queue file is the source of truth for task ordering.
18//! - Runner execution produces valid task JSON output.
19//! - Template loading and merging happens before prompt rendering.
20//! - Lock acquisition is optional (controlled by acquire_lock parameter).
21
22use super::{TaskBuildOptions, resolve_task_build_settings};
23use crate::commands::run::PhaseType;
24use crate::contracts::ProjectType;
25use crate::{config, prompts, queue, runner, runutil, timeutil};
26use anyhow::{Context, Result, bail};
27
28pub fn build_task(resolved: &config::Resolved, opts: TaskBuildOptions) -> Result<()> {
29    build_task_impl(resolved, opts, true)
30}
31
32pub fn build_task_without_lock(resolved: &config::Resolved, opts: TaskBuildOptions) -> Result<()> {
33    build_task_impl(resolved, opts, false)
34}
35
36fn build_task_impl(
37    resolved: &config::Resolved,
38    mut opts: TaskBuildOptions,
39    acquire_lock: bool,
40) -> Result<()> {
41    let _queue_lock = if acquire_lock {
42        Some(queue::acquire_queue_lock(
43            &resolved.repo_root,
44            "task",
45            opts.force,
46        )?)
47    } else {
48        None
49    };
50
51    if opts.request.trim().is_empty() {
52        bail!("Missing request: task requires a request description. Provide a non-empty request.");
53    }
54
55    // Apply template if specified
56    let mut template_context = String::new();
57    if let Some(template_name) = opts.template_hint.clone() {
58        // Use context-aware loading with validation
59        let load_result = crate::template::load_template_with_context(
60            &template_name,
61            &resolved.repo_root,
62            opts.template_target.as_deref(),
63            opts.strict_templates,
64        );
65
66        match load_result {
67            Ok(loaded) => {
68                // Log any warnings from template validation
69                for warning in &loaded.warnings {
70                    log::warn!("Template '{}': {}", template_name, warning);
71                }
72
73                crate::template::merge_template_with_options(&loaded.task, &mut opts);
74                template_context = crate::template::format_template_context(&loaded.task);
75                log::info!("Using template '{}' for task creation", template_name);
76            }
77            Err(e) => {
78                if opts.strict_templates {
79                    bail!(
80                        "Template '{}' failed strict validation: {}",
81                        template_name,
82                        e
83                    );
84                } else {
85                    log::warn!("Failed to load template '{}': {}", template_name, e);
86                }
87            }
88        }
89    }
90
91    let before = queue::load_queue(&resolved.queue_path)
92        .with_context(|| format!("read queue {}", resolved.queue_path.display()))?;
93
94    // Compute insertion strategy from pre-run queue state
95    let insert_index = queue::suggest_new_task_insert_index(&before);
96
97    let done = queue::load_queue_or_default(&resolved.done_path)
98        .with_context(|| format!("read done {}", resolved.done_path.display()))?;
99    let done_ref = if done.tasks.is_empty() && !resolved.done_path.exists() {
100        None
101    } else {
102        Some(&done)
103    };
104    let max_depth = resolved.config.queue.max_dependency_depth.unwrap_or(10);
105    queue::validate_queue_set(
106        &before,
107        done_ref,
108        &resolved.id_prefix,
109        resolved.id_width,
110        max_depth,
111    )
112    .context("validate queue set before task")?;
113    let before_ids = queue::task_id_set(&before);
114
115    let template = prompts::load_task_builder_prompt(&resolved.repo_root)?;
116    let project_type = resolved.config.project_type.unwrap_or(ProjectType::Code);
117    let mut prompt = prompts::render_task_builder_prompt(
118        &template,
119        &opts.request,
120        &opts.hint_tags,
121        &opts.hint_scope,
122        project_type,
123        &resolved.config,
124    )?;
125
126    // Append template context to prompt if available
127    if !template_context.is_empty() {
128        prompt.push_str("\n\n--- Template Suggestions ---\n");
129        prompt.push_str(&template_context);
130    }
131
132    prompt = prompts::wrap_with_repoprompt_requirement(&prompt, opts.repoprompt_tool_injection);
133    prompt = prompts::wrap_with_instruction_files(&resolved.repo_root, &prompt, &resolved.config)?;
134
135    let settings = resolve_task_build_settings(resolved, &opts)?;
136    let bins = runner::resolve_binaries(&resolved.config.agent);
137    // Two-pass mode disabled for task (only generates task, should not implement)
138
139    let retry_policy = runutil::RunnerRetryPolicy::from_config(&resolved.config.agent.runner_retry)
140        .unwrap_or_default();
141
142    let _output = runutil::run_prompt_with_handling(
143        runutil::RunnerInvocation {
144            settings: runutil::RunnerSettings {
145                repo_root: &resolved.repo_root,
146                runner_kind: settings.runner,
147                bins,
148                model: settings.model,
149                reasoning_effort: settings.reasoning_effort,
150                runner_cli: settings.runner_cli,
151                timeout: None,
152                permission_mode: settings.permission_mode,
153                output_handler: None,
154                output_stream: runner::OutputStream::Terminal,
155            },
156            execution: runutil::RunnerExecutionContext {
157                prompt: &prompt,
158                phase_type: PhaseType::SinglePhase,
159                session_id: None,
160            },
161            failure: runutil::RunnerFailureHandling {
162                revert_on_error: false,
163                git_revert_mode: resolved
164                    .config
165                    .agent
166                    .git_revert_mode
167                    .unwrap_or(crate::contracts::GitRevertMode::Ask),
168                revert_prompt: None,
169            },
170            retry: runutil::RunnerRetryState {
171                policy: retry_policy,
172            },
173        },
174        runutil::RunnerErrorMessages {
175            log_label: "task builder",
176            interrupted_msg: "Task builder interrupted: the agent run was canceled.",
177            timeout_msg: "Task builder timed out: the agent run exceeded the time limit. Changes in the working tree were NOT reverted; review the repo state manually.",
178            terminated_msg: "Task builder terminated: the agent was stopped by a signal. Review uncommitted changes before rerunning.",
179            non_zero_msg: |code| {
180                format!(
181                    "Task builder failed: the agent exited with a non-zero code ({}). Review uncommitted changes before rerunning.",
182                    code
183                )
184            },
185            other_msg: |err| {
186                format!(
187                    "Task builder failed: the agent could not be started or encountered an error. Error: {:#}",
188                    err
189                )
190            },
191        },
192    )?;
193
194    let mut after = match queue::load_queue(&resolved.queue_path)
195        .with_context(|| format!("read queue {}", resolved.queue_path.display()))
196    {
197        Ok(queue) => queue,
198        Err(err) => {
199            return Err(err);
200        }
201    };
202
203    let done_after = queue::load_queue_or_default(&resolved.done_path)
204        .with_context(|| format!("read done {}", resolved.done_path.display()))?;
205    let done_after_ref = if done_after.tasks.is_empty() && !resolved.done_path.exists() {
206        None
207    } else {
208        Some(&done_after)
209    };
210    queue::validate_queue_set(
211        &after,
212        done_after_ref,
213        &resolved.id_prefix,
214        resolved.id_width,
215        max_depth,
216    )
217    .context("validate queue set after task")?;
218
219    let added = queue::added_tasks(&before_ids, &after);
220    if !added.is_empty() {
221        let added_ids: Vec<String> = added.iter().map(|(id, _)| id.clone()).collect();
222
223        // Enforce smart positioning deterministically
224        queue::reposition_new_tasks(&mut after, &added_ids, insert_index);
225
226        let now = timeutil::now_utc_rfc3339_or_fallback();
227        let default_request = opts.request.clone();
228        queue::backfill_missing_fields(&mut after, &added_ids, &default_request, &now);
229
230        // Apply estimated_minutes if provided via --estimate flag
231        if let Some(estimated) = opts.estimated_minutes {
232            for task in &mut after.tasks {
233                if added_ids.contains(&task.id) {
234                    task.estimated_minutes = Some(estimated);
235                }
236            }
237        }
238
239        queue::save_queue(&resolved.queue_path, &after)
240            .context("save queue with backfilled fields")?;
241    }
242    if added.is_empty() {
243        log::info!("Task builder completed. No new tasks detected.");
244    } else {
245        log::info!("Task builder added {} task(s):", added.len());
246        for (id, title) in added.iter().take(10) {
247            log::info!("- {}: {}", id, title);
248        }
249        if added.len() > 10 {
250            log::info!("...and {} more.", added.len() - 10);
251        }
252    }
253    Ok(())
254}