feat: relancer une étape de ticket et distinguer le statut no-fix
Some checks failed
Quality / quality (push) Failing after 31s

This commit is contained in:
thibaud-lclr 2026-04-22 09:35:45 +02:00
parent 618c30ef84
commit 81bf897348
9 changed files with 506 additions and 217 deletions

View file

@ -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<Worktree>,
}
#[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<String>) -> 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<String>)> = {
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

View file

@ -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<String>, max_bytes: usize) -> Option<String> {
let Some(content) = value else {
return None;
};
let content = value?;
if content.len() <= max_bytes {
return Some(content);

View file

@ -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,

View file

@ -30,6 +30,13 @@ pub struct ProjectThroughputStats {
pub avg_lead_time_seconds: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetryFromStep {
Analyst,
Developer,
Review,
}
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<ProcessedTicket> {
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]

View file

@ -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<PathBuf> {
let trimmed = path.trim();
if trimmed.is_empty() {
@ -805,6 +818,20 @@ async fn process_ticket(
(analyst_agent, developer_agent, reviewer_agent)
};
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
};
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")
@ -876,7 +903,7 @@ async fn process_ticket(
{
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
ProcessedTicket::update_status(&conn, &ticket.id, "Done")
ProcessedTicket::update_status(&conn, &ticket.id, "NoFix")
.map_err(|e| format!("update_status: {}", e))?;
}
let _ = app_handle.emit(
@ -891,6 +918,11 @@ async fn process_ticket(
return Ok(true);
}
analyst_report
} else {
ticket.analyst_report.clone().unwrap_or_default()
};
{
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
let current = ProcessedTicket::get_by_id(&conn, &ticket.id)
@ -900,8 +932,44 @@ async fn process_ticket(
}
}
let worktree_result =
worktree_manager::create_worktree(&project.path, &project.base_branch, ticket.artifact_id);
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,
"Cannot resume from review step without worktree metadata.",
);
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(
@ -1001,6 +1069,13 @@ async fn process_ticket(
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))?;
}
(wt_path, branch_name, developer_report)
};
{
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
ProcessedTicket::update_status(&conn, &ticket.id, "Reviewing")
.map_err(|e| format!("update_status: {}", e))?;
}

View file

@ -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 (
<div className={pageClass}>
@ -348,13 +368,22 @@ export default function TicketDetail() {
</h2>
</div>
<div className="flex gap-2">
{(ticket.status === "Error" || ticket.status === "Done" || ticket.status === "Cancelled") && (
{canRetryDeveloperStep && (
<button
onClick={handleRetryDeveloper}
disabled={loading}
className={buttonClass({ variant: "secondary", size: "sm" })}
>
Retry dev
</button>
)}
{canRetryPipeline && (
<button
onClick={handleRetry}
disabled={loading}
className={buttonClass({ variant: "primary", size: "sm" })}
>
Retry
Retry all
</button>
)}
{(ticket.status === "Pending" ||

View file

@ -83,7 +83,7 @@ export default function TicketList() {
</div>
<div className="mb-4 flex gap-2">
{["all", "Pending", "Analyzing", "Developing", "Reviewing", "Done", "Error"].map((s) => (
{["all", "Pending", "Analyzing", "Developing", "Reviewing", "Done", "NoFix", "Error"].map((s) => (
<button
key={s}
onClick={() => setFilter(s)}

View file

@ -6,6 +6,7 @@ const statusClasses: Record<string, string> = {
Developing: "bg-purple-100 text-purple-700",
Reviewing: "bg-indigo-100 text-indigo-700",
Done: "bg-green-100 text-green-700",
NoFix: "bg-emerald-100 text-emerald-700",
Error: "bg-red-100 text-red-700",
Cancelled: "bg-gray-100 text-gray-500",
};

View file

@ -247,9 +247,13 @@ export async function getRuntimeActivity(projectId: string): Promise<RuntimeActi
export async function getTicketResult(ticketId: string): Promise<TicketResult> {
return invoke("get_ticket_result", { ticketId });
}
export type TicketRetryStep = "analyst" | "developer" | "review";
export async function retryTicket(ticketId: string): Promise<void> {
return invoke("retry_ticket", { ticketId });
}
export async function retryTicketStep(ticketId: string, step: TicketRetryStep): Promise<void> {
return invoke("retry_ticket_step", { ticketId, step });
}
export async function cancelTicket(ticketId: string): Promise<void> {
return invoke("cancel_ticket", { ticketId });
}