diff --git a/src-tauri/src/commands/orchestrator.rs b/src-tauri/src/commands/orchestrator.rs index 2cd4216..d17c6f8 100644 --- a/src-tauri/src/commands/orchestrator.rs +++ b/src-tauri/src/commands/orchestrator.rs @@ -1,8 +1,9 @@ use crate::error::AppError; -use crate::models::ticket::ProcessedTicket; +use crate::models::project::Project; +use crate::models::ticket::{ProcessedTicket, RetryFromStep}; use crate::models::worktree::Worktree; use crate::AppState; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tauri::State; #[derive(Debug, Clone, Serialize)] @@ -11,6 +12,128 @@ pub struct TicketResult { pub worktree: Option, } +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RetryStep { + Analyst, + Developer, + Review, +} + +impl RetryStep { + fn to_model_step(self) -> RetryFromStep { + match self { + RetryStep::Analyst => RetryFromStep::Analyst, + RetryStep::Developer => RetryFromStep::Developer, + RetryStep::Review => RetryFromStep::Review, + } + } +} + +fn can_retry_ticket_status(status: &str) -> bool { + matches!(status, "Error" | "Done" | "NoFix" | "Cancelled") +} + +fn has_non_empty_text(value: &Option) -> bool { + value + .as_deref() + .map(|text| !text.trim().is_empty()) + .unwrap_or(false) +} + +fn validate_retry_step_preconditions(ticket: &ProcessedTicket, step: RetryStep) -> Result<(), AppError> { + match step { + RetryStep::Analyst => Ok(()), + RetryStep::Developer => { + if has_non_empty_text(&ticket.analyst_report) { + Ok(()) + } else { + Err(AppError::from( + "Cannot retry developer step without an analyst report".to_string(), + )) + } + } + RetryStep::Review => { + if !has_non_empty_text(&ticket.analyst_report) { + return Err(AppError::from( + "Cannot retry review step without an analyst report".to_string(), + )); + } + if !has_non_empty_text(&ticket.developer_report) { + return Err(AppError::from( + "Cannot retry review step without a developer report".to_string(), + )); + } + if ticket + .worktree_path + .as_deref() + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + && ticket + .branch_name + .as_deref() + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + { + Ok(()) + } else { + Err(AppError::from( + "Cannot retry review step without worktree metadata".to_string(), + )) + } + } + } +} + +fn retry_ticket_internal( + state: &State<'_, AppState>, + ticket_id: &str, + step: RetryStep, +) -> Result<(), AppError> { + let cleanup_target: Option<(Worktree, Option)> = { + let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; + let ticket = ProcessedTicket::get_by_id(&conn, ticket_id)?; + + if !can_retry_ticket_status(&ticket.status) { + return Err(AppError::from(format!( + "Cannot retry ticket with status '{}'", + ticket.status + ))); + } + + validate_retry_step_preconditions(&ticket, step)?; + ProcessedTicket::reset_for_retry_from_step(&conn, ticket_id, step.to_model_step())?; + + if !matches!(step, RetryStep::Analyst | RetryStep::Developer) { + None + } else if let Some(wt) = Worktree::get_by_ticket_id(&conn, ticket_id)? { + if wt.status == "Active" { + let project = Project::get_by_id(&conn, &ticket.project_id)?; + Some((wt, Some(project.path))) + } else { + Some((wt, None)) + } + } else { + None + } + }; + + if let Some((worktree, Some(path))) = &cleanup_target { + let _ = crate::services::worktree_manager::delete_worktree( + path, + &worktree.path, + &worktree.branch_name, + ); + } + + if let Some((worktree, _)) = cleanup_target { + let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; + Worktree::delete(&conn, &worktree.id)?; + } + + Ok(()) +} + #[tauri::command] pub fn get_ticket_result( state: State<'_, AppState>, @@ -24,55 +147,16 @@ pub fn get_ticket_result( #[tauri::command] pub fn retry_ticket(state: State<'_, AppState>, ticket_id: String) -> Result<(), AppError> { - let active_worktree_cleanup: Option<(crate::models::worktree::Worktree, String)> = { - let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; - let ticket = ProcessedTicket::get_by_id(&conn, &ticket_id)?; + retry_ticket_internal(&state, &ticket_id, RetryStep::Analyst) +} - if ticket.status != "Error" && ticket.status != "Done" && ticket.status != "Cancelled" { - return Err(AppError::from(format!( - "Cannot retry ticket with status '{}'", - ticket.status - ))); - } - - ProcessedTicket::update_status(&conn, &ticket_id, "Pending")?; - conn.execute( - "UPDATE processed_tickets SET analyst_report = NULL, developer_report = NULL, review_report = NULL, \ - worktree_path = NULL, branch_name = NULL, processed_at = NULL WHERE id = ?1", - rusqlite::params![ticket_id], - )?; - - let cleanup_target = if let Some(wt) = Worktree::get_by_ticket_id(&conn, &ticket_id)? { - if wt.status == "Active" { - let project = - crate::models::project::Project::get_by_id(&conn, &ticket.project_id)?; - Some((wt, project.path)) - } else { - Some((wt, String::new())) - } - } else { - None - }; - - cleanup_target - }; - - if let Some((wt, project_path)) = &active_worktree_cleanup { - if wt.status == "Active" { - let _ = crate::services::worktree_manager::delete_worktree( - project_path, - &wt.path, - &wt.branch_name, - ); - } - } - - if let Some((wt, _)) = active_worktree_cleanup { - let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; - Worktree::delete(&conn, &wt.id)?; - } - - Ok(()) +#[tauri::command] +pub fn retry_ticket_step( + state: State<'_, AppState>, + ticket_id: String, + step: RetryStep, +) -> Result<(), AppError> { + retry_ticket_internal(&state, &ticket_id, step) } #[tauri::command] @@ -81,7 +165,7 @@ pub async fn cancel_ticket(state: State<'_, AppState>, ticket_id: String) -> Res let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; let ticket = ProcessedTicket::get_by_id(&conn, &ticket_id)?; - if ticket.status == "Done" || ticket.status == "Cancelled" { + if ticket.status == "Done" || ticket.status == "NoFix" || ticket.status == "Cancelled" { return Err(AppError::from(format!( "Cannot cancel ticket with status '{}'", ticket.status @@ -93,7 +177,7 @@ pub async fn cancel_ticket(state: State<'_, AppState>, ticket_id: String) -> Res let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; let ticket = ProcessedTicket::get_by_id(&conn, &ticket_id)?; - if ticket.status == "Done" || ticket.status == "Cancelled" { + if ticket.status == "Done" || ticket.status == "NoFix" || ticket.status == "Cancelled" { return Err(AppError::from(format!( "Cannot cancel ticket with status '{}'", ticket.status diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index 2fffa40..346f32c 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -12,9 +12,7 @@ const TASK_LIST_TRUNCATION_NOTICE: &str = "\n\n[... contenu tronque pour preserver la fluidite de l'interface ...]"; fn truncate_for_task_list(value: Option, max_bytes: usize) -> Option { - let Some(content) = value else { - return None; - }; + let content = value?; if content.len() <= max_bytes { return Some(content); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 478df5c..a4808b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -115,6 +115,7 @@ pub fn run() { commands::module::set_project_module_enabled, commands::orchestrator::get_ticket_result, commands::orchestrator::retry_ticket, + commands::orchestrator::retry_ticket_step, commands::orchestrator::cancel_ticket, commands::live_agent::create_live_session, commands::live_agent::list_live_sessions, diff --git a/src-tauri/src/models/ticket.rs b/src-tauri/src/models/ticket.rs index 579ada1..d7c3d0d 100644 --- a/src-tauri/src/models/ticket.rs +++ b/src-tauri/src/models/ticket.rs @@ -30,6 +30,13 @@ pub struct ProjectThroughputStats { pub avg_lead_time_seconds: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetryFromStep { + Analyst, + Developer, + Review, +} + fn from_row(row: &rusqlite::Row) -> rusqlite::Result { Ok(ProcessedTicket { id: row.get(0)?, @@ -209,13 +216,13 @@ impl ProcessedTicket { "SELECT COALESCE(SUM( CASE - WHEN pt.status NOT IN ('Done', 'Error', 'Cancelled') THEN 1 + WHEN pt.status NOT IN ('Done', 'NoFix', 'Error', 'Cancelled') THEN 1 ELSE 0 END ), 0) AS backlog_count, COALESCE(SUM( CASE - WHEN pt.status = 'Done' + WHEN pt.status IN ('Done', 'NoFix') AND pt.processed_at IS NOT NULL AND julianday(pt.processed_at) >= julianday(?2) THEN 1 @@ -233,7 +240,7 @@ impl ProcessedTicket { ), 0) AS error_last_24h, AVG( CASE - WHEN pt.status IN ('Done', 'Error') + WHEN pt.status IN ('Done', 'NoFix', 'Error') AND pt.processed_at IS NOT NULL AND julianday(pt.processed_at) >= julianday(?2) THEN (julianday(pt.processed_at) - julianday(pt.detected_at)) * 86400.0 @@ -336,6 +343,35 @@ impl ProcessedTicket { Ok(()) } + pub fn reset_for_retry_from_step( + conn: &Connection, + id: &str, + step: RetryFromStep, + ) -> Result<()> { + let sql = match step { + RetryFromStep::Analyst => { + "UPDATE processed_tickets \ + SET status = 'Pending', analyst_report = NULL, developer_report = NULL, review_report = NULL, \ + worktree_path = NULL, branch_name = NULL, processed_at = NULL \ + WHERE id = ?1" + } + RetryFromStep::Developer => { + "UPDATE processed_tickets \ + SET status = 'Pending', developer_report = NULL, review_report = NULL, \ + worktree_path = NULL, branch_name = NULL, processed_at = NULL \ + WHERE id = ?1" + } + RetryFromStep::Review => { + "UPDATE processed_tickets \ + SET status = 'Pending', review_report = NULL, processed_at = NULL \ + WHERE id = ?1" + } + }; + + conn.execute(sql, params![id])?; + Ok(()) + } + pub fn set_error(conn: &Connection, id: &str, error_message: &str) -> Result<()> { let now = chrono::Utc::now().to_rfc3339(); conn.execute( @@ -712,6 +748,58 @@ mod tests { assert!(updated.processed_at.is_none()); } + #[test] + fn test_reset_for_retry_from_developer_keeps_analysis_and_invalidates_following_steps() { + let (conn, project_id, tracker_id) = setup(); + let ticket = ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "T1", "{}") + .unwrap() + .unwrap(); + + ProcessedTicket::update_status(&conn, &ticket.id, "Done").unwrap(); + ProcessedTicket::set_analyst_report(&conn, &ticket.id, "analysis").unwrap(); + ProcessedTicket::set_developer_report(&conn, &ticket.id, "dev report").unwrap(); + ProcessedTicket::set_review_report(&conn, &ticket.id, "review report").unwrap(); + ProcessedTicket::set_worktree_info(&conn, &ticket.id, "/tmp/wt", "orchai/1").unwrap(); + + ProcessedTicket::reset_for_retry_from_step(&conn, &ticket.id, RetryFromStep::Developer) + .unwrap(); + + let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap(); + assert_eq!(updated.status, "Pending"); + assert_eq!(updated.analyst_report.as_deref(), Some("analysis")); + assert!(updated.developer_report.is_none()); + assert!(updated.review_report.is_none()); + assert!(updated.worktree_path.is_none()); + assert!(updated.branch_name.is_none()); + assert!(updated.processed_at.is_none()); + } + + #[test] + fn test_reset_for_retry_from_review_keeps_analysis_and_developer_reports() { + let (conn, project_id, tracker_id) = setup(); + let ticket = ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "T1", "{}") + .unwrap() + .unwrap(); + + ProcessedTicket::update_status(&conn, &ticket.id, "Done").unwrap(); + ProcessedTicket::set_analyst_report(&conn, &ticket.id, "analysis").unwrap(); + ProcessedTicket::set_developer_report(&conn, &ticket.id, "dev report").unwrap(); + ProcessedTicket::set_review_report(&conn, &ticket.id, "review report").unwrap(); + ProcessedTicket::set_worktree_info(&conn, &ticket.id, "/tmp/wt", "orchai/1").unwrap(); + + ProcessedTicket::reset_for_retry_from_step(&conn, &ticket.id, RetryFromStep::Review) + .unwrap(); + + let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap(); + assert_eq!(updated.status, "Pending"); + assert_eq!(updated.analyst_report.as_deref(), Some("analysis")); + assert_eq!(updated.developer_report.as_deref(), Some("dev report")); + assert!(updated.review_report.is_none()); + assert_eq!(updated.worktree_path.as_deref(), Some("/tmp/wt")); + assert_eq!(updated.branch_name.as_deref(), Some("orchai/1")); + assert!(updated.processed_at.is_none()); + } + #[test] fn test_set_error() { let (conn, project_id, tracker_id) = setup(); @@ -801,17 +889,26 @@ mod tests { &cancelled_detected, None, ); + insert_ticket_with_timestamps( + &conn, + &project_id, + &tracker_id, + 1007, + "NoFix", + &done_detected, + Some(&done_processed), + ); let stats = ProcessedTicket::get_project_throughput_stats(&conn, &project_id).unwrap(); assert_eq!(stats.backlog_count, 2); - assert_eq!(stats.done_last_24h, 1); + assert_eq!(stats.done_last_24h, 2); assert_eq!(stats.error_last_24h, 1); let avg = stats .avg_lead_time_seconds .expect("avg lead time should be available"); - assert!((avg - 10800.0).abs() < 1.0); + assert!((avg - 9600.0).abs() < 1.0); } #[test] diff --git a/src-tauri/src/services/orchestrator.rs b/src-tauri/src/services/orchestrator.rs index 0590048..8a49dab 100644 --- a/src-tauri/src/services/orchestrator.rs +++ b/src-tauri/src/services/orchestrator.rs @@ -28,6 +28,13 @@ pub enum Verdict { NoFix, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProcessStartStep { + Analyst, + Developer, + Review, +} + pub fn build_analyst_prompt(ticket: &ProcessedTicket, project: &Project) -> String { let source_ref = ticket.source_ref.as_deref().unwrap_or("-"); format!( @@ -240,6 +247,12 @@ pub fn parse_verdict(report: &str) -> Verdict { Verdict::FixNeeded } +fn has_non_empty_text(value: Option<&str>) -> bool { + value + .map(|text| !text.trim().is_empty()) + .unwrap_or(false) +} + fn resolve_path_from_working_dir(working_dir: &Path, path: &str) -> Option { let trimmed = path.trim(); if trimmed.is_empty() { @@ -805,91 +818,110 @@ async fn process_ticket( (analyst_agent, developer_agent, reviewer_agent) }; - { - let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; - ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing") - .map_err(|e| format!("update_status failed: {}", e))?; - } - - let _ = app_handle.emit( - "ticket-processing-started", - serde_json::json!({ - "project_id": &project.id, - "ticket_id": &ticket.id, - "artifact_id": ticket.artifact_id, - "step": "analyst", - }), - ); - - let analyst_prompt = append_custom_prompt( - build_analyst_prompt(&ticket, &project), - &analyst_agent.custom_prompt, - ); - let analyst_args = build_agent_cli_args(&analyst_agent, &project.path); - let analyst_result = run_cli_command( - analyst_agent.tool.to_command(), - &analyst_args, - &analyst_prompt, - &project.path, - 600, - TicketCliContext { - app_handle, - ticket_id: &ticket.id, - process_registry, - }, - ) - .await; - - let analyst_report = match analyst_result { - Ok(report) => report, - Err(e) => { - if is_ticket_cancelled(db, &ticket.id)? { - return Ok(true); - } - record_ticket_error( - db, - app_handle, - &project.id, - &ticket.id, - ticket.artifact_id, - &e, - ); - return Ok(true); + let start_step = if has_non_empty_text(ticket.analyst_report.as_deref()) { + if has_non_empty_text(ticket.developer_report.as_deref()) + && has_non_empty_text(ticket.worktree_path.as_deref()) + && has_non_empty_text(ticket.branch_name.as_deref()) + { + ProcessStartStep::Review + } else { + ProcessStartStep::Developer } + } else { + ProcessStartStep::Analyst }; - if is_ticket_cancelled(db, &ticket.id)? { - return Ok(true); - } + let analyst_report = if start_step == ProcessStartStep::Analyst { + { + let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; + ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing") + .map_err(|e| format!("update_status failed: {}", e))?; + } - { - let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; - ProcessedTicket::set_analyst_report(&conn, &ticket.id, &analyst_report) - .map_err(|e| format!("set_analyst_report: {}", e))?; - } + let _ = app_handle.emit( + "ticket-processing-started", + serde_json::json!({ + "project_id": &project.id, + "ticket_id": &ticket.id, + "artifact_id": ticket.artifact_id, + "step": "analyst", + }), + ); + + let analyst_prompt = append_custom_prompt( + build_analyst_prompt(&ticket, &project), + &analyst_agent.custom_prompt, + ); + let analyst_args = build_agent_cli_args(&analyst_agent, &project.path); + let analyst_result = run_cli_command( + analyst_agent.tool.to_command(), + &analyst_args, + &analyst_prompt, + &project.path, + 600, + TicketCliContext { + app_handle, + ticket_id: &ticket.id, + process_registry, + }, + ) + .await; + + let analyst_report = match analyst_result { + Ok(report) => report, + Err(e) => { + if is_ticket_cancelled(db, &ticket.id)? { + return Ok(true); + } + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + &e, + ); + return Ok(true); + } + }; - let verdict = parse_verdict(&analyst_report); - if verdict == Verdict::NoFix { if is_ticket_cancelled(db, &ticket.id)? { return Ok(true); } { let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; - ProcessedTicket::update_status(&conn, &ticket.id, "Done") - .map_err(|e| format!("update_status: {}", e))?; + ProcessedTicket::set_analyst_report(&conn, &ticket.id, &analyst_report) + .map_err(|e| format!("set_analyst_report: {}", e))?; } - let _ = app_handle.emit( - "ticket-processing-done", - serde_json::json!({ - "project_id": &project.id, - "ticket_id": &ticket.id, - "artifact_id": ticket.artifact_id, - }), - ); - notifier::notify_analysis_done(db, app_handle, &project.id, &ticket.id, ticket.artifact_id); - return Ok(true); - } + + let verdict = parse_verdict(&analyst_report); + if verdict == Verdict::NoFix { + if is_ticket_cancelled(db, &ticket.id)? { + return Ok(true); + } + + { + let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; + ProcessedTicket::update_status(&conn, &ticket.id, "NoFix") + .map_err(|e| format!("update_status: {}", e))?; + } + let _ = app_handle.emit( + "ticket-processing-done", + serde_json::json!({ + "project_id": &project.id, + "ticket_id": &ticket.id, + "artifact_id": ticket.artifact_id, + }), + ); + notifier::notify_analysis_done(db, app_handle, &project.id, &ticket.id, ticket.artifact_id); + return Ok(true); + } + + analyst_report + } else { + ticket.analyst_report.clone().unwrap_or_default() + }; { let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; @@ -900,107 +932,150 @@ async fn process_ticket( } } - let worktree_result = - worktree_manager::create_worktree(&project.path, &project.base_branch, ticket.artifact_id); - - if let Err(e) = &worktree_result { - record_ticket_error( - db, - app_handle, - &project.id, - &ticket.id, - ticket.artifact_id, - e, - ); - } - - let (wt_path, branch_name) = worktree_result?; - - { - let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; - ProcessedTicket::set_worktree_info(&conn, &ticket.id, &wt_path, &branch_name) - .map_err(|e| format!("set_worktree_info: {}", e))?; - Worktree::insert(&conn, &ticket.id, &wt_path, &branch_name) - .map_err(|e| format!("insert worktree: {}", e))?; - ProcessedTicket::update_status(&conn, &ticket.id, "Developing") - .map_err(|e| format!("update_status: {}", e))?; - } - - let _ = app_handle.emit( - "ticket-processing-started", - serde_json::json!({ - "project_id": &project.id, - "ticket_id": &ticket.id, - "artifact_id": ticket.artifact_id, - "step": "developer", - }), - ); - - let developer_prompt = append_custom_prompt( - build_developer_prompt(&ticket, &project, &analyst_report, &wt_path), - &developer_agent.custom_prompt, - ); - let developer_args = build_agent_cli_args(&developer_agent, &wt_path); - let developer_result = run_cli_command( - developer_agent.tool.to_command(), - &developer_args, - &developer_prompt, - &wt_path, - 600, - TicketCliContext { - app_handle, - ticket_id: &ticket.id, - process_registry, - }, - ) - .await; - - let developer_report = match developer_result { - Ok(report) => report, - Err(e) => { - if is_ticket_cancelled(db, &ticket.id)? { - return Ok(true); - } + let (wt_path, branch_name, developer_report) = if start_step == ProcessStartStep::Review { + if !has_non_empty_text(ticket.worktree_path.as_deref()) + || !has_non_empty_text(ticket.branch_name.as_deref()) + { record_ticket_error( db, app_handle, &project.id, &ticket.id, ticket.artifact_id, - &e, + "Cannot resume from review step without worktree metadata.", ); return Ok(true); } - }; - if is_ticket_cancelled(db, &ticket.id)? { - return Ok(true); - } + if !has_non_empty_text(ticket.developer_report.as_deref()) { + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + "Cannot resume from review step without developer report.", + ); + return Ok(true); + } + + ( + ticket.worktree_path.clone().unwrap_or_default(), + ticket.branch_name.clone().unwrap_or_default(), + ticket.developer_report.clone().unwrap_or_default(), + ) + } else { + let worktree_result = worktree_manager::create_worktree( + &project.path, + &project.base_branch, + ticket.artifact_id, + ); + + if let Err(e) = &worktree_result { + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + e, + ); + } + + let (wt_path, branch_name) = worktree_result?; + + { + let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; + ProcessedTicket::set_worktree_info(&conn, &ticket.id, &wt_path, &branch_name) + .map_err(|e| format!("set_worktree_info: {}", e))?; + Worktree::insert(&conn, &ticket.id, &wt_path, &branch_name) + .map_err(|e| format!("insert worktree: {}", e))?; + ProcessedTicket::update_status(&conn, &ticket.id, "Developing") + .map_err(|e| format!("update_status: {}", e))?; + } + + let _ = app_handle.emit( + "ticket-processing-started", + serde_json::json!({ + "project_id": &project.id, + "ticket_id": &ticket.id, + "artifact_id": ticket.artifact_id, + "step": "developer", + }), + ); + + let developer_prompt = append_custom_prompt( + build_developer_prompt(&ticket, &project, &analyst_report, &wt_path), + &developer_agent.custom_prompt, + ); + let developer_args = build_agent_cli_args(&developer_agent, &wt_path); + let developer_result = run_cli_command( + developer_agent.tool.to_command(), + &developer_args, + &developer_prompt, + &wt_path, + 600, + TicketCliContext { + app_handle, + ticket_id: &ticket.id, + process_registry, + }, + ) + .await; + + let developer_report = match developer_result { + Ok(report) => report, + Err(e) => { + if is_ticket_cancelled(db, &ticket.id)? { + return Ok(true); + } + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + &e, + ); + return Ok(true); + } + }; + + if is_ticket_cancelled(db, &ticket.id)? { + return Ok(true); + } + + if let Err(validation_error) = + validate_developer_completion(&project, &branch_name, &developer_report) + { + { + let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; + ProcessedTicket::set_developer_report(&conn, &ticket.id, &developer_report) + .map_err(|e| format!("set_developer_report: {}", e))?; + } + + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + &validation_error, + ); + return Ok(true); + } - if let Err(validation_error) = - validate_developer_completion(&project, &branch_name, &developer_report) - { { let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; ProcessedTicket::set_developer_report(&conn, &ticket.id, &developer_report) .map_err(|e| format!("set_developer_report: {}", e))?; } - record_ticket_error( - db, - app_handle, - &project.id, - &ticket.id, - ticket.artifact_id, - &validation_error, - ); - return Ok(true); - } + (wt_path, branch_name, developer_report) + }; { let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; - ProcessedTicket::set_developer_report(&conn, &ticket.id, &developer_report) - .map_err(|e| format!("set_developer_report: {}", e))?; ProcessedTicket::update_status(&conn, &ticket.id, "Reviewing") .map_err(|e| format!("update_status: {}", e))?; } diff --git a/src/components/tickets/TicketDetail.tsx b/src/components/tickets/TicketDetail.tsx index 594dd19..b8d2009 100644 --- a/src/components/tickets/TicketDetail.tsx +++ b/src/components/tickets/TicketDetail.tsx @@ -10,6 +10,7 @@ import { getWorktreeDiff, listLocalBranchesForWorktree, retryTicket, + retryTicketStep, } from "../../lib/api"; import { getErrorMessage } from "../../lib/errors"; import { @@ -258,6 +259,19 @@ export default function TicketDetail() { } } + async function handleRetryDeveloper() { + if (!ticketId) return; + setLoading(true); + try { + await retryTicketStep(ticketId, "developer"); + await loadData(); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + } + async function handleApplyFix() { if (!worktree) return; const normalizedTargetBranch = targetBranch.trim(); @@ -330,6 +344,12 @@ export default function TicketDetail() { }, ]; const sourceLink = buildTicketResourceLink(ticket, resourceConfig); + const canRetryPipeline = + ticket.status === "Error" || + ticket.status === "Done" || + ticket.status === "NoFix" || + ticket.status === "Cancelled"; + const canRetryDeveloperStep = canRetryPipeline && Boolean(ticket.analyst_report?.trim()); return (
@@ -348,13 +368,22 @@ export default function TicketDetail() {
- {(ticket.status === "Error" || ticket.status === "Done" || ticket.status === "Cancelled") && ( + {canRetryDeveloperStep && ( + + )} + {canRetryPipeline && ( )} {(ticket.status === "Pending" || diff --git a/src/components/tickets/TicketList.tsx b/src/components/tickets/TicketList.tsx index b82278a..cf57a6b 100644 --- a/src/components/tickets/TicketList.tsx +++ b/src/components/tickets/TicketList.tsx @@ -83,7 +83,7 @@ export default function TicketList() {
- {["all", "Pending", "Analyzing", "Developing", "Reviewing", "Done", "Error"].map((s) => ( + {["all", "Pending", "Analyzing", "Developing", "Reviewing", "Done", "NoFix", "Error"].map((s) => (