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.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            repo_root: &resolved.repo_root,
145            runner_kind: settings.runner,
146            bins,
147            model: settings.model,
148            reasoning_effort: settings.reasoning_effort,
149            runner_cli: settings.runner_cli,
150            prompt: &prompt,
151            timeout: None,
152            permission_mode: settings.permission_mode,
153            revert_on_error: false,
154            git_revert_mode: resolved
155                .config
156                .agent
157                .git_revert_mode
158                .unwrap_or(crate::contracts::GitRevertMode::Ask),
159            output_handler: None,
160            output_stream: runner::OutputStream::Terminal,
161            revert_prompt: None,
162            phase_type: PhaseType::SinglePhase,
163            session_id: None,
164            retry_policy,
165        },
166        runutil::RunnerErrorMessages {
167            log_label: "task builder",
168            interrupted_msg: "Task builder interrupted: the agent run was canceled.",
169            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.",
170            terminated_msg: "Task builder terminated: the agent was stopped by a signal. Review uncommitted changes before rerunning.",
171            non_zero_msg: |code| {
172                format!(
173                    "Task builder failed: the agent exited with a non-zero code ({}). Review uncommitted changes before rerunning.",
174                    code
175                )
176            },
177            other_msg: |err| {
178                format!(
179                    "Task builder failed: the agent could not be started or encountered an error. Error: {:#}",
180                    err
181                )
182            },
183        },
184    )?;
185
186    let mut after = match queue::load_queue(&resolved.queue_path)
187        .with_context(|| format!("read queue {}", resolved.queue_path.display()))
188    {
189        Ok(queue) => queue,
190        Err(err) => {
191            return Err(err);
192        }
193    };
194
195    let done_after = queue::load_queue_or_default(&resolved.done_path)
196        .with_context(|| format!("read done {}", resolved.done_path.display()))?;
197    let done_after_ref = if done_after.tasks.is_empty() && !resolved.done_path.exists() {
198        None
199    } else {
200        Some(&done_after)
201    };
202    queue::validate_queue_set(
203        &after,
204        done_after_ref,
205        &resolved.id_prefix,
206        resolved.id_width,
207        max_depth,
208    )
209    .context("validate queue set after task")?;
210
211    let added = queue::added_tasks(&before_ids, &after);
212    if !added.is_empty() {
213        let added_ids: Vec<String> = added.iter().map(|(id, _)| id.clone()).collect();
214
215        // Enforce smart positioning deterministically
216        queue::reposition_new_tasks(&mut after, &added_ids, insert_index);
217
218        let now = timeutil::now_utc_rfc3339_or_fallback();
219        let default_request = opts.request.clone();
220        queue::backfill_missing_fields(&mut after, &added_ids, &default_request, &now);
221
222        // Apply estimated_minutes if provided via --estimate flag
223        if let Some(estimated) = opts.estimated_minutes {
224            for task in &mut after.tasks {
225                if added_ids.contains(&task.id) {
226                    task.estimated_minutes = Some(estimated);
227                }
228            }
229        }
230
231        queue::save_queue(&resolved.queue_path, &after)
232            .context("save queue with backfilled fields")?;
233    }
234    if added.is_empty() {
235        log::info!("Task builder completed. No new tasks detected.");
236    } else {
237        log::info!("Task builder added {} task(s):", added.len());
238        for (id, title) in added.iter().take(10) {
239            log::info!("- {}: {}", id, title);
240        }
241        if added.len() > 10 {
242            log::info!("...and {} more.", added.len() - 10);
243        }
244    }
245    Ok(())
246}