use std::fmt::Write;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, error, info, warn};
use futures_util::future::join_all;
use crate::agent::run_agent;
use crate::board::{BOARD, BoardStore, Ticket, TicketComment, TicketPhase};
use crate::manager_queue::{JobKind, ManagerJob};
use crate::prompt::{load_prompt, substitute};
use crate::session::ticket_session_key;
use crate::ticket_buffer;
use crate::tools::shell::{ShellMode, ShellTool};
use crate::{Role, Tool, Workspace};
const PARALLEL_AGENT_COUNT: usize = 3;
const CIRCUIT_BREAKER_COMMENT_THRESHOLD: usize = 50;
const DIAGNOSTICS_CIRCUIT_BREAKER_MAX_ALLOWED: usize = 4;
const ANALYSIS_THRESHOLD: u8 = 7;
const REVIEW_QA_THRESHOLD: u8 = 9;
#[must_use]
async fn is_ticket_in_phase(board: &BoardStore, ticket_id: &str, expected: TicketPhase) -> bool {
match board.get_ticket_status(ticket_id).await {
Ok(Some(status)) => {
let ok = status == expected;
if !ok {
debug!(
ticket = %ticket_id,
expected = %expected,
actual = %status,
"Ticket moved externally — bailing out",
);
}
ok
}
Ok(None) => {
warn!(ticket = %ticket_id, "Ticket not found — may have been deleted");
false
}
Err(e) => {
warn!(ticket = %ticket_id, error = %e, "Failed to check ticket status");
false
}
}
}
#[must_use]
async fn guard_phase_and_circuit_breaker(
board: &BoardStore,
ticket: &Ticket,
expected_phase: TicketPhase,
label: &str,
) -> bool {
if !is_ticket_in_phase(board, &ticket.id, expected_phase).await {
return false;
}
if trip_circuit_breaker_if_exceeded(board, ticket, expected_phase, label).await {
return false;
}
true
}
enum NotifyPolicy {
Notify,
Buffer,
}
async fn transition_ticket(
board: &BoardStore,
ticket: &Ticket,
expected: TicketPhase,
target: TicketPhase,
notify: NotifyPolicy,
) -> Result<(), String> {
match board
.transition_to(&ticket.id, Some(expected), target)
.await
{
Ok(()) => {
if matches!(notify, NotifyPolicy::Notify) {
notify_ticket(ticket, target).await;
} else if let Some(ws) =
resolve_ticket_workspace(ticket, "cannot buffer transition").await
{
ticket_buffer::push(
&ws.name,
&ticket.id,
ticket.status.as_ref(),
target.as_ref(),
);
}
Ok(())
}
Err(e) => {
debug!(
ticket = %ticket.id,
expected = %expected,
target = %target,
error = %e,
"Failed to update ticket status",
);
Err(e.to_string())
}
}
}
#[must_use]
async fn resolve_ticket_workspace(
ticket: &Ticket,
context: &'static str,
) -> Option<crate::Workspace> {
match crate::workspace::get_by_name(&ticket.workspace_name).await {
Ok(Some(ws)) => Some(ws),
Ok(None) => {
warn!(
ticket = %ticket.id,
workspace_name = %ticket.workspace_name,
"Workspace not found for ticket — {context}",
);
None
}
Err(e) => {
warn!(
ticket = %ticket.id,
workspace_name = %ticket.workspace_name,
error = %e,
"Failed to look up workspace for ticket — {context}",
);
None
}
}
}
async fn bounce_back_to_development(
board: &BoardStore,
ticket: &Ticket,
source_phase: TicketPhase,
log_label: &str,
) {
match board
.transition_to_with_reservation(
&ticket.id,
Some(source_phase),
TicketPhase::ReadyForDevelopment,
true,
)
.await
{
Ok(()) => {
ticket_buffer::push(
&ticket.workspace_name,
&ticket.id,
source_phase.as_ref(),
"ready_for_development",
);
info!(
ticket = %ticket.id,
"{log_label} failed — pipeline reservation set for rework priority",
);
}
Err(e) => {
warn!(
ticket = %ticket.id,
error = %e,
"{log_label} failed but transition to ReadyForDevelopment \
failed — ticket stuck in {phase}, clearing assigned_to for retry",
phase = source_phase.as_ref(),
);
let _ = board.set_assigned_to(&ticket.id, None).await;
}
}
}
async fn notify_ticket(ticket: &Ticket, status: TicketPhase) {
let Some(ws) = resolve_ticket_workspace(ticket, "skipping notification").await else {
error!(
ticket = %ticket.id,
workspace_name = %ticket.workspace_name,
"Workspace resolution failed — notification skipped"
);
return;
};
if status == TicketPhase::Failed && !ws.paused {
if let Err(e) = crate::workspace::store().set_paused(&ws.name, true).await {
warn!(
ticket = %ticket.id,
workspace = %ws.name,
error = %e,
"Failed to pause workspace after ticket failure",
);
} else {
info!(
ticket = %ticket.id,
workspace = %ws.name,
"Workspace paused due to ticket failure",
);
}
}
let transition_log = format!(
"[{}] {}: {} → {}",
ticket.reporter,
ticket.id,
ticket.status,
status.as_ref()
);
let drained = crate::ticket_buffer::drain(&ws.name);
let message = substitute(
&load_prompt("notification.md"),
&[
("{{ticket_id}}", &ticket.id),
("{{ticket_title}}", &ticket.title),
("{{ticket_status}}", status.as_ref()),
("{{transition_log}}", &transition_log),
("{{ticket_updates}}", &drained),
],
);
crate::manager_queue::manager_queue().enqueue(ManagerJob {
content: message,
workspace_name: ws.name,
kind: JobKind::TicketNotify,
});
}
pub async fn run_management() {
if let Some(board) = BOARD.get()
&& let Err(e) = board.reset_inflight_tickets().await
{
error!(error = %e, "Failed to reset in-flight tickets");
}
let interval = Duration::from_secs(1);
loop {
if !crate::shutdown::sleep_or_shutdown(interval).await {
break;
}
if let Err(e) = poll_round().await {
error!(error = %e, "Board poller round failed");
}
}
}
fn spawn_dispatch(board: &'static BoardStore, phase: PollPhase, ticket: Ticket, ws: Workspace) {
let phase_info = phase.info();
let target_phase = phase_info.target;
info!(
ticket = %ticket.id,
title = %ticket.title,
workspace = %ws.name,
"Dispatching {} ticket",
phase_info.role_label,
);
let ticket = Arc::new(ticket);
let ticket_for_failure = Arc::clone(&ticket);
tokio::spawn(async move {
let handle = tokio::spawn(async move {
phase.dispatch(board, ticket, ws).await;
});
match handle.await {
Ok(()) => {
}
Err(join_error) => {
error!(
ticket = %ticket_for_failure.id,
panic = %join_error,
"Dispatch panicked — transitioning ticket to Failed",
);
let _ = board
.add_comment(
&ticket_for_failure.id,
"system",
&format!("❌ Dispatch panicked: {join_error}"),
)
.await;
if let Err(e) = transition_ticket(
board,
&ticket_for_failure,
target_phase,
TicketPhase::Failed,
NotifyPolicy::Notify,
)
.await
{
warn!(
ticket = %ticket_for_failure.id,
error = %e,
"Failed to transition ticket to Failed after dispatch panic",
);
}
}
}
});
}
#[derive(Copy, Clone)]
struct VerifierInfo {
role: Role,
log_label: &'static str,
source: TicketPhase,
success_phase: TicketPhase,
active_phase: TicketPhase,
prompt_template: &'static str,
extraction_prompt_path: &'static str,
}
const REVIEWER_VI: VerifierInfo = VerifierInfo {
role: Role::Reviewer,
log_label: "Reviewers",
source: TicketPhase::DiagnosticsDone,
success_phase: TicketPhase::Reviewed,
active_phase: TicketPhase::InReview,
prompt_template: "review.md",
extraction_prompt_path: "extraction/reviewer.md",
};
const QA_VI: VerifierInfo = VerifierInfo {
role: Role::Qa,
log_label: "QA",
source: TicketPhase::Reviewed,
success_phase: TicketPhase::QaPassed,
active_phase: TicketPhase::InQa,
prompt_template: "qa.md",
extraction_prompt_path: "extraction/qa.md",
};
#[derive(Copy, Clone)]
struct PollPhaseInfo {
source: TicketPhase,
target: TicketPhase,
require_clear_pipeline: bool,
role_label: &'static str,
}
#[derive(Copy, Clone)]
enum PollPhase {
BacklogAnalysis,
EngineerDevelopment,
DiagnosticsCheck,
VerifierCheck(VerifierInfo),
}
impl PollPhase {
fn info(self) -> PollPhaseInfo {
match self {
Self::BacklogAnalysis => PollPhaseInfo {
source: TicketPhase::Backlog,
target: TicketPhase::Analysis,
require_clear_pipeline: false,
role_label: Role::Analyst.as_str(),
},
Self::EngineerDevelopment => PollPhaseInfo {
source: TicketPhase::ReadyForDevelopment,
target: TicketPhase::InDevelopment,
require_clear_pipeline: true,
role_label: Role::Engineer.as_str(),
},
Self::DiagnosticsCheck => PollPhaseInfo {
source: TicketPhase::InDiagnostics,
target: TicketPhase::InDiagnostics,
require_clear_pipeline: false,
role_label: "diagnostics",
},
Self::VerifierCheck(vi) => PollPhaseInfo {
source: vi.source,
target: vi.active_phase,
require_clear_pipeline: false,
role_label: vi.role.as_str(),
},
}
}
async fn dispatch(self, board: &'static BoardStore, ticket: Arc<Ticket>, ws: Workspace) {
match self {
Self::BacklogAnalysis => dispatch_backlog_analysts(board, ticket, ws).await,
Self::EngineerDevelopment => dispatch_engineer(board, ticket, ws).await,
Self::DiagnosticsCheck => dispatch_diagnostics(board, ticket, ws).await,
Self::VerifierCheck(vi) => {
dispatch_verifiers(board, ticket, ws, vi).await;
}
}
}
}
const CLAIM_PHASES: &[PollPhase] = &[
PollPhase::BacklogAnalysis,
PollPhase::EngineerDevelopment,
PollPhase::VerifierCheck(REVIEWER_VI),
PollPhase::VerifierCheck(QA_VI),
];
async fn for_tickets_in_phase(
board: &BoardStore,
phase: TicketPhase,
ws_name: &str,
mut action: impl FnMut(Ticket),
) {
match board.list_tickets_in_phase(phase, ws_name).await {
Ok(tickets) => {
for ticket in tickets {
action(ticket);
}
}
Err(e) => error!(workspace = ws_name, phase = %phase, error = %e, "Phase listing failed"),
}
}
async fn poll_round() -> anyhow::Result<()> {
let board = crate::board::store();
let workspaces = match crate::workspace::store().list().await {
Ok(ws_list) => ws_list,
Err(e) => {
error!(error = %e, "Failed to list workspaces");
return Ok(());
}
};
for ws in &workspaces {
if !ws.paused {
for &phase in CLAIM_PHASES {
let info = phase.info();
let ticket = match board
.claim_ticket_in_workspace(
info.source,
info.target,
&ws.name,
info.require_clear_pipeline,
)
.await
{
Ok(Some(t)) => {
ticket_buffer::push(
&ws.name,
&t.id,
info.source.as_ref(),
t.status.as_ref(),
);
t
}
Ok(None) => continue,
Err(e) => {
error!(
workspace = %ws.name,
phase = %info.role_label,
error = %e,
"Claim failed, skipping remaining phases for workspace",
);
break;
}
};
spawn_dispatch(board, phase, ticket, ws.clone());
}
}
for_tickets_in_phase(board, TicketPhase::InDiagnostics, &ws.name, |ticket| {
if ticket.assigned_to.is_some() {
return; }
spawn_dispatch(board, PollPhase::DiagnosticsCheck, ticket, ws.clone());
})
.await;
for_tickets_in_phase(board, TicketPhase::QaPassed, &ws.name, |ticket| {
let ws = ws.clone();
tokio::spawn(async move {
finalize_qa_passed(board, ticket, ws).await;
});
})
.await;
}
Ok(())
}
async fn dispatch_engineer(board: &BoardStore, ticket: Arc<Ticket>, ws: Workspace) {
let session_key = ticket_session_key(&ticket.id, Role::Engineer.as_str());
if !guard_phase_and_circuit_breaker(board, &ticket, TicketPhase::InDevelopment, "Engineer")
.await
{
return;
}
let last_eng_pos = ticket
.comments
.iter()
.rposition(|c| c.role == Role::Engineer.as_str());
let feedback: Vec<&str> = ticket
.comments
.iter()
.skip(last_eng_pos.map_or(0, |i| i + 1))
.map(|c| c.content.as_str())
.collect();
let mut message = "Implement the ticket described in the system prompt.".to_string();
if !feedback.is_empty() {
let _ = write!(
message,
"\n\n---\nNew feedback to address:\n{}",
feedback.join("\n---\n")
);
}
if let Err(e) = board.set_assigned_to(&ticket.id, Some(&session_key)).await {
warn!(
ticket = %ticket.id,
error = %e,
"Failed to set engineer assignee — agent will run unassigned"
);
}
let (_agent, response) =
run_agent(session_key, Role::Engineer, &ws, Some(&ticket), &message).await;
if !is_ticket_in_phase(board, &ticket.id, TicketPhase::InDevelopment).await {
return;
}
let (comment_text, target_phase, notify) = if let Some(ref text) = response {
(
text.as_str(),
TicketPhase::InDiagnostics,
NotifyPolicy::Buffer,
)
} else {
("Agent failed", TicketPhase::Failed, NotifyPolicy::Notify)
};
let _ = board
.add_comment(&ticket.id, Role::Engineer.as_str(), comment_text)
.await;
if let Err(e) = transition_ticket(
board,
&ticket,
TicketPhase::InDevelopment,
target_phase,
notify,
)
.await
{
let verb = match target_phase {
TicketPhase::InDiagnostics => "completed",
_ => "failed",
};
warn!(
ticket = %ticket.id,
error = %e,
"Engineer {verb} but transition to {phase} failed — ticket stuck in {stuck}",
phase = target_phase.as_ref(),
stuck = TicketPhase::InDevelopment.as_ref(),
);
}
}
async fn move_ticket_to_done(board: &BoardStore, ticket: &Ticket, reason: &str) {
info!(ticket = %ticket.id, "{reason}");
if let Err(e) = transition_ticket(
board,
ticket,
TicketPhase::QaPassed,
TicketPhase::Done,
NotifyPolicy::Notify,
)
.await
{
warn!(
ticket = %ticket.id,
error = %e,
"QA passed but transition to Done failed",
);
}
}
async fn finalize_qa_passed(board: &BoardStore, ticket: Ticket, ws: Workspace) {
let repo_path = ws.as_path();
if !crate::diff_parse::git_is_installed().await {
move_ticket_to_done(
board,
&ticket,
"Git not installed — moving to Done without commit",
)
.await;
return;
}
if !crate::diff_parse::is_git_repo(repo_path).await {
move_ticket_to_done(
board,
&ticket,
"Not a git repo — moving to Done without commit",
)
.await;
return;
}
let has_changes = match crate::diff_parse::run_git_status(repo_path).await {
Ok(output) => !output.trim().is_empty(),
Err(e) => {
warn!(
ticket = %ticket.id,
error = %e,
"Failed to check git status — staying in QaPassed for retry"
);
return;
}
};
if !has_changes {
move_ticket_to_done(
board,
&ticket,
"Clean working tree — moving to Done without commit",
)
.await;
return;
}
match crate::diff_parse::run_git_commit(repo_path, &ticket.title).await {
Ok(commit_info) => {
if let Err(e) = board
.set_commit_info(
&ticket.id,
&commit_info.hash,
commit_info.lines_added,
commit_info.lines_removed,
)
.await
{
warn!(ticket = %ticket.id, "Failed to store commit info: {e}");
}
let short_hash = commit_info.hash.get(..7).unwrap_or(&commit_info.hash);
let comment = format_commit_summary(
short_hash,
commit_info.lines_added,
commit_info.lines_removed,
);
if let Err(e) = board.add_comment(&ticket.id, "system", &comment).await {
warn!(ticket = %ticket.id, "Failed to add commit comment: {e}");
}
move_ticket_to_done(
board,
&ticket,
&format!("Committed {short_hash}, moving to Done"),
)
.await;
}
Err(e) => {
error!(
ticket = %ticket.id,
error = %e,
"Commit failed — staying in QaPassed for retry"
);
}
}
}
fn format_commit_summary(short_hash: &str, added: i64, removed: i64) -> String {
match (added, removed) {
(0, 0) => format!("Committed as `{short_hash}` (no changes)"),
(a, 0) => format!("Committed as `{short_hash}` (+{a})"),
(0, r) => format!("Committed as `{short_hash}` (-{r})"),
(a, r) => format!("Committed as `{short_hash}` (+{a}/-{r})"),
}
}
#[allow(clippy::too_many_lines)]
async fn dispatch_diagnostics(board: &'static BoardStore, ticket: Arc<Ticket>, ws: Workspace) {
match board.claim_diagnostics(&ticket.id).await {
Ok(true) => {} Ok(false) => {
warn!(
ticket = %ticket.id,
"Diagnostics claim failed — ticket already claimed or moved out of InDiagnostics"
);
return;
}
Err(e) => {
error!(
ticket = %ticket.id,
error = %e,
"Diagnostics claim error — bailing out",
);
return;
}
}
let diag = match crate::workspace::store().get_diagnostics(&ws.name).await {
Ok(Some(cmds)) if !cmds.is_empty() => Some(cmds),
Ok(Some(_) | None) => None,
Err(e) => {
warn!(
ticket = %ticket.id,
error = %e,
"Failed to load diagnostics for workspace — transitioning to DiagnosticsDone"
);
None
}
};
let Some(diag) = diag else {
if let Err(e) = transition_ticket(
board,
&ticket,
TicketPhase::InDiagnostics,
TicketPhase::DiagnosticsDone,
NotifyPolicy::Buffer,
)
.await
{
warn!(
ticket = %ticket.id,
error = %e,
"No diagnostics commands — failed to transition to DiagnosticsDone",
);
}
return;
};
if trip_diagnostics_circuit_breaker_if_exceeded(board, &ticket).await {
return;
}
let mut comment = String::from("🔍 Auto-diagnostics");
let mut all_passed = true;
let mut failed_at: &str = "";
for (label, cmd_opt) in diag.commands() {
let Some(cmd) = cmd_opt else {
continue;
};
let _ = write!(comment, "\n\n{label} ({cmd}):\n");
match ShellTool::new(ShellMode::Full)
.execute(&ws, serde_json::json!({"command": cmd}))
.await
{
Ok(output) => {
let failed = output.contains("[exit status: ");
let display = if output.is_empty() {
"(no output)".to_string()
} else {
output
};
comment.push_str(&display);
if failed {
all_passed = false;
failed_at = label;
break;
}
}
Err(e) => {
comment.push_str(&e.to_string());
all_passed = false;
failed_at = label;
break;
}
}
}
let target = if all_passed {
comment.push_str("\n\n---\n✅ All diagnostics passed");
TicketPhase::DiagnosticsDone
} else {
let _ = write!(comment, "\n\n---\n❌ Diagnostics failed at {failed_at}");
TicketPhase::ReadyForDevelopment
};
let _ = board.add_comment(&ticket.id, "diagnostics", &comment).await;
if target == TicketPhase::ReadyForDevelopment {
bounce_back_to_development(board, &ticket, TicketPhase::InDiagnostics, "Diagnostics").await;
} else if let Err(e) = transition_ticket(
board,
&ticket,
TicketPhase::InDiagnostics,
target,
NotifyPolicy::Buffer,
)
.await
{
warn!(
ticket = %ticket.id,
error = %e,
"Diagnostics completed but transition to DiagnosticsDone \
failed — clearing assigned_to for retry",
);
let _ = board.set_assigned_to(&ticket.id, None).await;
}
}
#[must_use]
async fn trip_diagnostics_circuit_breaker_if_exceeded(board: &BoardStore, ticket: &Ticket) -> bool {
run_circuit_breaker(
board,
ticket,
TicketPhase::InDiagnostics,
TicketPhase::Failed,
DIAGNOSTICS_CIRCUIT_BREAKER_MAX_ALLOWED,
|comments| {
comments
.iter()
.filter(|c| {
c.role == "diagnostics"
&& c.content.starts_with("🔍 Auto-diagnostics")
&& c.content.contains('❌')
&& !c.content.contains("Circuit breaker")
})
.count()
},
|count| {
format!(
"🔍 Auto-diagnostics\n\n❌ Circuit breaker: {count} prior diagnostic \
failures. Failing ticket and pausing workspace."
)
},
"Diagnostics",
)
.await
}
struct ParallelVerdict {
response: String,
verdict: Option<crate::Verdict>,
}
async fn extract_parallel_verdicts(
results: Vec<(crate::Agent, String)>,
extraction_prompt: &str,
) -> Vec<ParallelVerdict> {
let retry_prompt = crate::prompt::load_prompt("extraction/retry.md");
let futures: Vec<_> = results
.into_iter()
.map(|(agent, response)| {
let extraction_prompt = extraction_prompt.to_string();
let retry_prompt = retry_prompt.clone();
async move {
if response.is_empty() {
return ParallelVerdict {
response,
verdict: None,
};
}
let verdict = agent
.extract_structured::<crate::Verdict>(&extraction_prompt, &retry_prompt, 5)
.await
.ok();
ParallelVerdict { response, verdict }
}
})
.collect();
join_all(futures).await
}
async fn run_parallel_with_extraction(
ticket: &Arc<Ticket>,
ws: &Workspace,
role: Role,
prompt: &str,
extraction_prompt: &str,
) -> Vec<ParallelVerdict> {
let suffix = crate::generate_suffix();
let futures: Vec<_> = (0..PARALLEL_AGENT_COUNT)
.map(move |i| {
let ticket = Arc::clone(ticket);
let prompt = prompt.to_string();
let ws = ws.clone();
let base = ticket_session_key(&ticket.id, role.as_str());
let session_key = format!("{base}_{i}_{suffix}");
async move {
let (agent, response) =
run_agent(session_key, role, &ws, Some(&ticket), &prompt).await;
(agent, response.unwrap_or_default())
}
})
.collect();
let results = join_all(futures).await;
extract_parallel_verdicts(results, extraction_prompt).await
}
#[must_use]
fn verdict_passes(verdict: Option<&crate::Verdict>) -> bool {
verdict.is_some_and(|v| v.score >= REVIEW_QA_THRESHOLD)
}
fn format_verdict_body(verdict: &crate::Verdict) -> String {
let mut text = verdict.critique.as_deref().unwrap_or_default().to_string();
if !verdict.issues_detected.is_empty() {
if !text.is_empty() {
text.push_str("\n\n");
}
text.push_str("Issues:\n");
for issue in &verdict.issues_detected {
let _ = writeln!(text, "- {issue}");
}
}
text
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VerdictFilter {
All,
FailingOnly,
}
fn format_verdict_comment(
r: &ParallelVerdict,
comment_role: &str,
filter: VerdictFilter,
) -> Option<String> {
if let Some(v) = &r.verdict {
if filter == VerdictFilter::FailingOnly && verdict_passes(Some(v)) {
return None; }
let comment = format_verdict_body(v);
if comment.is_empty() {
return Some(format!(
"{} agent scored {}/10 with no specific critique provided.",
comment_role, v.score
));
}
return Some(comment);
}
if r.response.is_empty() {
Some(format!(
"{comment_role} agent failed to produce a response — counting as a failure."
))
} else {
Some(format!(
"{comment_role} produced a response but verdict extraction failed — \
treating as a failure."
))
}
}
async fn record_verdict_comments(
board: &BoardStore,
ticket_id: &str,
results: &[ParallelVerdict],
role_str: &str,
filter: VerdictFilter,
) {
for (i, r) in results.iter().enumerate() {
let role_label = format!("{role_str}_{}", i + 1);
if let Some(comment) = format_verdict_comment(r, &role_label, filter) {
let _ = board.add_comment(ticket_id, &role_label, &comment).await;
}
}
}
async fn dispatch_backlog_analysts(board: &BoardStore, ticket: Arc<Ticket>, ws: Workspace) {
if !guard_phase_and_circuit_breaker(board, &ticket, TicketPhase::Analysis, "Analysts").await {
return;
}
let message = load_prompt("analyze.md");
let extraction_prompt = load_prompt("extraction/analyst.md");
let parallel_results =
run_parallel_with_extraction(&ticket, &ws, Role::Analyst, &message, &extraction_prompt)
.await;
if !is_ticket_in_phase(board, &ticket.id, TicketPhase::Analysis).await {
return;
}
handle_analyst_verdicts(board, &ticket, ¶llel_results).await;
}
async fn handle_analyst_verdicts(board: &BoardStore, ticket: &Ticket, results: &[ParallelVerdict]) {
record_verdict_comments(
board,
&ticket.id,
results,
Role::Analyst.as_str(),
VerdictFilter::All,
)
.await;
let nonempty_count = results.iter().filter(|r| !r.response.is_empty()).count();
let total = results.len();
let mut lgtm = 0usize;
let mut minor_issues = 0usize;
let mut potential_blockers = 0usize;
let mut missing_analysis = 0usize;
for r in results {
match &r.verdict {
Some(v) if v.score >= ANALYSIS_THRESHOLD && v.issues_detected.is_empty() => lgtm += 1,
Some(v) if v.score >= ANALYSIS_THRESHOLD => minor_issues += 1,
Some(_) => potential_blockers += 1,
None => missing_analysis += 1,
}
}
let summary = build_analyst_summary(
total,
lgtm,
minor_issues,
potential_blockers,
missing_analysis,
);
let _ = board.add_comment(&ticket.id, "system", &summary).await;
let extracted_count = total - missing_analysis;
let passing_count = lgtm + minor_issues;
let all_passed = passing_count == PARALLEL_AGENT_COUNT;
let target = if all_passed {
TicketPhase::Planning
} else {
TicketPhase::Paused
};
if let Err(e) = transition_ticket(
board,
ticket,
TicketPhase::Analysis,
target,
NotifyPolicy::Notify,
)
.await
{
warn!(
ticket = %ticket.id,
error = %e,
"Analyst verdicts completed but transition to {phase} failed — ticket stuck in {stuck}",
phase = target.as_ref(),
stuck = TicketPhase::Analysis.as_ref(),
);
return;
}
if all_passed {
info!(
ticket = %ticket.id,
nonempty_count,
"Backlog analysis complete — all analysts passed (≥ {ANALYSIS_THRESHOLD}/10)",
);
} else {
info!(
ticket = %ticket.id,
nonempty_count,
extracted_count,
passing_count,
"Backlog analysis incomplete — paused ({nonempty_count}/{PARALLEL_AGENT_COUNT} responded, \
{extracted_count} extracted, {passing_count} passed)",
);
}
}
fn build_analyst_summary(
total: usize,
lgtm: usize,
minor_issues: usize,
potential_blockers: usize,
missing_analysis: usize,
) -> String {
let description = [
(lgtm, "LGTM"),
(minor_issues, "found minor issues"),
(potential_blockers, "flagged potential blockers"),
(missing_analysis, "provided no analysis"),
]
.iter()
.filter(|&&(count, _label)| count > 0)
.map(|&(count, label)| {
if count == total {
format!("All {label}")
} else {
format!("{count} {label}")
}
})
.collect::<Vec<_>>()
.join(", ");
format!("{total} analysts reviewed this ticket. {description}.")
}
#[allow(clippy::too_many_arguments)]
#[must_use]
async fn run_circuit_breaker(
board: &BoardStore,
ticket: &Ticket,
expected: TicketPhase,
target: TicketPhase,
threshold: usize,
count_fn: impl Fn(&[TicketComment]) -> usize,
comment_text: impl Fn(usize) -> String,
log_label: &str,
) -> bool {
let comments = match board.get_comments(&ticket.id).await {
Ok(c) => c,
Err(e) => {
warn!(
ticket = %ticket.id,
error = %e,
"Failed to fetch comments for circuit breaker — proceeding anyway"
);
return false;
}
};
let count = count_fn(&comments);
if count <= threshold {
return false;
}
info!(
ticket = %ticket.id,
count,
threshold,
log_label,
"Circuit breaker tripped at {count}/{threshold} ({log_label}) — failing ticket and pausing workspace"
);
let _ = board
.add_comment(&ticket.id, "system", &comment_text(count))
.await;
if let Err(e) = transition_ticket(board, ticket, expected, target, NotifyPolicy::Notify).await {
warn!(
ticket = %ticket.id,
target = %target,
error = %e,
"Circuit breaker tripped but transition to {target} failed",
);
return true;
}
true
}
#[must_use]
async fn trip_circuit_breaker_if_exceeded(
board: &BoardStore,
ticket: &Ticket,
expected: TicketPhase,
log_label: &str,
) -> bool {
run_circuit_breaker(
board,
ticket,
expected,
TicketPhase::Failed,
CIRCUIT_BREAKER_COMMENT_THRESHOLD,
<[TicketComment]>::len,
|count| {
format!(
"Failed after {count} comments — ticket has accumulated too many comments \
(circuit breaker, threshold: {CIRCUIT_BREAKER_COMMENT_THRESHOLD}). \
Workspace paused for human investigation."
)
},
log_label,
)
.await
}
async fn process_verdict_results(
board: &BoardStore,
ticket: &Ticket,
results: &[ParallelVerdict],
verifier: VerifierInfo,
) {
record_verdict_comments(
board,
&ticket.id,
results,
verifier.role.as_str(),
VerdictFilter::FailingOnly,
)
.await;
let any_failed = results.iter().any(|r| !verdict_passes(r.verdict.as_ref()));
if any_failed
&& trip_circuit_breaker_if_exceeded(
board,
ticket,
verifier.active_phase,
verifier.log_label,
)
.await
{
return;
}
if any_failed {
bounce_back_to_development(board, ticket, verifier.active_phase, verifier.log_label).await;
} else if let Err(e) = transition_ticket(
board,
ticket,
verifier.active_phase,
verifier.success_phase,
NotifyPolicy::Buffer,
)
.await
{
warn!(
ticket = %ticket.id,
error = %e,
"{role} verdicts completed but transition to {phase} failed — ticket stuck in {stuck}",
role = verifier.log_label,
phase = verifier.success_phase.as_ref(),
stuck = verifier.active_phase.as_ref(),
);
} else {
info!(
ticket = %ticket.id,
"{log_label}: all passed (≥ {REVIEW_QA_THRESHOLD}/10)",
log_label = verifier.log_label,
);
}
}
async fn dispatch_verifiers(
board: &BoardStore,
ticket: Arc<Ticket>,
ws: Workspace,
vi: VerifierInfo,
) {
if !is_ticket_in_phase(board, &ticket.id, vi.active_phase).await {
return;
}
let engineer_response = ticket
.comments
.iter()
.rev()
.find(|c| c.role == Role::Engineer.as_str())
.map(|c| &c.content)
.map_or("(no output)", String::as_str);
let prompt = substitute(
&crate::prompt::load_prompt(vi.prompt_template),
&[("{{agent_response}}", engineer_response)],
);
let extraction_prompt = crate::prompt::load_prompt(vi.extraction_prompt_path);
let results =
run_parallel_with_extraction(&ticket, &ws, vi.role, &prompt, &extraction_prompt).await;
let all_failed = results.iter().all(|r| r.verdict.is_none());
if all_failed {
let _ = board
.add_comment(
&ticket.id,
"system",
&format!(
"❌ All {label} agents failed to produce verdicts — \
ticket marked as Failed.",
label = vi.log_label,
),
)
.await;
if let Err(e) = transition_ticket(
board,
&ticket,
vi.active_phase,
TicketPhase::Failed,
NotifyPolicy::Notify,
)
.await
{
warn!(
ticket = %ticket.id,
error = %e,
role = %vi.log_label,
"All {label} agents failed but transition to Failed also failed",
label = vi.log_label,
);
}
return;
}
if !is_ticket_in_phase(board, &ticket.id, vi.active_phase).await {
return;
}
process_verdict_results(board, &ticket, &results, vi).await;
}