1use 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
71fn 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
81fn load_validate_and_save_queue_after_update(
91 resolved: &config::Resolved,
92 backup_path: &Path,
93 max_depth: u8,
94) -> Result<QueueFile> {
95 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 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 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 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 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 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 let after = load_validate_and_save_queue_after_update(resolved, &backup_path, max_depth)?;
325
326 let done_after = queue::load_queue_or_default(&resolved.done_path)
328 .with_context(|| format!("read done {}", resolved.done_path.display()))?;
329
330 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 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 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 queue::save_queue(&backup_path, &original)?;
456
457 std::fs::write(&queue_path, "corrupted json")?;
459
460 restore_queue_from_backup(&queue_path, &backup_path)?;
462
463 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 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 let backup_dir = resolved.repo_root.join(".ralph/cache");
490 let backup_path = queue::backup_queue(&resolved.queue_path, &backup_dir)?;
491
492 std::fs::write(&resolved.queue_path, "{ not valid json }")?;
494
495 let result = load_validate_and_save_queue_after_update(&resolved, &backup_path, 10);
497
498 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 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 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 let backup_dir = resolved.repo_root.join(".ralph/cache");
535 let backup_path = queue::backup_queue(&resolved.queue_path, &backup_dir)?;
536
537 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 let result = load_validate_and_save_queue_after_update(&resolved, &backup_path, 10);
546
547 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 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 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 let backup_dir = resolved.repo_root.join(".ralph/cache");
584 let backup_path = queue::backup_queue(&resolved.queue_path, &backup_dir)?;
585
586 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"), );
596 t.title = "Updated title".to_string();
597 t
598 }],
599 };
600 queue::save_queue(&resolved.queue_path, &updated)?;
601
602 let result = load_validate_and_save_queue_after_update(&resolved, &backup_path, 10);
604 assert!(result.is_ok());
605
606 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}