Add stuck ticket detection and recovery mechanisms
- Add last_activity_at field to tickets, updated on claim and note additions
- Add GET /tickets/stuck?hours=N endpoint to find abandoned tickets
- Add POST /tickets/{id}/unclaim to release stuck tickets back to open
- Add stuck_tickets_warning to orient response when tickets are stuck >24h
- Unclaim automatically adds timestamped note with duration
This enables system resilience when conversations are cleared mid-ticket.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
091a25a76c
commit
44be09cd04
1 changed files with 115 additions and 5 deletions
118
src/main.rs
118
src/main.rs
|
|
@ -45,6 +45,8 @@ struct Ticket {
|
||||||
created_at: String,
|
created_at: String,
|
||||||
claimed_at: Option<String>,
|
claimed_at: Option<String>,
|
||||||
completed_at: Option<String>,
|
completed_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
last_activity_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
|
@ -76,6 +78,8 @@ struct OrientResponse {
|
||||||
rules: Vec<String>,
|
rules: Vec<String>,
|
||||||
available_context: Vec<String>,
|
available_context: Vec<String>,
|
||||||
recent_history: Vec<HistoryEvent>,
|
recent_history: Vec<HistoryEvent>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
stuck_tickets_warning: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -134,16 +138,30 @@ fn orient() -> Response {
|
||||||
updated_at: "unknown".to_string(),
|
updated_at: "unknown".to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load tickets and find current/suggested
|
// Load tickets and find current/suggested, also check for stuck tickets
|
||||||
let mut current_ticket: Option<Ticket> = None;
|
let mut current_ticket: Option<Ticket> = None;
|
||||||
let mut suggested_ticket: Option<Ticket> = None;
|
let mut suggested_ticket: Option<Ticket> = None;
|
||||||
let mut best_priority: u8 = 255;
|
let mut best_priority: u8 = 255;
|
||||||
|
let mut stuck_count: usize = 0;
|
||||||
|
let now = Utc::now();
|
||||||
|
let stuck_threshold_hours: i64 = 24;
|
||||||
|
|
||||||
if let Ok(entries) = fs::read_dir(&tickets_dir) {
|
if let Ok(entries) = fs::read_dir(&tickets_dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
if let Some(ticket) = read_json::<Ticket>(&entry.path()) {
|
if let Some(ticket) = read_json::<Ticket>(&entry.path()) {
|
||||||
if ticket.status == "claimed" {
|
if ticket.status == "claimed" {
|
||||||
|
// Check if this ticket is stuck (claimed > 24 hours ago)
|
||||||
|
let is_stuck = ticket.claimed_at.as_ref().map_or(false, |claimed_at| {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(claimed_at)
|
||||||
|
.map(|t| now.signed_duration_since(t).num_hours() >= stuck_threshold_hours)
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_stuck {
|
||||||
|
stuck_count += 1;
|
||||||
|
} else {
|
||||||
current_ticket = Some(ticket);
|
current_ticket = Some(ticket);
|
||||||
|
}
|
||||||
} else if ticket.status == "open" && ticket.priority < best_priority {
|
} else if ticket.status == "open" && ticket.priority < best_priority {
|
||||||
best_priority = ticket.priority;
|
best_priority = ticket.priority;
|
||||||
suggested_ticket = Some(ticket);
|
suggested_ticket = Some(ticket);
|
||||||
|
|
@ -179,6 +197,15 @@ fn orient() -> Response {
|
||||||
// Load rules from file
|
// Load rules from file
|
||||||
let rules_data: Rules = read_json(&rules_path).unwrap_or(Rules { rules: vec![] });
|
let rules_data: Rules = read_json(&rules_path).unwrap_or(Rules { rules: vec![] });
|
||||||
|
|
||||||
|
let stuck_tickets_warning = if stuck_count > 0 {
|
||||||
|
Some(format!(
|
||||||
|
"{} ticket(s) have been claimed for >{}h and may be stuck. Use GET /tickets/stuck to view them.",
|
||||||
|
stuck_count, stuck_threshold_hours
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let response = OrientResponse {
|
let response = OrientResponse {
|
||||||
project: ProjectInfo {
|
project: ProjectInfo {
|
||||||
name: "Egregore".to_string(),
|
name: "Egregore".to_string(),
|
||||||
|
|
@ -191,6 +218,7 @@ fn orient() -> Response {
|
||||||
rules: rules_data.rules,
|
rules: rules_data.rules,
|
||||||
available_context,
|
available_context,
|
||||||
recent_history,
|
recent_history,
|
||||||
|
stuck_tickets_warning,
|
||||||
};
|
};
|
||||||
|
|
||||||
json_response(response)
|
json_response(response)
|
||||||
|
|
@ -267,6 +295,7 @@ fn create_ticket(request: &Request) -> Response {
|
||||||
}
|
}
|
||||||
let new_id = format!("{:03}", max_id + 1);
|
let new_id = format!("{:03}", max_id + 1);
|
||||||
|
|
||||||
|
let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
let ticket = Ticket {
|
let ticket = Ticket {
|
||||||
id: new_id.clone(),
|
id: new_id.clone(),
|
||||||
title: body.title,
|
title: body.title,
|
||||||
|
|
@ -276,9 +305,10 @@ fn create_ticket(request: &Request) -> Response {
|
||||||
context: body.context.unwrap_or_default(),
|
context: body.context.unwrap_or_default(),
|
||||||
acceptance: body.acceptance.unwrap_or_default(),
|
acceptance: body.acceptance.unwrap_or_default(),
|
||||||
notes: vec![],
|
notes: vec![],
|
||||||
created_at: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
created_at: now.clone(),
|
||||||
claimed_at: None,
|
claimed_at: None,
|
||||||
completed_at: None,
|
completed_at: None,
|
||||||
|
last_activity_at: Some(now),
|
||||||
};
|
};
|
||||||
|
|
||||||
let path = tickets_dir.join(format!("{}.json", new_id));
|
let path = tickets_dir.join(format!("{}.json", new_id));
|
||||||
|
|
@ -341,8 +371,10 @@ fn claim_ticket(id: &str) -> Response {
|
||||||
return error_response(400, &format!("Ticket {} is not open (status: {})", id, ticket.status));
|
return error_response(400, &format!("Ticket {} is not open (status: {})", id, ticket.status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
ticket.status = "claimed".to_string();
|
ticket.status = "claimed".to_string();
|
||||||
ticket.claimed_at = Some(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string());
|
ticket.claimed_at = Some(timestamp.clone());
|
||||||
|
ticket.last_activity_at = Some(timestamp);
|
||||||
|
|
||||||
match write_json(&path, &ticket) {
|
match write_json(&path, &ticket) {
|
||||||
Ok(_) => json_response(ticket),
|
Ok(_) => json_response(ticket),
|
||||||
|
|
@ -410,8 +442,84 @@ fn add_ticket_note(id: &str, request: &Request) -> Response {
|
||||||
Err(e) => return error_response(400, &format!("Invalid JSON: {}", e)),
|
Err(e) => return error_response(400, &format!("Invalid JSON: {}", e)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string();
|
let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
ticket.notes.push(format!("[{}] {}", timestamp, body.note));
|
ticket.notes.push(format!("[{}] {}", timestamp, body.note));
|
||||||
|
ticket.last_activity_at = Some(timestamp);
|
||||||
|
|
||||||
|
match write_json(&path, &ticket) {
|
||||||
|
Ok(_) => json_response(ticket),
|
||||||
|
Err(e) => error_response(500, &e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stuck_tickets(request: &Request) -> Response {
|
||||||
|
let tickets_dir = PathBuf::from(DATA_DIR).join("tickets");
|
||||||
|
|
||||||
|
// Parse hours parameter (default 24)
|
||||||
|
let hours: i64 = request
|
||||||
|
.get_param("hours")
|
||||||
|
.and_then(|h| h.parse().ok())
|
||||||
|
.unwrap_or(24);
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut stuck: Vec<Ticket> = vec![];
|
||||||
|
|
||||||
|
if let Ok(entries) = fs::read_dir(&tickets_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if let Some(ticket) = read_json::<Ticket>(&entry.path()) {
|
||||||
|
if ticket.status == "claimed" {
|
||||||
|
if let Some(claimed_at) = &ticket.claimed_at {
|
||||||
|
if let Ok(claimed_time) = chrono::DateTime::parse_from_rfc3339(claimed_at) {
|
||||||
|
let elapsed = now.signed_duration_since(claimed_time);
|
||||||
|
if elapsed.num_hours() >= hours {
|
||||||
|
stuck.push(ticket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by claimed_at (oldest first)
|
||||||
|
stuck.sort_by(|a, b| a.claimed_at.cmp(&b.claimed_at));
|
||||||
|
|
||||||
|
json_response(json!({
|
||||||
|
"stuck_tickets": stuck,
|
||||||
|
"threshold_hours": hours,
|
||||||
|
"count": stuck.len()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unclaim_ticket(id: &str) -> Response {
|
||||||
|
let path = PathBuf::from(DATA_DIR).join("tickets").join(format!("{}.json", id));
|
||||||
|
|
||||||
|
let mut ticket: Ticket = match read_json(&path) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return error_response(404, &format!("Ticket {} not found", id)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if ticket.status != "claimed" {
|
||||||
|
return error_response(400, &format!("Ticket {} is not claimed (status: {})", id, ticket.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add automatic note about unclaim
|
||||||
|
let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
let claimed_duration = if let Some(claimed_at) = &ticket.claimed_at {
|
||||||
|
if let Ok(claimed_time) = chrono::DateTime::parse_from_rfc3339(claimed_at) {
|
||||||
|
let elapsed = Utc::now().signed_duration_since(claimed_time);
|
||||||
|
format!(" (was claimed for {} hours)", elapsed.num_hours())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
ticket.notes.push(format!("[{}] UNCLAIMED: Ticket released back to open status{}", timestamp, claimed_duration));
|
||||||
|
ticket.status = "open".to_string();
|
||||||
|
ticket.last_activity_at = Some(timestamp);
|
||||||
|
// Preserve claimed_at for historical reference
|
||||||
|
|
||||||
match write_json(&path, &ticket) {
|
match write_json(&path, &ticket) {
|
||||||
Ok(_) => json_response(ticket),
|
Ok(_) => json_response(ticket),
|
||||||
|
|
@ -539,12 +647,14 @@ fn handle_request(request: &Request) -> Response {
|
||||||
|
|
||||||
// Tickets
|
// Tickets
|
||||||
(GET) ["/tickets"] => { list_tickets() },
|
(GET) ["/tickets"] => { list_tickets() },
|
||||||
|
(GET) ["/tickets/stuck"] => { stuck_tickets(request) },
|
||||||
(POST) ["/tickets"] => { create_ticket(request) },
|
(POST) ["/tickets"] => { create_ticket(request) },
|
||||||
(GET) ["/tickets/{id}", id: String] => { get_ticket(&id) },
|
(GET) ["/tickets/{id}", id: String] => { get_ticket(&id) },
|
||||||
(PATCH) ["/tickets/{id}", id: String] => { update_ticket(&id, request) },
|
(PATCH) ["/tickets/{id}", id: String] => { update_ticket(&id, request) },
|
||||||
(POST) ["/tickets/{id}/claim", id: String] => { claim_ticket(&id) },
|
(POST) ["/tickets/{id}/claim", id: String] => { claim_ticket(&id) },
|
||||||
(POST) ["/tickets/{id}/complete", id: String] => { complete_ticket(&id, request) },
|
(POST) ["/tickets/{id}/complete", id: String] => { complete_ticket(&id, request) },
|
||||||
(POST) ["/tickets/{id}/note", id: String] => { add_ticket_note(&id, request) },
|
(POST) ["/tickets/{id}/note", id: String] => { add_ticket_note(&id, request) },
|
||||||
|
(POST) ["/tickets/{id}/unclaim", id: String] => { unclaim_ticket(&id) },
|
||||||
|
|
||||||
// Context
|
// Context
|
||||||
(GET) ["/context"] => { list_context() },
|
(GET) ["/context"] => { list_context() },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue