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:
egregore 2026-02-02 12:39:09 +00:00
parent 091a25a76c
commit 44be09cd04

View file

@ -45,6 +45,8 @@ struct Ticket {
created_at: String,
claimed_at: Option<String>,
completed_at: Option<String>,
#[serde(default)]
last_activity_at: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
@ -76,6 +78,8 @@ struct OrientResponse {
rules: Vec<String>,
available_context: Vec<String>,
recent_history: Vec<HistoryEvent>,
#[serde(skip_serializing_if = "Option::is_none")]
stuck_tickets_warning: Option<String>,
}
#[derive(Serialize)]
@ -134,16 +138,30 @@ fn orient() -> Response {
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 suggested_ticket: Option<Ticket> = None;
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) {
for entry in entries.flatten() {
if let Some(ticket) = read_json::<Ticket>(&entry.path()) {
if ticket.status == "claimed" {
current_ticket = Some(ticket);
// 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);
}
} else if ticket.status == "open" && ticket.priority < best_priority {
best_priority = ticket.priority;
suggested_ticket = Some(ticket);
@ -179,6 +197,15 @@ fn orient() -> Response {
// Load rules from file
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 {
project: ProjectInfo {
name: "Egregore".to_string(),
@ -191,6 +218,7 @@ fn orient() -> Response {
rules: rules_data.rules,
available_context,
recent_history,
stuck_tickets_warning,
};
json_response(response)
@ -267,6 +295,7 @@ fn create_ticket(request: &Request) -> Response {
}
let new_id = format!("{:03}", max_id + 1);
let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let ticket = Ticket {
id: new_id.clone(),
title: body.title,
@ -276,9 +305,10 @@ fn create_ticket(request: &Request) -> Response {
context: body.context.unwrap_or_default(),
acceptance: body.acceptance.unwrap_or_default(),
notes: vec![],
created_at: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
created_at: now.clone(),
claimed_at: None,
completed_at: None,
last_activity_at: Some(now),
};
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));
}
let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").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) {
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)),
};
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.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) {
Ok(_) => json_response(ticket),
@ -539,12 +647,14 @@ fn handle_request(request: &Request) -> Response {
// Tickets
(GET) ["/tickets"] => { list_tickets() },
(GET) ["/tickets/stuck"] => { stuck_tickets(request) },
(POST) ["/tickets"] => { create_ticket(request) },
(GET) ["/tickets/{id}", id: String] => { get_ticket(&id) },
(PATCH) ["/tickets/{id}", id: String] => { update_ticket(&id, request) },
(POST) ["/tickets/{id}/claim", id: String] => { claim_ticket(&id) },
(POST) ["/tickets/{id}/complete", id: String] => { complete_ticket(&id, request) },
(POST) ["/tickets/{id}/note", id: String] => { add_ticket_note(&id, request) },
(POST) ["/tickets/{id}/unclaim", id: String] => { unclaim_ticket(&id) },
// Context
(GET) ["/context"] => { list_context() },