Skip to main content

ralph/commands/task/
update.rs

1//! Task updating functionality for modifying existing tasks via runner invocation.
2//!
3//! Responsibilities:
4//! - Update tasks using AI runners via .ralph/prompts/task_updater.md.
5//! - Support single task updates and batch updates (update-all).
6//! - Create queue backups before updates for recovery.
7//! - Validate queue state before and after runner execution.
8//! - Handle dry-run mode for previewing updates.
9//! - Detect and report changed fields after updates.
10//! - Handle tasks moved to done.jsonc during updates.
11//!
12//! Not handled here:
13//! - Task building (see build.rs).
14//! - Refactor task generation (see refactor.rs).
15//! - CLI argument parsing or command routing.
16//! - Direct queue file manipulation outside of runner-driven changes.
17//!
18//! Invariants/assumptions:
19//! - Queue file is the source of truth for task state.
20//! - Runner execution produces valid task JSON output.
21//! - Backup is created before any mutations for recovery.
22//! - Lock acquisition is optional (controlled by acquire_lock parameter).
23//! - Tasks may be moved to done.jsonc during updates (not an error).
24
25use super::{TaskUpdateSettings, compare_task_fields, resolve_task_update_settings};
26use crate::commands::run::PhaseType;
27use crate::contracts::{ProjectType, QueueFile};
28use crate::{config, fsutil, prompts, queue, runner, runutil};
29use anyhow::{Context, Result, anyhow, bail};
30use std::path::Path;
31
32pub fn update_task(
33    resolved: &config::Resolved,
34    task_id: &str,
35    settings: &TaskUpdateSettings,
36) -> Result<()> {
37    update_task_impl(resolved, task_id, settings, true)
38}
39
40pub fn update_task_without_lock(
41    resolved: &config::Resolved,
42    task_id: &str,
43    settings: &TaskUpdateSettings,
44) -> Result<()> {
45    update_task_impl(resolved, task_id, settings, false)
46}
47
48pub fn update_all_tasks(resolved: &config::Resolved, settings: &TaskUpdateSettings) -> Result<()> {
49    let _queue_lock =
50        queue::acquire_queue_lock(&resolved.repo_root, "task update", settings.force)?;
51
52    let queue_file = queue::load_queue(&resolved.queue_path)
53        .with_context(|| format!("read queue {}", resolved.queue_path.display()))?;
54
55    if queue_file.tasks.is_empty() {
56        bail!("No tasks in queue to update.");
57    }
58
59    let task_ids: Vec<String> = queue_file
60        .tasks
61        .iter()
62        .map(|task| task.id.clone())
63        .collect();
64    for task_id in task_ids {
65        update_task_impl(resolved, &task_id, settings, false)?;
66    }
67
68    Ok(())
69}
70
71/// Restore queue file from backup.
72/// Returns Ok(()) on successful restore, Err otherwise.
73fn restore_queue_from_backup(queue_path: &Path, backup_path: &Path) -> Result<()> {
74    let bytes = std::fs::read(backup_path)
75        .with_context(|| format!("read queue backup {}", backup_path.display()))?;
76    fsutil::write_atomic(queue_path, &bytes)
77        .with_context(|| format!("restore queue from backup {}", backup_path.display()))?;
78    Ok(())
79}
80
81/// Load, validate, and save queue after task update with automatic backup restoration on failure.
82///
83/// This function attempts to:
84/// 1. Load the queue after the runner has modified it
85/// 2. Validate the queue against semantic rules
86/// 3. Save the normalized queue back to disk
87///
88/// If any of these steps fail, the original queue is automatically restored from backup
89/// and the error is returned with context about the restoration.
90fn load_validate_and_save_queue_after_update(
91    resolved: &config::Resolved,
92    backup_path: &Path,
93    max_depth: u8,
94) -> Result<QueueFile> {
95    // Step 1: Load queue after update (with repair for common JSON errors)
96    let after = queue::load_queue_with_repair(&resolved.queue_path)
97        .with_context(|| "parse queue after task update")
98        .or_else(
99            |err| match restore_queue_from_backup(&resolved.queue_path, backup_path) {
100                Ok(()) => Err(err).with_context(|| {
101                    format!(
102                        "queue parse failed after task update; restored queue from backup {}",
103                        backup_path.display()
104                    )
105                }),
106                Err(restore_err) => Err(err).with_context(|| {
107                    format!(
108                        "queue parse failed after task update AND restore failed (backup {}): {:#}",
109                        backup_path.display(),
110                        restore_err
111                    )
112                }),
113            },
114        )?;
115
116    // Step 2: Prepare done file reference for validation
117    let done_after = queue::load_queue_or_default(&resolved.done_path)
118        .with_context(|| format!("read done {}", resolved.done_path.display()))?;
119    let done_after_ref = if done_after.tasks.is_empty() && !resolved.done_path.exists() {
120        None
121    } else {
122        Some(&done_after)
123    };
124
125    // Step 3: Validate queue set (semantic validation)
126    queue::validate_queue_set(
127        &after,
128        done_after_ref,
129        &resolved.id_prefix,
130        resolved.id_width,
131        max_depth,
132    )
133    .context("validate queue set after task update")
134    .or_else(|err| {
135        match restore_queue_from_backup(&resolved.queue_path, backup_path) {
136            Ok(()) => Err(err).with_context(|| {
137                format!(
138                    "queue validation failed after task update; restored queue from backup {}",
139                    backup_path.display()
140                )
141            }),
142            Err(restore_err) => Err(err).with_context(|| {
143                format!(
144                    "queue validation failed after task update AND restore failed (backup {}): {:#}",
145                    backup_path.display(),
146                    restore_err
147                )
148            }),
149        }
150    })?;
151
152    // Step 4: Save the validated queue
153    queue::save_queue(&resolved.queue_path, &after)
154        .context("save queue after task update")
155        .or_else(
156            |err| match restore_queue_from_backup(&resolved.queue_path, backup_path) {
157                Ok(()) => Err(err).with_context(|| {
158                    format!(
159                        "queue save failed after task update; restored queue from backup {}",
160                        backup_path.display()
161                    )
162                }),
163                Err(restore_err) => Err(err).with_context(|| {
164                    format!(
165                        "queue save failed after task update AND restore failed (backup {}): {:#}",
166                        backup_path.display(),
167                        restore_err
168                    )
169                }),
170            },
171        )?;
172
173    Ok(after)
174}
175
176fn update_task_impl(
177    resolved: &config::Resolved,
178    task_id: &str,
179    settings: &TaskUpdateSettings,
180    acquire_lock: bool,
181) -> Result<()> {
182    // Handle dry-run mode early (before any mutations)
183    if settings.dry_run {
184        let before = queue::load_queue(&resolved.queue_path)
185            .with_context(|| format!("read queue {}", resolved.queue_path.display()))?;
186
187        let task_id = task_id.trim();
188        let task = before
189            .tasks
190            .iter()
191            .find(|t| t.id.trim() == task_id)
192            .ok_or_else(|| anyhow!("{}", crate::error_messages::task_not_found(task_id)))?;
193
194        let template = prompts::load_task_updater_prompt(&resolved.repo_root)?;
195        let project_type = resolved.config.project_type.unwrap_or(ProjectType::Code);
196        let prompt = prompts::render_task_updater_prompt(
197            &template,
198            task_id,
199            project_type,
200            &resolved.config,
201        )?;
202
203        println!("Dry run - would update task {}:", task_id);
204        println!("  Current title: {}", task.title);
205        println!("\n  Prompt preview (first 800 chars):");
206        let preview_len = prompt.len().min(800);
207        println!("{}", &prompt[..preview_len]);
208        if prompt.len() > 800 {
209            println!("\n  ... ({} more characters)", prompt.len() - 800);
210        }
211        println!("\n  Note: Actual changes depend on runner analysis of repository state.");
212        return Ok(());
213    }
214
215    let _queue_lock = if acquire_lock {
216        Some(queue::acquire_queue_lock(
217            &resolved.repo_root,
218            "task update",
219            settings.force,
220        )?)
221    } else {
222        None
223    };
224
225    // Create backup before running task updater
226    let cache_dir = resolved.repo_root.join(".ralph/cache");
227    let backup_path = queue::backup_queue(&resolved.queue_path, &cache_dir)
228        .with_context(|| "failed to create queue backup before task update")?;
229    log::debug!("Created queue backup at: {}", backup_path.display());
230
231    let before = queue::load_queue(&resolved.queue_path)
232        .with_context(|| format!("read queue {}", resolved.queue_path.display()))?;
233
234    let task_id = task_id.trim();
235    if !before.tasks.iter().any(|t| t.id.trim() == task_id) {
236        bail!("{}", crate::error_messages::task_not_found(task_id));
237    }
238
239    let before_task = before
240        .tasks
241        .iter()
242        .find(|t| t.id.trim() == task_id)
243        .unwrap();
244    let before_json = serde_json::to_string(before_task)?;
245
246    let done = queue::load_queue_or_default(&resolved.done_path)
247        .with_context(|| format!("read done {}", resolved.done_path.display()))?;
248    let done_ref = if done.tasks.is_empty() && !resolved.done_path.exists() {
249        None
250    } else {
251        Some(&done)
252    };
253    let max_depth = resolved.config.queue.max_dependency_depth.unwrap_or(10);
254    queue::validate_queue_set(
255        &before,
256        done_ref,
257        &resolved.id_prefix,
258        resolved.id_width,
259        max_depth,
260    )
261    .context("validate queue set before task update")?;
262
263    let template = prompts::load_task_updater_prompt(&resolved.repo_root)?;
264    let project_type = resolved.config.project_type.unwrap_or(ProjectType::Code);
265    let prompt =
266        prompts::render_task_updater_prompt(&template, task_id, project_type, &resolved.config)?;
267
268    let prompt =
269        prompts::wrap_with_repoprompt_requirement(&prompt, settings.repoprompt_tool_injection);
270    let prompt =
271        prompts::wrap_with_instruction_files(&resolved.repo_root, &prompt, &resolved.config)?;
272
273    let runner_settings = resolve_task_update_settings(resolved, settings)?;
274    let bins = runner::resolve_binaries(&resolved.config.agent);
275
276    let retry_policy = runutil::RunnerRetryPolicy::from_config(&resolved.config.agent.runner_retry)
277        .unwrap_or_default();
278
279    let _output = runutil::run_prompt_with_handling(
280        runutil::RunnerInvocation {
281            repo_root: &resolved.repo_root,
282            runner_kind: runner_settings.runner,
283            bins,
284            model: runner_settings.model.clone(),
285            reasoning_effort: runner_settings.reasoning_effort,
286            runner_cli: runner_settings.runner_cli,
287            prompt: &prompt,
288            timeout: None,
289            permission_mode: runner_settings.permission_mode,
290            revert_on_error: true,
291            git_revert_mode: resolved
292                .config
293                .agent
294                .git_revert_mode
295                .unwrap_or(crate::contracts::GitRevertMode::Ask),
296            output_handler: None,
297            output_stream: runner::OutputStream::Terminal,
298            revert_prompt: None,
299            phase_type: PhaseType::SinglePhase,
300            session_id: None,
301            retry_policy,
302        },
303        runutil::RunnerErrorMessages {
304            log_label: "task updater",
305            interrupted_msg: "Task updater interrupted: agent run was canceled.",
306            timeout_msg: "Task updater timed out: agent run exceeded time limit. Changes in the working tree were reverted; review repo state manually.",
307            terminated_msg: "Task updater terminated: agent was stopped by a signal. Review uncommitted changes before rerunning.",
308            non_zero_msg: |code| {
309                format!(
310                    "Task updater failed: agent exited with a non-zero code ({}). Changes in the working tree were reverted; review repo state before rerunning.",
311                    code
312                )
313            },
314            other_msg: |err| {
315                format!(
316                    "Task updater failed: agent could not be started or encountered an error. Error: {:#}",
317                    err
318                )
319            },
320        },
321    )?;
322
323    // Load, validate, and save queue after update with automatic backup restoration on failure
324    let after = load_validate_and_save_queue_after_update(resolved, &backup_path, max_depth)?;
325
326    // Load done_after again since it may have been modified during update
327    let done_after = queue::load_queue_or_default(&resolved.done_path)
328        .with_context(|| format!("read done {}", resolved.done_path.display()))?;
329
330    // Look up the task after update - it may have been moved to done.jsonc or removed
331    match after.tasks.iter().find(|t| t.id.trim() == task_id) {
332        Some(after_task) => {
333            let after_json = serde_json::to_string(after_task)?;
334
335            if before_json == after_json {
336                log::info!("Task {} updated. No changes detected.", task_id);
337            } else {
338                let changed_fields = compare_task_fields(&before_json, &after_json)?;
339                log::info!(
340                    "Task {} updated. Changed fields: {}",
341                    task_id,
342                    changed_fields.join(", ")
343                );
344            }
345        }
346        None => {
347            // Task not in queue after update - check if it was moved to done.jsonc
348            match done_after.tasks.iter().find(|t| t.id.trim() == task_id) {
349                Some(done_task) => {
350                    let after_json = serde_json::to_string(done_task)?;
351
352                    if before_json == after_json {
353                        log::info!("Task {} moved to done.jsonc. No changes detected.", task_id);
354                    } else {
355                        let changed_fields = compare_task_fields(&before_json, &after_json)?;
356                        log::info!(
357                            "Task {} moved to done.jsonc. Changed fields: {}",
358                            task_id,
359                            changed_fields.join(", ")
360                        );
361                    }
362                }
363                None => {
364                    log::warn!(
365                        "Task {} was removed during update and not found in done.jsonc.",
366                        task_id
367                    );
368                }
369            }
370        }
371    }
372
373    Ok(())
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::config::Resolved;
380    use crate::contracts::{Config, QueueFile, Task, TaskStatus};
381    use std::collections::HashMap;
382    use tempfile::TempDir;
383
384    fn task_with_timestamps(
385        id: &str,
386        status: TaskStatus,
387        created_at: Option<&str>,
388        updated_at: Option<&str>,
389    ) -> Task {
390        Task {
391            id: id.to_string(),
392            status,
393            title: "Test task".to_string(),
394            description: None,
395            priority: Default::default(),
396            tags: vec!["tag".to_string()],
397            scope: vec!["file".to_string()],
398            evidence: vec!["observed".to_string()],
399            plan: vec!["do thing".to_string()],
400            notes: vec![],
401            request: Some("test request".to_string()),
402            agent: None,
403            created_at: created_at.map(|s| s.to_string()),
404            updated_at: updated_at.map(|s| s.to_string()),
405            completed_at: None,
406            started_at: None,
407            scheduled_start: None,
408            depends_on: vec![],
409            blocks: vec![],
410            relates_to: vec![],
411            duplicates: None,
412            custom_fields: HashMap::new(),
413            estimated_minutes: None,
414            actual_minutes: None,
415            parent_id: None,
416        }
417    }
418
419    fn create_test_resolved(temp: &TempDir) -> Result<Resolved> {
420        let repo_root = temp.path().to_path_buf();
421        let ralph_dir = repo_root.join(".ralph");
422        std::fs::create_dir_all(&ralph_dir)?;
423
424        Ok(Resolved {
425            config: Config::default(),
426            repo_root,
427            queue_path: ralph_dir.join("queue.json"),
428            done_path: ralph_dir.join("done.jsonc"),
429            id_prefix: "RQ".to_string(),
430            id_width: 4,
431            global_config_path: None,
432            project_config_path: None,
433        })
434    }
435
436    #[test]
437    fn restore_queue_from_backup_success() -> Result<()> {
438        let temp = TempDir::new()?;
439        let queue_path = temp.path().join("queue.json");
440        let backup_path = temp.path().join("queue.json.backup");
441
442        // Create original queue
443        let original = QueueFile {
444            version: 1,
445            tasks: vec![task_with_timestamps(
446                "RQ-0001",
447                TaskStatus::Todo,
448                Some("2026-01-18T00:00:00Z"),
449                Some("2026-01-18T00:00:00Z"),
450            )],
451        };
452        queue::save_queue(&queue_path, &original)?;
453
454        // Create backup
455        queue::save_queue(&backup_path, &original)?;
456
457        // Corrupt the queue
458        std::fs::write(&queue_path, "corrupted json")?;
459
460        // Restore from backup
461        restore_queue_from_backup(&queue_path, &backup_path)?;
462
463        // Verify restored
464        let restored = queue::load_queue(&queue_path)?;
465        assert_eq!(restored.tasks.len(), 1);
466        assert_eq!(restored.tasks[0].id, "RQ-0001");
467
468        Ok(())
469    }
470
471    #[test]
472    fn load_validate_and_save_queue_restores_on_parse_failure() -> Result<()> {
473        let temp = TempDir::new()?;
474        let resolved = create_test_resolved(&temp)?;
475
476        // Create valid initial queue with all required fields
477        let initial = QueueFile {
478            version: 1,
479            tasks: vec![task_with_timestamps(
480                "RQ-0001",
481                TaskStatus::Todo,
482                Some("2026-01-18T00:00:00Z"),
483                Some("2026-01-18T00:00:00Z"),
484            )],
485        };
486        queue::save_queue(&resolved.queue_path, &initial)?;
487
488        // Create backup
489        let backup_dir = resolved.repo_root.join(".ralph/cache");
490        let backup_path = queue::backup_queue(&resolved.queue_path, &backup_dir)?;
491
492        // Corrupt the queue with invalid JSON
493        std::fs::write(&resolved.queue_path, "{ not valid json }")?;
494
495        // Attempt to load/validate/save - should fail and restore backup
496        let result = load_validate_and_save_queue_after_update(&resolved, &backup_path, 10);
497
498        // Should return error
499        assert!(result.is_err());
500        let err_msg = result.unwrap_err().to_string();
501        assert!(
502            err_msg.contains("restored queue from backup"),
503            "Error should mention backup restoration: {}",
504            err_msg
505        );
506
507        // Verify queue was restored to backup content
508        let restored_content = std::fs::read_to_string(&resolved.queue_path)?;
509        let restored: QueueFile = serde_json::from_str(&restored_content)?;
510        assert_eq!(restored.tasks.len(), 1);
511        assert_eq!(restored.tasks[0].id, "RQ-0001");
512
513        Ok(())
514    }
515
516    #[test]
517    fn load_validate_and_save_queue_restores_on_validation_failure() -> Result<()> {
518        let temp = TempDir::new()?;
519        let resolved = create_test_resolved(&temp)?;
520
521        // Create valid initial queue with all required fields
522        let initial = QueueFile {
523            version: 1,
524            tasks: vec![task_with_timestamps(
525                "RQ-0001",
526                TaskStatus::Todo,
527                Some("2026-01-18T00:00:00Z"),
528                Some("2026-01-18T00:00:00Z"),
529            )],
530        };
531        queue::save_queue(&resolved.queue_path, &initial)?;
532
533        // Create backup
534        let backup_dir = resolved.repo_root.join(".ralph/cache");
535        let backup_path = queue::backup_queue(&resolved.queue_path, &backup_dir)?;
536
537        // Replace queue with JSON that parses but fails semantic validation
538        // (missing required timestamps)
539        std::fs::write(
540            &resolved.queue_path,
541            r#"{"version":1,"tasks":[{"id":"RQ-0001","title":"Test","status":"todo","tags":[],"scope":[],"evidence":[],"plan":[],"notes":[],"depends_on":[],"blocks":[],"relates_to":[],"custom_fields":{}}]}"#,
542        )?;
543
544        // Attempt to load/validate/save - should fail and restore backup
545        let result = load_validate_and_save_queue_after_update(&resolved, &backup_path, 10);
546
547        // Should return error
548        assert!(result.is_err());
549        let err_msg = result.unwrap_err().to_string();
550        assert!(
551            err_msg.contains("restored queue from backup"),
552            "Error should mention backup restoration: {}",
553            err_msg
554        );
555
556        // Verify queue was restored to backup content
557        let restored_content = std::fs::read_to_string(&resolved.queue_path)?;
558        let restored: QueueFile = serde_json::from_str(&restored_content)?;
559        assert_eq!(restored.tasks.len(), 1);
560        assert_eq!(restored.tasks[0].id, "RQ-0001");
561
562        Ok(())
563    }
564
565    #[test]
566    fn load_validate_and_save_queue_succeeds_with_valid_queue() -> Result<()> {
567        let temp = TempDir::new()?;
568        let resolved = create_test_resolved(&temp)?;
569
570        // Create valid initial queue
571        let initial = QueueFile {
572            version: 1,
573            tasks: vec![task_with_timestamps(
574                "RQ-0001",
575                TaskStatus::Todo,
576                Some("2026-01-18T00:00:00Z"),
577                Some("2026-01-18T00:00:00Z"),
578            )],
579        };
580        queue::save_queue(&resolved.queue_path, &initial)?;
581
582        // Create backup
583        let backup_dir = resolved.repo_root.join(".ralph/cache");
584        let backup_path = queue::backup_queue(&resolved.queue_path, &backup_dir)?;
585
586        // Replace queue with another valid queue (simulating a successful update)
587        let updated = QueueFile {
588            version: 1,
589            tasks: vec![{
590                let mut t = task_with_timestamps(
591                    "RQ-0001",
592                    TaskStatus::Todo,
593                    Some("2026-01-18T00:00:00Z"),
594                    Some("2026-01-19T00:00:00Z"), // updated timestamp
595                );
596                t.title = "Updated title".to_string();
597                t
598            }],
599        };
600        queue::save_queue(&resolved.queue_path, &updated)?;
601
602        // Should succeed
603        let result = load_validate_and_save_queue_after_update(&resolved, &backup_path, 10);
604        assert!(result.is_ok());
605
606        // Verify the updated content is preserved
607        let final_content = std::fs::read_to_string(&resolved.queue_path)?;
608        let final_queue: QueueFile = serde_json::from_str(&final_content)?;
609        assert_eq!(final_queue.tasks.len(), 1);
610        assert_eq!(final_queue.tasks[0].title, "Updated title");
611
612        Ok(())
613    }
614}