use super::*;
use crate::prompt::{load_prompt, substitute};
use crate::util::test::make_ticket;
use crate::util::test::{
JobRowBuilder, create_test_workspace, expect_ticket, expect_ticket_phase,
init_management_test_stores, init_test_stores,
};
use crate::workspace::test_ws_named;
#[test]
fn bounce_breaker_max_is_ten_and_comment_breakers_trip_before() {
let bounce_max = crate::joint_verdict::MAX_BOUNCES;
assert_eq!(bounce_max, 10, "MAX_BOUNCES must be 10");
for kind in [
CircuitBreakerKind::Sanitation,
CircuitBreakerKind::Diagnostics,
] {
assert!(
kind.max_count() < bounce_max,
"{kind:?}.max_count() ({}) must be less than the bounce budget ({bounce_max})",
kind.max_count(),
);
}
}
#[tokio::test]
async fn circuit_breaker_self_counting_prevention() {
init_test_stores().await;
{
let msg = CircuitBreakerKind::Sanitation.trip_message(99, 3);
let trip_comment = TicketComment {
role: SYSTEM_ROLE.to_owned(),
content: msg,
created_at: String::new(),
};
assert!(
CircuitBreakerKind::Sanitation
.should_trip(&[trip_comment])
.is_none(),
"Sanitation breaker must NOT count its own trip comment \
(role mismatch: SYSTEM_ROLE != SANITATION_ROLE)",
);
}
{
let msg = CircuitBreakerKind::Diagnostics.trip_message(99, 4);
let failed_marker = load_prompt("pipeline/diagnostics_failed.md");
assert!(
!msg.contains(failed_marker.as_str()),
"Diagnostics trip message must not contain the diagnostics_failed.md marker string \
({failed_marker:?}), otherwise self-counting would occur on re-evaluation. Trip message: {msg:?}",
);
let trip_comment = TicketComment {
role: SYSTEM_ROLE.to_owned(),
content: msg,
created_at: String::new(),
};
assert!(
CircuitBreakerKind::Diagnostics
.should_trip(&[trip_comment])
.is_none(),
"Diagnostics breaker must NOT count its own trip comment \
(role mismatch: SYSTEM_ROLE != DIAGNOSTICS_ROLE)",
);
}
}
#[tokio::test]
async fn circuit_breaker_moves_other_ready_for_development_tickets_to_planning() {
init_management_test_stores().await;
let ws_a = test_ws_named("/ws_a", "ws_a");
let ws_b = test_ws_named("/ws_b", "ws_b");
let trip_id = make_ticket(
board(),
&ws_a,
"Trip Ticket",
TicketPhase::ReadyForDevelopment,
)
.await;
let victim_id = make_ticket(
board(),
&ws_a,
"Victim Ticket",
TicketPhase::ReadyForDevelopment,
)
.await;
let other_ws_id = make_ticket(
board(),
&ws_b,
"Other Workspace Ticket",
TicketPhase::ReadyForDevelopment,
)
.await;
for _ in 0..4 {
add_breaker_failure(CircuitBreakerKind::Sanitation, &trip_id).await;
}
let ticket_a = expect_ticket(board(), &trip_id).await;
let tripped = try_trip_circuit_breaker(
&ticket_a,
TicketPhase::ReadyForDevelopment,
Some(CircuitBreakerKind::Sanitation),
"test",
)
.await;
assert!(tripped, "circuit breaker should have tripped");
drain_ready_for_development_siblings(&ticket_a).await;
{
let ticket_a = expect_ticket(board(), &trip_id).await;
assert_eq!(
ticket_a.phase,
TicketPhase::Failed,
"tripped ticket A should be Failed"
);
}
{
let ticket_b = expect_ticket(board(), &victim_id).await;
assert_eq!(
ticket_b.phase,
TicketPhase::Planning,
"other ReadyForDevelopment ticket B in same workspace should be Planning"
);
}
{
let ticket_c = expect_ticket(board(), &other_ws_id).await;
assert_eq!(
ticket_c.phase,
TicketPhase::ReadyForDevelopment,
"ticket C in different workspace must not be moved"
);
}
}
async fn setup_db_workspace(suffix: &str) -> crate::Workspace {
init_management_test_stores().await;
let ws_name = format!("ws_{suffix}");
let ws_path = format!("/tmp/test_{suffix}");
create_test_workspace(&ws_path, &ws_name).await
}
async fn setup_ticket(
ws_path: &str,
ws_name: &str,
title: &str,
phase: TicketPhase,
) -> (crate::Workspace, String) {
init_management_test_stores().await;
let ws = test_ws_named(ws_path, ws_name);
let ticket_id = make_ticket(board(), &ws, title, phase).await;
(ws, ticket_id)
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn transition_ticket_to_done_buffer_and_notify() {
let _lock = crate::util::test::retry_tests_lock();
let ws = setup_db_workspace("drains_buffer").await;
let first_id = make_ticket(board(), &ws, "Ticket A", TicketPhase::QaPassed).await;
let second_id = make_ticket(board(), &ws, "Ticket B", TicketPhase::QaPassed).await;
let ticket_a = expect_ticket(board(), &first_id).await;
transition_ticket_to_done(
&ticket_a,
TicketPhase::QaPassed,
"Test — ticket A done, B still active",
)
.await;
let intermediate = crate::ticket_buffer::drain("ws_drains_buffer");
assert!(
!intermediate.is_empty(),
"After first QaPassed → Done with other active tickets: \
should have buffered the notification (got empty buffer)",
);
let ticket_b = expect_ticket(board(), &second_id).await;
transition_ticket_to_done(
&ticket_b,
TicketPhase::QaPassed,
"Test — ticket B done, last ticket",
)
.await;
for (id, label) in [(&first_id, "A"), (&second_id, "B")] {
let t = expect_ticket(board(), id).await;
assert_eq!(t.phase, TicketPhase::Done, "Ticket {label} should be Done");
let comments = board().get_comments(id).await.expect("get_comments");
assert!(
comments.iter().any(|c| c.role == SYSTEM_ROLE),
"Ticket {label}: expected SYSTEM_ROLE comment from transition_ticket_to_done"
);
}
let drained = crate::ticket_buffer::drain("ws_drains_buffer");
assert!(
drained.is_empty(),
"Buffer should be empty after last ticket's Notify drains it",
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
async fn breaker_counts_failures() {
struct BreakerCase {
name: &'static str,
kind: CircuitBreakerKind,
source_phase: TicketPhase,
log_label: &'static str,
ws_suffix: &'static str,
}
init_management_test_stores().await;
let cases = [
BreakerCase {
name: "Sanitation",
kind: CircuitBreakerKind::Sanitation,
source_phase: TicketPhase::InSanitation,
log_label: "Sanitation",
ws_suffix: "san_breaker_test",
},
BreakerCase {
name: "Diagnostics",
kind: CircuitBreakerKind::Diagnostics,
source_phase: TicketPhase::InDiagnostics,
log_label: "Diagnostics",
ws_suffix: "diag_breaker_test",
},
];
for case in &cases {
let max_count = case.kind.max_count();
let below_max = max_count; let trip_at = max_count + 1;
let ticket_id = make_ticket(
board(),
&test_ws_named("/tmp/test", case.ws_suffix),
&format!("{} Breaker Test", case.log_label),
case.source_phase,
)
.await;
for _ in 0..below_max {
add_breaker_failure(case.kind, &ticket_id).await;
}
let ticket = expect_ticket(board(), &ticket_id).await;
assert!(
!try_trip_circuit_breaker(&ticket, case.source_phase, Some(case.kind), case.log_label,)
.await,
"case {}: should NOT trip with {} failures (max: {})",
case.name,
below_max,
case.kind.max_count(),
);
let phase = expect_ticket_phase(board(), &ticket_id).await;
assert_eq!(
phase,
case.source_phase,
"case {}: phase should remain {} after {} non-tripping failures (max: {})",
case.name,
case.source_phase,
below_max,
case.kind.max_count(),
);
for _ in below_max..trip_at {
add_breaker_failure(case.kind, &ticket_id).await;
}
let ticket = expect_ticket(board(), &ticket_id).await;
let tripped =
try_trip_circuit_breaker(&ticket, case.source_phase, Some(case.kind), case.log_label)
.await;
assert!(
tripped,
"case {}: should trip with {} failures (max: {}, {} > {})",
case.name,
trip_at,
case.kind.max_count(),
trip_at,
case.kind.max_count(),
);
let phase = expect_ticket_phase(board(), &ticket_id).await;
assert_eq!(
phase,
TicketPhase::Failed,
"case {}: circuit breaker should transition to Failed",
case.name,
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get_comments");
let has_breaker_comment = comments
.iter()
.any(|c| c.role == SYSTEM_ROLE && c.content.to_lowercase().contains("circuit breaker"));
assert!(
has_breaker_comment,
"case {}: should have a SYSTEM_ROLE comment with the circuit breaker message \
(containing 'circuit breaker')",
case.name,
);
}
}
fn pass_verdict() -> crate::Verdict {
crate::Verdict {
score: REVIEW_QA_THRESHOLD,
issues_detected: vec![],
}
}
fn fail_verdict() -> crate::Verdict {
crate::Verdict {
score: 3,
issues_detected: vec!["No timeout check".into()],
}
}
fn no_verdict() -> ParallelVerdict {
ParallelVerdict::NoResponse("agent produced no response".into())
}
async fn add_breaker_failure(kind: CircuitBreakerKind, ticket_id: &str) {
let (role, comment) = match kind {
CircuitBreakerKind::Sanitation => (
SANITATION_ROLE,
substitute(
&load_prompt("pipeline/sanitation_circuit_breaker_comment.md"),
&[
(
"{{sanitation_failed_marker}}",
load_prompt("pipeline/sanitation_failed.md").as_str(),
),
("{{count}}", "1"),
],
),
),
CircuitBreakerKind::Diagnostics => (
DIAGNOSTICS_ROLE,
format!(
"---\n{} test_step",
load_prompt("pipeline/diagnostics_failed.md")
),
),
};
let _ = board().add_comment(ticket_id, role, &comment).await;
}
fn pass_result() -> ParallelVerdict {
ParallelVerdict::Verdict(pass_verdict())
}
fn fail_result() -> ParallelVerdict {
ParallelVerdict::Verdict(fail_verdict())
}
fn analyst_verdict(score: u8, issues: &[&str]) -> ParallelVerdict {
ParallelVerdict::Verdict(crate::Verdict {
score,
issues_detected: issues.iter().map(|&s| s.into()).collect(),
})
}
fn install_synthesis_test_seams(
fake: crate::util::test::FakeProvider,
) -> (
std::sync::MutexGuard<'static, ()>,
crate::util::test::RetryPolicyGuard,
crate::util::test::FakeProviderGuard,
) {
let lock = crate::util::test::retry_tests_lock();
let policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let provider_guard = crate::util::test::install_fake_provider(std::sync::Arc::new(fake));
(lock, policy_guard, provider_guard)
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn process_verifier_verdicts_cases() {
struct Case {
name: &'static str,
ws_suffix: &'static str,
title: &'static str,
phase: TicketPhase,
results: Vec<ParallelVerdict>,
vi: VerifierInfo,
expected_phase: TicketPhase,
expected_pipeline_reservation: bool,
expected_bounce_count: i64,
expected_joint_comments: usize,
}
init_management_test_stores().await;
let (_lock, _policy_guard, _provider_guard) =
install_synthesis_test_seams(crate::util::test::FakeProvider::new());
let cases = vec![
Case {
name: "all failed -> Failed",
ws_suffix: "vp_all_fail",
title: "VP All Failed",
phase: TicketPhase::InReview,
results: vec![no_verdict(); 3],
vi: REVIEWER_VI,
expected_phase: TicketPhase::Failed,
expected_pipeline_reservation: false,
expected_bounce_count: 0,
expected_joint_comments: 0,
},
Case {
name: "any failed -> bounce-back with pipeline reservation",
ws_suffix: "vp_any_fail",
title: "VP Any Failed",
phase: TicketPhase::InReview,
results: vec![pass_result(), fail_result(), pass_result()],
vi: REVIEWER_VI,
expected_phase: TicketPhase::ReadyForDevelopment,
expected_pipeline_reservation: true,
expected_bounce_count: 1,
expected_joint_comments: 1,
},
Case {
name: "all passed -> Reviewed",
ws_suffix: "vp_all_pass",
title: "VP All Pass",
phase: TicketPhase::InReview,
results: vec![pass_result(), pass_result(), pass_result()],
vi: REVIEWER_VI,
expected_phase: TicketPhase::Reviewed,
expected_pipeline_reservation: false,
expected_bounce_count: 0,
expected_joint_comments: 1,
},
Case {
name: "all passed (QA) -> QaPassed",
ws_suffix: "vp_qa_pass",
title: "VP QA Pass",
phase: TicketPhase::InQa,
results: vec![pass_result(), pass_result(), pass_result()],
vi: QA_VI,
expected_phase: TicketPhase::QaPassed,
expected_pipeline_reservation: false,
expected_bounce_count: 0,
expected_joint_comments: 1,
},
];
for case in &cases {
let ws = test_ws_named("/tmp/test", case.ws_suffix);
let ticket_id = make_ticket(board(), &ws, case.title, case.phase).await;
let ticket = expect_ticket(board(), &ticket_id).await;
process_verifier_verdicts(&ws, &ticket, &case.results, case.vi).await;
let ticket = expect_ticket(board(), &ticket_id).await;
assert_eq!(
ticket.phase, case.expected_phase,
"case {}: expected phase {:?}, got {:?}",
case.name, case.expected_phase, ticket.phase,
);
assert_eq!(
ticket.pipeline_reservation, case.expected_pipeline_reservation,
"case {}: expected pipeline_reservation={}, got {}",
case.name, case.expected_pipeline_reservation, ticket.pipeline_reservation,
);
assert_eq!(
ticket.bounce_count, case.expected_bounce_count,
"case {}: expected bounce_count={}, got {}",
case.name, case.expected_bounce_count, ticket.bounce_count,
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get comments");
let verdict_comments: Vec<&TicketComment> = comments
.iter()
.filter(|c| c.role == stage_name(case.vi.role))
.collect();
if case.expected_joint_comments == 1 {
assert_eq!(
verdict_comments.len(),
1,
"case {}: one joint comment expected, got {}",
case.name,
verdict_comments.len(),
);
let joint = &verdict_comments[0];
assert!(
!joint.content.contains("valid verdicts")
&& !joint.content.contains("threshold 9/10"),
"case {}: verifier comments carry no round headline or threshold line: {}",
case.name,
joint.content,
);
assert!(
joint.content.contains("### Summary"),
"case {}: joint comment keeps the Summary section: {}",
case.name,
joint.content,
);
} else {
assert!(
verdict_comments.is_empty(),
"case {}: no per-stage comments expected for all-failed rounds",
case.name,
);
}
}
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn verifier_finalization_on_moved_ticket_is_clean_skip() {
init_management_test_stores().await;
let (_lock, _policy_guard, _provider_guard) =
install_synthesis_test_seams(crate::util::test::FakeProvider::new());
let ws = test_ws_named("/tmp/test", "vp_moved");
let ticket_id = make_ticket(board(), &ws, "VP Moved", TicketPhase::InReview).await;
board()
.transition_to(&ticket_id, None, TicketPhase::Planning, None)
.await
.expect("external move to Planning");
board()
.set_assigned_to_no_cancel(&ticket_id, Some("external-mover"))
.await
.expect("external mover claims the ticket");
let ticket = expect_ticket(board(), &ticket_id).await;
let transitioned = process_verifier_verdicts(
&ws,
&ticket,
&[pass_result(), fail_result(), pass_result()],
REVIEWER_VI,
)
.await;
assert!(!transitioned, "guard miss must not report a transition");
let ticket = expect_ticket(board(), &ticket_id).await;
assert_eq!(
ticket.phase,
TicketPhase::Planning,
"the moved ticket must be left untouched"
);
assert_eq!(
ticket.bounce_count, 0,
"guard miss must not bump the bounce counter"
);
assert_eq!(
ticket.assigned_to.as_deref(),
Some("external-mover"),
"guard miss must not clear the external mover's assignment"
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get comments");
assert!(
!comments
.iter()
.any(|c| c.role == stage_name(REVIEWER_VI.role)),
"a skipped round must not write a joint comment"
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn circuit_breaker_on_moved_ticket_is_silent_noop() {
init_management_test_stores().await;
let (_lock, _policy_guard, _provider_guard) =
install_synthesis_test_seams(crate::util::test::FakeProvider::new());
let ws = test_ws_named("/tmp/test", "cb_moved");
let ticket_id = make_ticket(board(), &ws, "CB Moved", TicketPhase::InSanitation).await;
for _ in 0..4 {
add_breaker_failure(CircuitBreakerKind::Sanitation, &ticket_id).await;
}
board()
.transition_to(&ticket_id, None, TicketPhase::Planning, None)
.await
.expect("external move to Planning");
board()
.set_assigned_to_no_cancel(&ticket_id, Some("external-mover"))
.await
.expect("external mover claims the ticket");
let ticket = expect_ticket(board(), &ticket_id).await;
assert!(
try_trip_circuit_breaker(
&ticket,
TicketPhase::InSanitation,
Some(CircuitBreakerKind::Sanitation),
"Sanitation",
)
.await,
"breaker still reports tripped — the caller must abort dispatch"
);
let ticket = expect_ticket(board(), &ticket_id).await;
assert_eq!(
ticket.phase,
TicketPhase::Planning,
"the moved ticket must be left untouched (no Failed transition)"
);
assert_eq!(
ticket.assigned_to.as_deref(),
Some("external-mover"),
"guard miss must not clear the external mover's assignment"
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn resume_verifier_round_replays_stored_outcomes() {
init_management_test_stores().await;
let (_lock, _policy_guard, _provider_guard) =
install_synthesis_test_seams(crate::util::test::FakeProvider::new());
let ws = test_ws_named("/tmp/test", "vp_resume");
let ticket_id = make_ticket(board(), &ws, "VP Resume", TicketPhase::InReview).await;
let job_id = "vp_resume_job";
let now = crate::turso::now();
let conn = &crate::session::store().conn;
JobRowBuilder::new(conn, job_id, "ticket_stage", "reviewer", &ws.name)
.timestamps(now.clone())
.insert()
.await
.unwrap();
conn.execute(
"INSERT INTO ticket_stage_jobs (id, ticket_id, stage, phase, round) \
VALUES (?1, ?2, 'review', 'in_review', 1)",
crate::turso::params![job_id, ticket_id.clone()],
)
.await
.unwrap();
for i in 0..3 {
let agent_id = format!("ticket_{ticket_id}_resume_{i}_reviewer");
conn.execute(
"INSERT INTO agents (job_id, agent_id, kind, idx, status, outcome, task) \
VALUES (?1, ?2, 'verifier', ?3, 'done', ?4, '')",
crate::turso::params![
job_id,
agent_id,
i64::from(i),
serialize_verdict_outcome(&pass_result()),
],
)
.await
.unwrap();
}
let ticket = expect_ticket(board(), &ticket_id).await;
resume_ticket_stage_round("review".to_string(), job_id.to_string(), ticket, ws).await;
let ticket = expect_ticket(board(), &ticket_id).await;
assert_eq!(
ticket.phase,
TicketPhase::Reviewed,
"replayed all-pass stored outcomes must transition to Reviewed"
);
assert_eq!(ticket.bounce_count, 0, "no bounce on an all-pass replay");
let jobs = conn
.query(
"SELECT status FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].get::<String>(0).unwrap(), "done");
let agents = conn
.query(
"SELECT COUNT(*) FROM agents WHERE job_id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(
agents[0].get::<i64>(0).unwrap(),
0,
"roster rows cascaded on completion"
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
async fn parallel_round_phase_gate_bail_unregisters_router() {
init_management_test_stores().await;
let ws = test_ws_named("/tmp/test", "pg_bail");
let ticket_id = make_ticket(board(), &ws, "PG Bail", TicketPhase::Analysis).await;
let ticket = Arc::new(expect_ticket(board(), &ticket_id).await);
assert_eq!(ticket.phase, TicketPhase::Analysis);
board()
.transition_to(&ticket_id, None, TicketPhase::Planning, None)
.await
.unwrap();
let slots: Vec<TicketStageSlot> = (0..2)
.map(|i| TicketStageSlot {
idx: i,
agent_id: format!("pg_bail_{i}"),
task: "task".to_string(),
status: crate::jobs::RowStatus::Launched,
outcome: None,
})
.collect();
let results = run_parallel_agents(
&ticket,
&ws,
Role::Analyst,
"extract",
"pg_bail_job",
&slots,
false,
)
.await;
assert_eq!(results.len(), 2);
for (slot, result) in slots.iter().zip(&results) {
assert!(
matches!(result, ParallelVerdict::NoResponse(r) if r == PHASE_GATE_BAIL_REASON),
"member must bail at the phase gate with the neutral reason"
);
assert!(
!crate::message_router::router_contains(&slot.agent_id),
"phase-gate bail must unregister the router entry for {}",
slot.agent_id,
);
}
}
#[tokio::test]
async fn panicked_round_member_maps_to_contained_no_response() {
let handle = tokio::spawn(async {
panic!("probe api_key=sk-abcdefgh12345678 boom");
});
let verdict = round_member_failed(handle.await.unwrap_err());
let ParallelVerdict::NoResponse(reason) = verdict else {
panic!("panicked member must resolve to NoResponse");
};
assert!(reason.contains("probe") && reason.contains("boom"));
assert!(
!reason.contains("sk-abcdefgh12345678"),
"panic reason must be credential-scrubbed"
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn resume_engineer_round_continues_anchor_session() {
init_management_test_stores().await;
let fake = crate::util::test::FakeProvider::new()
.ok("implemented the resume path")
.ok(r#"{"items": ["resumed the engineer session"]}"#);
let (_lock, _policy_guard, _provider_guard) = install_synthesis_test_seams(fake);
let ws = test_ws_named("/tmp/test", "eng_resume");
let ticket_id = make_ticket(board(), &ws, "Eng Resume", TicketPhase::InDevelopment).await;
let job_id = "eng_resume_job";
let now = crate::turso::now();
let conn = &crate::session::store().conn;
let anchor_id = crate::jobs::engineer_anchor_id(&ticket_id);
let task = "round 2 feedback: fix the tests";
JobRowBuilder::new(conn, job_id, "ticket_stage", "engineer", &ws.name)
.task(task)
.timestamps(now.clone())
.insert()
.await
.unwrap();
conn.execute(
"INSERT INTO ticket_stage_jobs (id, ticket_id, stage, phase, round) \
VALUES (?1, ?2, 'engineer', 'in_development', 2)",
crate::turso::params![job_id, ticket_id.clone()],
)
.await
.unwrap();
conn.execute(
"INSERT INTO agents (job_id, agent_id, kind, idx, status, task) \
VALUES (NULL, ?1, 'engineer', NULL, 'done', ?2)",
crate::turso::params![anchor_id.clone(), task],
)
.await
.unwrap();
conn.execute(
"INSERT INTO agents (job_id, agent_id, kind, idx, status, task) \
VALUES (?1, ?2, 'engineer', 0, 'launched', ?3)",
crate::turso::params![job_id, anchor_id.clone(), task],
)
.await
.unwrap();
conn.execute(
"INSERT INTO sessions (agent_id, role, content, created_at) \
VALUES (?1, 'user', ?2, ?3)",
crate::turso::params![
anchor_id.clone(),
format!("<ts>{now}</ts>\n\nround 1 task"),
now.clone()
],
)
.await
.unwrap();
let ticket = expect_ticket(board(), &ticket_id).await;
resume_ticket_stage_round("engineer".to_string(), job_id.to_string(), ticket, ws).await;
let ticket = expect_ticket(board(), &ticket_id).await;
assert_eq!(
ticket.phase,
TicketPhase::InDiagnostics,
"resumed engineer must transition to InDiagnostics"
);
let jobs = conn
.query(
"SELECT status FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].get::<String>(0).unwrap(), "done");
let roster = conn
.query(
"SELECT COUNT(*) FROM agents WHERE job_id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(
roster[0].get::<i64>(0).unwrap(),
0,
"roster cascaded on completion"
);
let anchors = conn
.query(
"SELECT COUNT(*) FROM agents WHERE agent_id = ?1 AND job_id IS NULL",
crate::turso::params![anchor_id.clone()],
)
.await
.unwrap();
assert_eq!(
anchors[0].get::<i64>(0).unwrap(),
1,
"NULL-seat anchor survives round completion"
);
let msgs = conn
.query(
"SELECT role, content FROM sessions WHERE agent_id = ?1 ORDER BY id",
crate::turso::params![anchor_id.clone()],
)
.await
.unwrap();
assert_eq!(
msgs.len(),
2,
"round-1 user row + round-2 assistant response only"
);
assert_eq!(msgs[0].get::<String>(0).unwrap(), "user");
assert_eq!(msgs[1].get::<String>(0).unwrap(), "assistant");
assert!(
!msgs[1]
.get::<String>(1)
.unwrap()
.contains("round 2 feedback"),
"task must not be re-appended on a resumed session"
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn resume_sanitation_round_continues_session_and_passes() {
init_management_test_stores().await;
let fake = crate::util::test::FakeProvider::new()
.ok("inspected the workspace — no garbage files found")
.ok(r#"{"pass": true, "garbage_files": [], "rationale": "workspace is clean"}"#);
let (_lock, _policy_guard, _provider_guard) = install_synthesis_test_seams(fake);
let ws = test_ws_named("/tmp/test", "san_resume");
let ticket_id = make_ticket(board(), &ws, "San Resume", TicketPhase::InSanitation).await;
let job_id = "san_resume_job";
let now = crate::turso::now();
let conn = &crate::session::store().conn;
let agent_id = format!("ticket_{job_id}_sanitation");
let task = "sanitation task";
JobRowBuilder::new(conn, job_id, "ticket_stage", "sanitation", &ws.name)
.task(task)
.timestamps(now.clone())
.insert()
.await
.unwrap();
conn.execute(
"INSERT INTO ticket_stage_jobs (id, ticket_id, stage, phase, round) \
VALUES (?1, ?2, 'sanitation', 'in_sanitation', 1)",
crate::turso::params![job_id, ticket_id.clone()],
)
.await
.unwrap();
conn.execute(
"INSERT INTO agents (job_id, agent_id, kind, idx, status, task) \
VALUES (?1, ?2, 'sanitation', 0, 'launched', ?3)",
crate::turso::params![job_id, agent_id.clone(), task],
)
.await
.unwrap();
conn.execute(
"INSERT INTO sessions (agent_id, role, content, created_at) \
VALUES (?1, 'user', ?2, ?3)",
crate::turso::params![
agent_id.clone(),
format!("<ts>{now}</ts>\n\n{task}"),
now.clone()
],
)
.await
.unwrap();
let ticket = expect_ticket(board(), &ticket_id).await;
resume_ticket_stage_round("sanitation".to_string(), job_id.to_string(), ticket, ws).await;
let ticket = expect_ticket(board(), &ticket_id).await;
assert_eq!(
ticket.phase,
TicketPhase::SanitationPassed,
"resumed sanitation pass must transition to SanitationPassed"
);
let jobs = conn
.query(
"SELECT status FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].get::<String>(0).unwrap(), "done");
let roster = conn
.query(
"SELECT COUNT(*) FROM agents WHERE job_id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(
roster[0].get::<i64>(0).unwrap(),
0,
"roster cascaded on completion"
);
let msgs = conn
.query(
"SELECT role, content FROM sessions WHERE agent_id = ?1 ORDER BY id",
crate::turso::params![agent_id.clone()],
)
.await
.unwrap();
assert_eq!(msgs.len(), 2, "seeded user row + assistant response only");
assert_eq!(msgs[0].get::<String>(0).unwrap(), "user");
assert_eq!(msgs[1].get::<String>(0).unwrap(), "assistant");
assert!(
!msgs[1].get::<String>(1).unwrap().contains(task),
"task must not be re-appended on a resumed session"
);
}
struct PanicProvider {
drain_first: bool,
}
#[async_trait::async_trait]
impl crate::Provider for PanicProvider {
async fn chat_scoped(
&self,
_request: crate::ChatRequest,
_idle_timeout: std::time::Duration,
_deadline: std::time::Instant,
) -> Result<crate::ChatResponse, crate::providers::ScopedCallError> {
if self.drain_first {
crate::shutdown::drain_begin();
}
panic!("provider boom");
}
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn dispatch_panic_during_drain_skips_failed_transition() {
init_management_test_stores().await;
let _lock = crate::util::test::retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let ctrl_provider =
crate::util::test::install_fake_provider(std::sync::Arc::new(PanicProvider {
drain_first: false,
}));
let ws_ctrl = test_ws_named("/tmp/test", "panic_ctrl");
let ctrl_id = make_ticket(
board(),
&ws_ctrl,
"Panic Control",
TicketPhase::InDevelopment,
)
.await;
let ticket = expect_ticket(board(), &ctrl_id).await;
spawn_dispatch(PollPhase::EngineerDevelopment, ticket, ws_ctrl);
let mut reached_failed = false;
for _ in 0..50 {
if expect_ticket_phase(board(), &ctrl_id).await == TicketPhase::Failed {
reached_failed = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
assert!(
reached_failed,
"control panic must transition the ticket to Failed"
);
drop(ctrl_provider);
let drain_provider =
crate::util::test::install_fake_provider(std::sync::Arc::new(PanicProvider {
drain_first: true,
}));
let ws_drain = test_ws_named("/tmp/test", "panic_drain");
let drain_id = make_ticket(
board(),
&ws_drain,
"Panic Drain",
TicketPhase::InDevelopment,
)
.await;
let ticket = expect_ticket(board(), &drain_id).await;
spawn_dispatch(PollPhase::EngineerDevelopment, ticket, ws_drain);
let mut fired = false;
for _ in 0..50 {
if crate::shutdown::is_draining() {
fired = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(fired, "dispatch task never reached the provider");
crate::shutdown::drain_clear();
drop(drain_provider);
let t = expect_ticket(board(), &drain_id).await;
assert_eq!(
t.phase,
TicketPhase::InDevelopment,
"a panic during the drain must NOT transition the ticket to Failed"
);
let comments = board().get_comments(&drain_id).await.unwrap();
assert!(
!comments
.iter()
.any(|c| c.content.contains("Dispatch panicked")),
"no failure comment during the drain (job resumes at boot)"
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn eleventh_bounce_fails_ticket() {
init_management_test_stores().await;
let (_lock, _policy_guard, _provider_guard) =
install_synthesis_test_seams(crate::util::test::FakeProvider::new());
let ws = test_ws_named("/tmp/test", "eleventh_bounce");
let ticket_id = make_ticket(board(), &ws, "Eleventh Bounce", TicketPhase::InReview).await;
board()
.conn
.execute(
"UPDATE tickets SET bounce_count = ?1 WHERE id = ?2",
turso::params![
i64::try_from(crate::joint_verdict::MAX_BOUNCES).unwrap(),
ticket_id.as_str()
],
)
.await
.expect("set bounce_count to max");
let ticket = expect_ticket(board(), &ticket_id).await;
let transitioned = process_verifier_verdicts(
&ws,
&ticket,
&[pass_result(), fail_result(), pass_result()],
REVIEWER_VI,
)
.await;
assert!(
transitioned,
"11th-bounce round should transition the ticket"
);
let ticket = expect_ticket(board(), &ticket_id).await;
assert_eq!(
ticket.phase,
TicketPhase::Failed,
"11th bounce must fail the ticket"
);
assert_eq!(
ticket.bounce_count,
i64::try_from(crate::joint_verdict::MAX_BOUNCES).unwrap(),
"bounce counter stays at the max — the failing bounce is not counted"
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get comments");
assert!(
comments
.iter()
.any(|c| c.content.contains("circuit breaker")),
"the bounce breaker must leave an explicit trip comment",
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn technical_failure_pauses_workspace_but_circuit_breaker_does_not() {
let _lock = crate::util::test::retry_tests_lock();
init_management_test_stores().await;
let ws_breaker = create_test_workspace("/tmp/pause_breaker_ws", "ws_pause_breaker").await;
let breaker_id = make_ticket(
board(),
&ws_breaker,
"Breaker Trip",
TicketPhase::InSanitation,
)
.await;
for _ in 0..4 {
add_breaker_failure(CircuitBreakerKind::Sanitation, &breaker_id).await;
}
let ticket = expect_ticket(board(), &breaker_id).await;
assert!(
try_trip_circuit_breaker(
&ticket,
TicketPhase::InSanitation,
Some(CircuitBreakerKind::Sanitation),
"Sanitation",
)
.await,
"breaker should trip"
);
let ws = crate::workspace::store()
.get_by_name("ws_pause_breaker")
.await
.expect("get workspace")
.expect("workspace exists");
assert!(
!ws.paused,
"circuit-breaker trip must NOT pause the workspace"
);
let ws_verifier = create_test_workspace("/tmp/pause_verifier_ws", "ws_pause_verifier").await;
let verifier_id = make_ticket(
board(),
&ws_verifier,
"Verifier All Failed",
TicketPhase::InReview,
)
.await;
let ticket = expect_ticket(board(), &verifier_id).await;
let transitioned =
process_verifier_verdicts(&ws_verifier, &ticket, &vec![no_verdict(); 3], REVIEWER_VI).await;
assert!(
transitioned,
"all-failed round should transition the ticket"
);
let ws = crate::workspace::store()
.get_by_name("ws_pause_verifier")
.await
.expect("get workspace")
.expect("workspace exists");
assert!(ws.paused, "verifier all-failed must pause the workspace");
let comments = board()
.get_comments(&verifier_id)
.await
.expect("get comments");
let last = comments
.last()
.expect("failure comment written")
.content
.clone();
assert!(last.contains("Workspace paused"), "{last}");
assert!(last.contains("agent produced no response"), "{last}");
}
#[test]
fn blocks_claim_gate_matrix() {
for &(source, phase) in CLAIM_PHASES {
let new_work = matches!(
phase,
PollPhase::BacklogAnalysis | PollPhase::EngineerDevelopment
);
let paused = Workspace {
status: WorkspaceStatus::Ready,
paused: true,
..Default::default()
};
assert_eq!(
blocks_claim(&paused, phase),
new_work,
"pause gate mismatch for {} ({} → {})",
phase.info().log_label,
source.as_ref(),
phase.info().expected_phase.as_ref(),
);
let ready = Workspace {
status: WorkspaceStatus::Ready,
paused: false,
..Default::default()
};
assert!(
!blocks_claim(&ready, phase),
"{} must run when Ready and unpaused",
phase.info().log_label,
);
for status in [
WorkspaceStatus::Pending,
WorkspaceStatus::Analyzing,
WorkspaceStatus::Failed,
] {
let not_ready = Workspace {
status,
paused: false,
..Default::default()
};
assert_eq!(
blocks_claim(¬_ready, phase),
new_work,
"status gate mismatch for {} ({})",
phase.info().log_label,
status,
);
}
}
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn paused_workspace_holds_backlog_and_rfd_until_unpause() {
let _lock = crate::util::test::retry_tests_lock();
let ws = setup_db_workspace("pause_gate").await;
let ws_name = ws.name.clone();
crate::workspace::store()
.set_status(&ws_name, &WorkspaceStatus::Ready)
.await
.expect("set workspace ready");
crate::workspace::store()
.set_paused(&ws_name, true)
.await
.expect("pause workspace");
let backlog_id = make_ticket(board(), &ws, "Paused Backlog", TicketPhase::Backlog).await;
let rfd_id = make_ticket(board(), &ws, "Paused RFD", TicketPhase::ReadyForDevelopment).await;
let old = (chrono::Utc::now() - ChronoDuration::minutes(10)).to_rfc3339();
for id in [&backlog_id, &rfd_id] {
board()
.conn
.execute(
"UPDATE tickets SET created_at = ?1 WHERE id = ?2",
crate::turso::params![old.clone(), id.clone()],
)
.await
.expect("backdate ticket created_at");
}
let paused_ws = crate::workspace::store()
.get_by_name(&ws_name)
.await
.expect("get workspace")
.expect("workspace exists");
run_claim_pipeline(&paused_ws).await;
assert_eq!(
expect_ticket_phase(board(), &backlog_id).await,
TicketPhase::Backlog,
"paused workspace must not claim backlog into analysis",
);
assert_eq!(
expect_ticket_phase(board(), &rfd_id).await,
TicketPhase::ReadyForDevelopment,
"paused workspace must not claim RFD into development",
);
crate::workspace::store()
.set_paused(&ws_name, false)
.await
.expect("unpause workspace");
let resumed_ws = crate::workspace::store()
.get_by_name(&ws_name)
.await
.expect("get workspace")
.expect("workspace exists");
run_claim_pipeline(&resumed_ws).await;
assert_eq!(
expect_ticket_phase(board(), &backlog_id).await,
TicketPhase::Analysis,
"unpaused workspace must claim backlog into analysis",
);
assert_eq!(
expect_ticket_phase(board(), &rfd_id).await,
TicketPhase::InDevelopment,
"unpaused workspace must claim RFD into development",
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn process_analyst_verdicts_cases() {
struct Case {
name: &'static str,
ws_suffix: &'static str,
title: &'static str,
results: Vec<ParallelVerdict>,
expected_comment_substring: &'static str,
}
init_management_test_stores().await;
let (_lock, _policy_guard, _provider_guard) =
install_synthesis_test_seams(crate::util::test::FakeProvider::new());
let cases = vec![
Case {
name: "all pass -> Planning with joint comment",
ws_suffix: "an_all_pass",
title: "Analyst All Pass",
results: vec![
analyst_verdict(10, &[]),
analyst_verdict(9, &[]),
analyst_verdict(8, &[]),
],
expected_comment_substring: "All LGTM",
},
Case {
name: "partial fail -> Planning with joint comment",
ws_suffix: "an_partial",
title: "Analyst Partial Fail",
results: vec![
analyst_verdict(10, &[]),
analyst_verdict(3, &["Missing data"]),
analyst_verdict(8, &["Minor issue"]),
],
expected_comment_substring: "flagged potential blockers",
},
Case {
name: "no verdicts -> Planning with failure dumps",
ws_suffix: "an_no_v",
title: "Analyst No Verdicts",
results: vec![no_verdict(); 3],
expected_comment_substring: "Agent failures",
},
];
for case in &cases {
let ws = test_ws_named("/tmp/test", case.ws_suffix);
let ticket_id = make_ticket(board(), &ws, case.title, TicketPhase::Analysis).await;
let ticket = expect_ticket(board(), &ticket_id).await;
process_analyst_verdicts(&ws, &ticket, &case.results).await;
let phase = expect_ticket_phase(board(), &ticket_id).await;
assert_eq!(
phase,
TicketPhase::Planning,
"case {}: analysis is fail-open — expected Planning, got {:?}",
case.name,
phase,
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get_comments");
let verdict_comments: Vec<&TicketComment> = comments
.iter()
.filter(|c| c.role == stage_name(Role::Analyst))
.collect();
assert_eq!(
verdict_comments.len(),
1,
"case {}: exactly one joint comment expected, got {}",
case.name,
verdict_comments.len(),
);
assert!(
verdict_comments[0]
.content
.contains(case.expected_comment_substring),
"case {}: joint comment should contain {:?}, got: {}",
case.name,
case.expected_comment_substring,
verdict_comments[0].content,
);
}
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn analyst_round_fails_open_with_fallback_comment() {
init_management_test_stores().await;
let fake = crate::util::test::FakeProvider::new()
.err(crate::retry::FailureClass::Transport, "synthesis down")
.err(crate::retry::FailureClass::Transport, "synthesis down")
.err(crate::retry::FailureClass::Transport, "synthesis down");
let (_lock, _policy_guard, _provider_guard) = install_synthesis_test_seams(fake);
let ws = test_ws_named("/tmp/test", "an_fail_open");
let ticket_id = make_ticket(board(), &ws, "Fail Open", TicketPhase::Analysis).await;
let results = vec![
analyst_verdict(10, &[]),
analyst_verdict(3, &["Missing data"]),
];
let ticket = expect_ticket(board(), &ticket_id).await;
process_analyst_verdicts(&ws, &ticket, &results).await;
let phase = expect_ticket_phase(board(), &ticket_id).await;
assert_eq!(phase, TicketPhase::Planning, "fail-open must advance");
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get comments");
let joint = comments
.iter()
.find(|c| c.role == stage_name(Role::Analyst))
.expect("joint comment written");
assert!(
joint.content.contains("LLM grouping unavailable"),
"fallback marker must be explicit: {}",
joint.content,
);
assert!(
joint.content.contains("Missing data"),
"deterministic issues must render: {}",
joint.content,
);
}
#[tokio::test]
async fn handle_qa_passed_no_git_to_done() {
let dir = tempfile::tempdir().expect("create temp dir");
let ws_path = dir.path().to_str().expect("temp path is valid UTF-8");
let (ws, ticket_id) =
setup_ticket(ws_path, "qa_no_git", "QA No Git", TicketPhase::QaPassed).await;
let ticket = expect_ticket(board(), &ticket_id).await;
handle_qa_passed(ticket, ws).await;
let phase = expect_ticket_phase(board(), &ticket_id).await;
assert_eq!(
phase,
TicketPhase::Done,
"QA passed should eventually transition to Done"
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get_comments");
assert!(
comments
.iter()
.any(|c| c.role == SYSTEM_ROLE && c.content.contains("without commit")),
"Expected a SYSTEM_ROLE comment explaining why no commit was made"
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
async fn handle_qa_passed_untracked_files_to_insanitation() {
if !crate::git_commands::git_is_installed().await {
eprintln!("git not installed — skipping git-dependent test");
return;
}
let (_dir, repo_path) = crate::util::test::init_temp_repo();
std::fs::write(repo_path.join("untracked.txt"), b"garbage").expect("write untracked file");
let (ws, ticket_id) = setup_ticket(
repo_path.to_str().unwrap(),
"qa_untracked",
"QA Untracked",
TicketPhase::QaPassed,
)
.await;
let ticket = expect_ticket(board(), &ticket_id).await;
handle_qa_passed(ticket, ws).await;
let phase = expect_ticket_phase(board(), &ticket_id).await;
assert_eq!(
phase,
TicketPhase::InSanitation,
"QA passed with untracked files should transition to InSanitation"
);
let ticket = expect_ticket(board(), &ticket_id).await;
let base_key = crate::session::ticket_agent_id(&ticket_id, crate::Role::Sanitation.as_str());
assert!(
ticket
.assigned_to
.as_deref()
.is_some_and(|a| a.starts_with(&base_key)),
"assigned_to should be a sanitation agent ID for this ticket, got {:?}",
ticket.assigned_to,
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
async fn sanitation_register_persists_registered_id() {
init_management_test_stores().await;
let ws = test_ws_named("/tmp/test_san_register", "ws_san_register");
let ticket_id = make_ticket(board(), &ws, "San Register", TicketPhase::InSanitation).await;
let agent_id = "ticket_test-job_sanitation".to_string();
let mut rx = register_agent_and_assign(
&ticket_id,
&agent_id,
"Failed to persist assigned_to for sanitation agent — mid-run comments may not route",
)
.await;
let ticket = expect_ticket(board(), &ticket_id).await;
assert_eq!(
ticket.assigned_to.as_deref(),
Some(agent_id.as_str()),
"assigned_to must store the exact suffixed agent ID registered in the router"
);
assert_eq!(
agent_id,
format!("ticket_test-job_sanitation"),
"sanitation agent ID must be job-derived for run isolation, got {agent_id}"
);
board()
.add_comment(&ticket_id, "manager", "mid-run ping")
.await
.expect("add_comment should succeed");
let job = rx
.try_recv()
.expect("comment routed to the assigned sanitation agent should be delivered");
assert_eq!(job.content, "mid-run ping");
assert_eq!(job.kind, crate::message_router::JobKind::TicketComment);
message_router::unregister_agent(&agent_id);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
async fn sanitation_unsuffixed_assignment_is_not_routed() {
init_management_test_stores().await;
let ws = test_ws_named("/tmp/test_san_mismatch", "ws_san_mismatch");
let ticket_id = make_ticket(board(), &ws, "San Mismatch", TicketPhase::InSanitation).await;
let base = crate::session::ticket_agent_id(&ticket_id, crate::Role::Sanitation.as_str());
board()
.set_assigned_to_no_cancel(&ticket_id, Some(&base))
.await
.expect("set assigned_to");
let suffixed = format!("{base}_{}", crate::generate_suffix());
let mut rx = message_router::register_agent(&suffixed);
board()
.add_comment(&ticket_id, "manager", "should be dropped")
.await
.expect("add_comment should succeed");
assert!(
rx.try_recv().is_err(),
"comment routed to an unsuffixed assigned_to must NOT reach the suffixed \
registered agent (mismatch shape pinned by mahbot-1035)"
);
message_router::unregister_agent(&suffixed);
}
#[tokio::test]
async fn handle_qa_passed_clean_tree_to_done() {
if !crate::git_commands::git_is_installed().await {
eprintln!("git not installed — skipping git-dependent test");
return;
}
let (_dir, repo_path) = crate::util::test::init_temp_repo();
let (ws, ticket_id) = setup_ticket(
repo_path.to_str().unwrap(),
"qa_clean",
"QA Clean Tree",
TicketPhase::QaPassed,
)
.await;
let ticket = expect_ticket(board(), &ticket_id).await;
handle_qa_passed(ticket, ws).await;
let phase = expect_ticket_phase(board(), &ticket_id).await;
assert_eq!(
phase,
TicketPhase::Done,
"QA passed with clean tree should transition to Done"
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get_comments");
assert!(
comments
.iter()
.any(|c| c.role == SYSTEM_ROLE && c.content.contains("Clean working tree")),
"Expected a SYSTEM_ROLE comment explaining the clean-tree skip"
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
async fn process_sanitation_verdict_cases() {
struct Case {
name: &'static str,
ws_suffix: &'static str,
verdict: crate::SanitationVerdict,
expected_phase: TicketPhase,
expected_pipeline_reservation: bool,
sanit_markers: &'static [&'static str],
sys_markers: Option<&'static [&'static str]>,
}
init_management_test_stores().await;
let sanitation_failed_marker: &'static str =
load_prompt("pipeline/sanitation_failed.md").leak();
let sys_markers_val: &'static [&'static str] =
Box::leak(vec![sanitation_failed_marker].into_boxed_slice());
let clean = crate::SanitationVerdict {
pass: true,
garbage_files: vec![],
rationale: "All files are legitimate project files.".into(),
};
let garbage = crate::SanitationVerdict {
pass: false,
garbage_files: vec!["node_modules/".into(), "tmp/scratch.js".into()],
rationale: "These are intermediate build artifacts.".into(),
};
let reviewed = crate::SanitationVerdict {
pass: true,
garbage_files: vec!["generated/bundle.js".into()],
rationale: "Reviewed, no issues found.".into(),
};
let cases = [
Case {
name: "pass=true → SanitationPassed",
ws_suffix: "sp",
verdict: clean,
expected_phase: TicketPhase::SanitationPassed,
expected_pipeline_reservation: false,
sanit_markers: &[],
sys_markers: None,
},
Case {
name: "pass=false → ReadyForDevelopment",
ws_suffix: "sf",
verdict: garbage,
expected_phase: TicketPhase::ReadyForDevelopment,
expected_pipeline_reservation: true,
sanit_markers: &["node_modules/"],
sys_markers: Some(sys_markers_val),
},
Case {
name: "pass=true with reviewed files → SanitationPassed (files reviewed)",
ws_suffix: "sp_r",
verdict: reviewed,
expected_phase: TicketPhase::SanitationPassed,
expected_pipeline_reservation: false,
sanit_markers: &["(files reviewed)"],
sys_markers: None,
},
];
for case in &cases {
let ws = test_ws_named("/tmp/test", case.ws_suffix);
let id = make_ticket(board(), &ws, case.name, TicketPhase::InSanitation).await;
let ticket = expect_ticket(board(), &id).await;
process_sanitation_verdict(&ticket, case.verdict.clone()).await;
let phase = expect_ticket_phase(board(), &id).await;
assert_eq!(
phase, case.expected_phase,
"case {}: expected phase {:?}, got {:?}",
case.name, case.expected_phase, phase,
);
let ticket = expect_ticket(board(), &id).await;
assert_eq!(
ticket.pipeline_reservation, case.expected_pipeline_reservation,
"case {}: pipeline_reservation mismatch",
case.name,
);
assert!(
ticket.assigned_to.is_none(),
"case {}: assigned_to should be cleared",
case.name,
);
let comments = board().get_comments(&id).await.expect("get_comments");
assert!(
comments.iter().any(|c| c.role == Role::Sanitation.as_str()
&& case.sanit_markers.iter().all(|&m| c.content.contains(m))),
"case {}: expected Sanitation comment matching {:?}",
case.name,
case.sanit_markers,
);
match &case.sys_markers {
Some(markers) => {
assert!(
comments.iter().any(|c| c.role == SANITATION_ROLE
&& markers.iter().all(|&m| c.content.contains(m))),
"case {}: expected SANITATION_ROLE comment matching {:?}",
case.name,
markers,
);
}
None => assert!(
!comments.iter().any(|c| c.role == SANITATION_ROLE),
"case {}: expected no SANITATION_ROLE comment",
case.name,
),
}
}
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
async fn dispatch_diagnostics_cases() {
struct Case {
name: &'static str,
ws_suffix: &'static str,
title: &'static str,
commands: Option<DiagnosticsCommands>,
corrupt_diagnostics: bool,
needs_tempdir: bool,
expected_phase: TicketPhase,
expected_pipeline_reservation: bool,
expected_comment_contains: &'static [&'static str],
}
const NO_DIAG_CMDS: &[&str] = &["No diagnostics commands are configured"];
const DB_ERR: &[&str] = &["database error"];
init_management_test_stores().await;
let diagnostics_failed_marker: &'static str =
load_prompt("pipeline/diagnostics_failed.md").leak();
let diagnostics_passed_marker: &'static str =
load_prompt("pipeline/diagnostics_passed.md").leak();
let fail_comment_contains: &'static [&'static str] =
Box::leak(vec![diagnostics_failed_marker].into_boxed_slice());
let pass_comment_contains: &'static [&'static str] =
Box::leak(vec![diagnostics_passed_marker, "PASSED in"].into_boxed_slice());
let fail_cmds = DiagnosticsCommands {
format: Some("false".to_string()),
..Default::default()
};
let pass_cmds = DiagnosticsCommands {
format: Some("true".to_string()),
type_check: Some("true".to_string()),
..Default::default()
};
let cases = [
Case {
name: "no diagnostics commands",
ws_suffix: "dc_no_cmds",
title: "No Diagnostics Commands",
commands: None,
corrupt_diagnostics: false,
needs_tempdir: false,
expected_phase: TicketPhase::DiagnosticsDone,
expected_pipeline_reservation: false,
expected_comment_contains: NO_DIAG_CMDS,
},
Case {
name: "diagnostics failure",
ws_suffix: "dc_fail",
title: "Diagnostics Failure Test",
commands: Some(fail_cmds),
corrupt_diagnostics: false,
needs_tempdir: true,
expected_phase: TicketPhase::ReadyForDevelopment,
expected_pipeline_reservation: true,
expected_comment_contains: fail_comment_contains,
},
Case {
name: "diagnostics all pass",
ws_suffix: "dc_pass",
title: "Diagnostics All Pass Test",
commands: Some(pass_cmds),
corrupt_diagnostics: false,
needs_tempdir: true,
expected_phase: TicketPhase::DiagnosticsDone,
expected_pipeline_reservation: false,
expected_comment_contains: pass_comment_contains,
},
Case {
name: "diagnostics DB error",
ws_suffix: "dc_db_err",
title: "Diagnostics DB Error Test",
commands: None,
corrupt_diagnostics: true,
needs_tempdir: false,
expected_phase: TicketPhase::DiagnosticsDone,
expected_pipeline_reservation: false,
expected_comment_contains: DB_ERR,
},
];
for case in &cases {
let (_dir, ws_path): (Option<tempfile::TempDir>, String) = if case.needs_tempdir {
let dir = tempfile::tempdir().expect("create temp dir");
let path = dir.path().to_string_lossy().to_string();
(Some(dir), path)
} else {
(None, format!("/tmp/{}", case.ws_suffix))
};
let ws = create_test_workspace(&ws_path, case.ws_suffix).await;
if let Some(cmds) = &case.commands {
crate::workspace::store()
.set_diagnostics(case.ws_suffix, cmds)
.await
.expect("set diagnostics");
}
if case.corrupt_diagnostics {
crate::workspace::store()
.conn
.execute(
"UPDATE workspaces SET diagnostics = ?1 WHERE name = ?2",
turso::params!["not valid json", case.ws_suffix],
)
.await
.expect("set diagnostics to invalid JSON");
}
let ticket_id = make_ticket(board(), &ws, case.title, TicketPhase::InDiagnostics).await;
let ticket = expect_ticket(board(), &ticket_id).await;
dispatch_diagnostics(Arc::new(ticket), ws).await;
let phase = expect_ticket_phase(board(), &ticket_id).await;
assert_eq!(
phase, case.expected_phase,
"case {}: expected phase {:?}, got {:?}",
case.name, case.expected_phase, phase,
);
let ticket = expect_ticket(board(), &ticket_id).await;
assert_eq!(
ticket.pipeline_reservation, case.expected_pipeline_reservation,
"case {}: pipeline_reservation mismatch",
case.name,
);
assert!(
ticket.assigned_to.is_none(),
"case {}: assigned_to should be cleared after diagnostics dispatch",
case.name,
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get_comments");
assert!(
!comments.is_empty(),
"case {}: should have written at least one comment",
case.name,
);
let has_expected = comments.iter().any(|c| {
c.role == DIAGNOSTICS_ROLE
&& case
.expected_comment_contains
.iter()
.all(|&marker| c.content.contains(marker))
});
assert!(
has_expected,
"case {}: should have a DIAGNOSTICS_ROLE comment containing: {:?}",
case.name, case.expected_comment_contains,
);
}
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
async fn dispatch_verifiers_skip_review_when_content_matches_base() {
if !crate::git_commands::git_is_installed().await {
eprintln!("git not installed — skipping git-dependent test");
return;
}
let (_dir, repo_path) = crate::util::test::init_temp_repo();
let repo_str = repo_path.to_str().expect("temp path is valid UTF-8");
let (ws, ticket_id) = setup_ticket(
repo_str,
"skip_review_content_test",
"Skip Review Content Test",
TicketPhase::InReview,
)
.await;
let head = crate::git_commands::run_git_head(&repo_path)
.await
.expect("repo has commits");
let tree = crate::git_commands::run_git_write_tree(&repo_path)
.await
.expect("index writable");
board()
.set_reviewed_base(&ticket_id, Some(&head), Some(&tree))
.await
.expect("set_reviewed_base");
let ticket = Arc::new(expect_ticket(board(), &ticket_id).await);
dispatch_verifiers(ticket, ws, REVIEWER_VI).await;
let phase = expect_ticket_phase(board(), &ticket_id).await;
assert_eq!(
phase,
TicketPhase::Reviewed,
"Content identical to the reviewed base should skip review and go directly to Reviewed"
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get_comments");
assert!(
comments
.iter()
.any(|c| c.role == SYSTEM_ROLE && c.content.contains("Skipping reviewer dispatch")),
"Expected a SYSTEM_ROLE comment explaining the skip-review reason"
);
}
#[test]
fn should_skip_review_decision_matrix() {
let base = (Some("base-head"), Some("base-tree"));
let same = base;
let none = (None, None);
#[expect(clippy::type_complexity)] let cases: [(
(Option<&str>, Option<&str>),
(Option<&str>, Option<&str>),
&str,
bool,
); 12] = [
(none, same, "", false),
((None, Some("base-tree")), same, "", false),
((Some("base-head"), None), same, "", false),
(base, same, "", true),
(base, (Some("new-head"), Some("new-tree")), "", false),
(base, (Some("new-head"), Some("base-tree")), "", false),
(base, (Some("base-head"), Some("new-tree")), "", false),
(base, same, " M src/lib.rs", false),
(base, same, "?? new_file.txt", false),
(base, (None, Some("base-tree")), "", false),
(base, (Some("base-head"), None), "", false),
(base, same, "\n\n", true), ];
for (i, (reviewed, current, porcelain, expected)) in cases.iter().enumerate() {
assert_eq!(
should_skip_review(reviewed.0, reviewed.1, current.0, current.1, porcelain),
*expected,
"case {i}"
);
}
}
fn retry_exhausted_with_raw(last_raw: Option<String>) -> crate::retry::RetryExhausted {
let rec = crate::retry::RetryFailureRecord::new_simple(
crate::retry::FailureClass::Parse,
&anyhow::anyhow!("parse failed"),
None,
);
crate::retry::RetryExhausted::with_last_raw(
vec![rec],
crate::retry::FailureClass::Parse,
last_raw,
)
}
#[test]
fn joint_comment_includes_failed_agent_dumps() {
let raw = r#"{"score": 9, "critique": "solid", "issues": []}"#;
let round = crate::joint_verdict::JointRound {
stage: "Review",
dispatched: 2,
verdicts: vec![],
failures: vec![
crate::joint_verdict::JointFailure {
agent_index: 0,
dump: crate::util::scrub_credentials(&raw_response_dump_section(
&retry_exhausted_with_raw(Some(raw.to_string())),
)),
},
crate::joint_verdict::JointFailure {
agent_index: 1,
dump: "agent produced no response".to_string(),
},
],
header: String::new(),
threshold: 9,
};
let comment = crate::joint_verdict::render_joint_comment(
&round,
&crate::consensus::RepairOutcome::Fallback,
&crate::consensus::ItemTable::new(&crate::joint_verdict::issues_by_agent(&round)),
);
assert!(
!comment.contains("valid verdicts"),
"verifier comments carry no verdict/threshold headline: {comment}"
);
assert!(
comment.contains("Raw agent response"),
"parse-failed dump marker must appear: {comment}"
);
assert!(
comment.contains(raw),
"raw text must be in the comment: {comment}"
);
assert!(
comment.contains("agent produced no response"),
"no-response reason must appear: {comment}"
);
assert!(comment.contains("### Agent failures"), "{comment}");
assert!(
comment.contains("Agent 2"),
"agent indices are 1-based: {comment}"
);
}
#[test]
fn raw_response_dump_section_covers_sanitation_shape() {
let raw = "Sanitation agent output with details";
let failure = retry_exhausted_with_raw(Some(raw.to_string()));
let section = raw_response_dump_section(&failure);
assert!(section.contains("Raw agent response"), "{section}");
assert!(section.contains(raw), "{section}");
}
#[test]
fn engineer_failure_comment_classifies_causes() {
let err = "LLM step failed at iteration 3: LLM call exhausted retry budget: \
12 attempt(s) failed (last: transport): OpenRouter API error (503): \
Service is too busy";
let c = engineer_failure_comment(false, false, Some(err));
assert!(c.contains("LLM provider retry exhaustion"), "{c}");
assert!(c.contains("503"), "{c}");
let c = engineer_failure_comment(true, true, None);
assert!(c.contains("service shutting down"), "{c}");
let c = engineer_failure_comment(false, true, None);
assert!(c.contains("cancelled by user"), "{c}");
let c = engineer_failure_comment(
false,
false,
Some("Agent exceeded maximum of 1000 tool rounds"),
);
assert!(
c.contains("Agent exceeded maximum of 1000 tool rounds"),
"{c}"
);
let c = engineer_failure_comment(false, false, None);
assert!(c.contains("technical issues"), "{c}");
}
#[test]
fn engineer_failure_comment_scrubs_and_truncates() {
let err = format!(
"LLM call exhausted retry budget: 12 attempt(s) failed (last: transport): \
provider=x attempt 1/1: retryable; error=api_key={secret} boom",
secret = "sk-1234567890abcdef"
);
let c = engineer_failure_comment(false, false, Some(&err));
assert!(c.contains("[REDACTED]"), "{c}");
assert!(
!c.contains("abcdef"),
"scrubbing must remove the secret tail: {c}"
);
let big = format!("LLM call exhausted retry budget: {}", "x".repeat(30_000));
let c = engineer_failure_comment(false, false, Some(&big));
assert!(
c.contains("bytes omitted at engineer failure truncation"),
"{c}"
);
assert!(c.len() < 26_000, "comment capped, got {}", c.len());
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn engineer_comment_text_fail_open_and_renders() {
use crate::util::test::{
FakeProvider, install_fake_provider, install_test_retry_policy, retry_tests_lock,
};
let _lock = retry_tests_lock();
let _policy_guard = install_test_retry_policy(crate::retry::tiny_test_policy());
let ws = test_ws_named("/tmp/test_ws", "eng_comment");
let raw = "raw response with secret=abcdefgh1234";
let new_agent = |suffix: &str| {
Agent::new(
format!("eng_comment_{suffix}_{}", crate::generate_suffix()),
Role::Engineer,
&ws,
None,
String::new(),
String::new(),
None,
None,
)
};
let fake = FakeProvider::new()
.err(crate::retry::FailureClass::Transport, "boom")
.err(crate::retry::FailureClass::Transport, "boom")
.err(crate::retry::FailureClass::Transport, "boom");
let _provider = install_fake_provider(Arc::new(fake));
let comment = engineer_comment_text(&new_agent("err"), raw).await;
assert_eq!(
comment,
crate::util::scrub_credentials(raw),
"extraction failure keeps the raw response"
);
assert!(
comment.contains("[REDACTED]") && !comment.contains("abcdefgh1234"),
"fallback is scrubbed: {comment}"
);
let fake = FakeProvider::new().ok(r#"{"items": []}"#);
let _provider = install_fake_provider(Arc::new(fake));
let comment = engineer_comment_text(&new_agent("empty"), raw).await;
assert_eq!(
comment,
crate::util::scrub_credentials(raw),
"empty items fall back to raw"
);
let fake = FakeProvider::new().ok(r#"{"items": [""]}"#);
let _provider = install_fake_provider(Arc::new(fake));
let comment = engineer_comment_text(&new_agent("blank"), raw).await;
assert_eq!(
comment,
crate::util::scrub_credentials(raw),
"blank items fall back to raw"
);
let fake = FakeProvider::new().ok(r#"{"items": ["implemented X", "fixed Y"]}"#);
let _provider = install_fake_provider(Arc::new(fake));
let comment = engineer_comment_text(&new_agent("ok"), raw).await;
assert_eq!(
comment, "Implemented / fixed / executed:\n- implemented X\n- fixed Y",
"valid items render the compact bullet list"
);
}
fn engineer_finalize_test_agent(ws: &Workspace, ticket: &Ticket, suffix: &str) -> Agent {
Agent::new(
format!("eng_finalize_{suffix}_{}", crate::generate_suffix()),
Role::Engineer,
ws,
Some(ticket.clone()),
String::new(),
String::new(),
None,
None,
)
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn engineer_hard_failure_bounces_to_ready_for_development() {
init_management_test_stores().await;
let (_lock, _policy_guard, _provider_guard) =
install_synthesis_test_seams(crate::util::test::FakeProvider::new());
let ws = create_test_workspace("/tmp/eng_bounce_ws", "ws_eng_bounce").await;
let ticket_id = make_ticket(board(), &ws, "Eng Hard Failure", TicketPhase::InDevelopment).await;
let ticket = expect_ticket(board(), &ticket_id).await;
let mut agent = engineer_finalize_test_agent(&ws, &ticket, "hard");
agent.failure = Some("OpenRouter 500: service is too busy".to_string());
finalize_engineer_round(&ticket, &agent, None, "job_eng_bounce", false).await;
let t = expect_ticket(board(), &ticket_id).await;
assert_eq!(
t.phase,
TicketPhase::ReadyForDevelopment,
"a hard engineer failure must bounce the ticket, not fail it"
);
assert_eq!(
t.bounce_count, 1,
"the hard-failure bounce must consume the shared review/QA bounce budget"
);
assert!(
t.pipeline_reservation,
"the bounce must set rework priority over fresh ReadyForDevelopment tickets"
);
let ws_after = crate::workspace::store()
.get_by_name("ws_eng_bounce")
.await
.expect("query workspace")
.expect("workspace exists");
assert!(
ws_after.paused,
"a hard engineer failure must pause the workspace"
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get comments");
let last = comments.last().expect("failure comment written");
assert_eq!(
last.role, SYSTEM_ROLE,
"the failure comment must use SYSTEM_ROLE so the retry feedback window \
(comments after the last engineer-role comment) includes it"
);
assert!(
last.content.contains("OpenRouter 500: service is too busy"),
"the concrete error must be recorded on the ticket: {}",
last.content
);
assert!(
last.content.contains("Workspace paused"),
"the pause notice must be attached to the failure comment: {}",
last.content
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn engineer_hard_failure_budget_exhaustion_fails_ticket() {
init_management_test_stores().await;
let (_lock, _policy_guard, _provider_guard) =
install_synthesis_test_seams(crate::util::test::FakeProvider::new());
let ws = create_test_workspace("/tmp/eng_trip_ws", "ws_eng_trip").await;
let ticket_id = make_ticket(board(), &ws, "Eng Trip", TicketPhase::InDevelopment).await;
let sibling_id = make_ticket(
board(),
&ws,
"Eng Trip Sibling",
TicketPhase::ReadyForDevelopment,
)
.await;
board()
.conn
.execute(
"UPDATE tickets SET bounce_count = ?1 WHERE id = ?2",
turso::params![
i64::try_from(crate::joint_verdict::MAX_BOUNCES).unwrap(),
ticket_id.as_str()
],
)
.await
.expect("set bounce_count to max");
let ticket = expect_ticket(board(), &ticket_id).await;
let mut agent = engineer_finalize_test_agent(&ws, &ticket, "trip");
agent.failure = Some("provider exploded".to_string());
finalize_engineer_round(&ticket, &agent, None, "job_eng_trip", false).await;
let t = expect_ticket(board(), &ticket_id).await;
assert_eq!(
t.phase,
TicketPhase::Failed,
"an engineer hard failure at the bounce budget max must fail the ticket"
);
assert_eq!(
t.bounce_count,
i64::try_from(crate::joint_verdict::MAX_BOUNCES).unwrap(),
"the failing bounce is not counted — the counter stays at the max"
);
let ws_after = crate::workspace::store()
.get_by_name("ws_eng_trip")
.await
.expect("query workspace")
.expect("workspace exists");
assert!(
ws_after.paused,
"the budget-exhausting hard failure must pause the workspace too"
);
let sibling = expect_ticket(board(), &sibling_id).await;
assert_eq!(
sibling.phase,
TicketPhase::ReadyForDevelopment,
"the engineer trip must NOT drain ReadyForDevelopment siblings to Planning"
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get comments");
let trip_idx = comments
.iter()
.position(|c| c.content.contains("circuit breaker"))
.expect("trip comment written");
let failure_idx = comments
.iter()
.rposition(|c| c.content.contains("provider exploded"))
.expect("failure comment written");
assert!(
trip_idx < failure_idx,
"the trip comment must be written BEFORE the failure comment so the \
notification's last-comment lookup surfaces the concrete error"
);
assert_eq!(
comments[failure_idx].role, SYSTEM_ROLE,
"the failure comment must be SYSTEM_ROLE"
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn engineer_cancel_fails_ticket_without_bounce() {
init_management_test_stores().await;
let (_lock, _policy_guard, _provider_guard) =
install_synthesis_test_seams(crate::util::test::FakeProvider::new());
let ws = create_test_workspace("/tmp/eng_cancel_ws", "ws_eng_cancel").await;
let ticket_id = make_ticket(board(), &ws, "Eng Cancel", TicketPhase::InDevelopment).await;
let ticket = expect_ticket(board(), &ticket_id).await;
let agent = engineer_finalize_test_agent(&ws, &ticket, "cancel");
crate::registry::AGENT_REGISTRY.cancel_by_ticket_id(&ticket_id);
finalize_engineer_round(&ticket, &agent, None, "job_eng_cancel", false).await;
let t = expect_ticket(board(), &ticket_id).await;
assert_eq!(
t.phase,
TicketPhase::Failed,
"a user-cancelled engineer run must fail the ticket, not bounce it"
);
assert_eq!(
t.bounce_count, 0,
"a user cancel must not consume the bounce budget"
);
let ws_after = crate::workspace::store()
.get_by_name("ws_eng_cancel")
.await
.expect("query workspace")
.expect("workspace exists");
assert!(
ws_after.paused,
"a user cancel must pause the workspace (unchanged)"
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get comments");
let last = comments.last().expect("failure comment written");
assert!(
last.content.contains("cancelled by user"),
"the cancel cause must be recorded: {}",
last.content
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn engineer_failure_during_drain_stays_queued_for_boot_resume() {
init_management_test_stores().await;
let _lock = crate::util::test::retry_tests_lock();
let ws = create_test_workspace("/tmp/eng_drain_ws", "ws_eng_drain").await;
let ticket_id = make_ticket(board(), &ws, "Eng Drain", TicketPhase::InDevelopment).await;
let job_id = "job_eng_drain";
let now = crate::turso::now();
JobRowBuilder::new(
&crate::session::store().conn,
job_id,
"ticket_stage",
"engineer",
&ws.name,
)
.timestamps(now)
.insert()
.await
.expect("insert launched job row");
let ticket = expect_ticket(board(), &ticket_id).await;
let mut agent = engineer_finalize_test_agent(&ws, &ticket, "drain");
agent.failure = Some("drained boom".to_string());
crate::shutdown::drain_begin();
finalize_engineer_round(&ticket, &agent, None, job_id, false).await;
crate::shutdown::drain_clear();
let t = expect_ticket(board(), &ticket_id).await;
assert_eq!(
t.phase,
TicketPhase::InDevelopment,
"a drain-cut engineer round must NOT transition the ticket — it stays queued for boot resume"
);
assert_eq!(
t.bounce_count, 0,
"a drain-cut round must not consume the bounce budget"
);
let ws_after = crate::workspace::store()
.get_by_name("ws_eng_drain")
.await
.expect("query workspace")
.expect("workspace exists");
assert!(
!ws_after.paused,
"a drain-cut round must not pause the workspace"
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get comments");
assert!(
comments.is_empty(),
"no failure comment during the drain (the job resumes at boot)"
);
let status: Option<String> = crate::session::store()
.conn
.query_optional(
"SELECT status FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
|row| row.get::<String>(0),
)
.await
.expect("query job status");
assert_eq!(
status.as_deref(),
Some("launched"),
"the drain-cut job must stay 'launched' for boot resume — never terminalized to 'done'"
);
}
#[tokio::test]
#[serial_test::serial(reset_inflight)]
#[expect(clippy::await_holding_lock)] async fn engineer_failure_post_pause_drain_bails_leaves_job_launched() {
init_management_test_stores().await;
let _lock = crate::util::test::retry_tests_lock();
let ws = create_test_workspace("/tmp/eng_midtail_ws", "ws_eng_midtail").await;
let ticket_id = make_ticket(
board(),
&ws,
"Eng Midtail Drain",
TicketPhase::InDevelopment,
)
.await;
let job_id = "job_eng_midtail";
let now = crate::turso::now();
JobRowBuilder::new(
&crate::session::store().conn,
job_id,
"ticket_stage",
"engineer",
&ws.name,
)
.timestamps(now)
.insert()
.await
.expect("insert launched job row");
let ticket = expect_ticket(board(), &ticket_id).await;
let mut agent = engineer_finalize_test_agent(&ws, &ticket, "midtail");
agent.failure = Some("drained boom".to_string());
crate::shutdown::drain_begin();
let bailed = handle_engineer_failure(&ticket, &agent, false).await;
crate::shutdown::drain_clear();
assert!(
!bailed,
"the mid-tail drain must report the bail so the caller keeps the job launched"
);
let t = expect_ticket(board(), &ticket_id).await;
assert_eq!(
t.phase,
TicketPhase::InDevelopment,
"the mid-tail drain bail must not transition the ticket"
);
assert_eq!(
t.bounce_count, 0,
"the mid-tail drain bail must not consume the bounce budget"
);
let ws_after = crate::workspace::store()
.get_by_name("ws_eng_midtail")
.await
.expect("query workspace")
.expect("workspace exists");
assert!(
!ws_after.paused,
"the mid-tail drain bail must not pause the workspace"
);
let comments = board()
.get_comments(&ticket_id)
.await
.expect("get comments");
assert!(
comments.is_empty(),
"no failure comment on the mid-tail drain bail"
);
let status: Option<String> = crate::session::store()
.conn
.query_optional(
"SELECT status FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
|row| row.get::<String>(0),
)
.await
.expect("query job status");
assert_eq!(
status.as_deref(),
Some("launched"),
"the mid-tail drain bail must leave the job 'launched' for boot resume"
);
}
#[test]
fn ticket_stage_agent_id_format() {
assert_eq!(
ticket_stage_agent_id("t-42", 0, "abc123", Role::Analyst),
"ticket_t-42_0_abc123_analyst",
"base-round slot 0"
);
assert_eq!(
ticket_stage_agent_id("t-42", 2, "abc123", Role::Analyst),
"ticket_t-42_2_abc123_analyst",
"base-round slot 2"
);
assert_eq!(
ticket_stage_agent_id("t-42", 3, "def456", Role::Analyst),
"ticket_t-42_3_def456_analyst",
"escalation slot 3"
);
assert_eq!(
ticket_stage_agent_id("t-42", 4, "def456", Role::Analyst),
"ticket_t-42_4_def456_analyst",
"escalation slot 4"
);
assert_eq!(
ticket_stage_agent_id("t-7", 0, "xyz789", Role::Reviewer),
"ticket_t-7_0_xyz789_reviewer"
);
assert_eq!(
ticket_stage_agent_id("t-7", 0, "xyz789", Role::Qa),
"ticket_t-7_0_xyz789_qa"
);
}
#[test]
fn ticket_stage_slot_task_angle_branches() {
let prompt = "Review the change";
let angles = vec!["angle one".to_string(), "angle two".to_string()];
assert_eq!(
ticket_stage_slot_task(prompt, &[], 3, 1),
prompt,
"no angles: shared prompt used verbatim"
);
assert_eq!(
ticket_stage_slot_task(prompt, &angles, 1, 0),
format!("{prompt}\n\nangle one\n\nangle two"),
"slot_count == 1 concatenates every angle section"
);
assert_eq!(
ticket_stage_slot_task(prompt, &angles, 2, 0),
format!("{prompt}\n\nangle one"),
"global idx 0 → first angle"
);
assert_eq!(
ticket_stage_slot_task(prompt, &angles, 2, 1),
format!("{prompt}\n\nangle two"),
"global idx 1 → second angle"
);
assert_eq!(
ticket_stage_slot_task(prompt, &angles, 2, 2),
format!("{prompt}\n\nangle one"),
"global idx 2 wraps to first angle"
);
assert_eq!(
ticket_stage_slot_task(prompt, &angles, 2, 3),
format!("{prompt}\n\nangle two"),
"global idx 3 wraps to second angle"
);
let angles3 = vec!["a".to_string(), "b".to_string(), "c".to_string()];
assert_eq!(
ticket_stage_slot_task(prompt, &angles3, 5, 3),
format!("{prompt}\n\na"),
"escalation global idx 3 → angles[3 % 3]"
);
assert_eq!(
ticket_stage_slot_task(prompt, &angles3, 5, 4),
format!("{prompt}\n\nb"),
"escalation global idx 4 → angles[4 % 3]"
);
}
struct ConfigGuard(crate::config::ConfigData);
impl ConfigGuard {
fn new(provider_key: Option<&str>, custom_endpoint: Option<&str>) -> Self {
let snapshot = crate::config::CONFIG.snapshot();
crate::config::CONFIG.swap(crate::config::ConfigData::STRUCT_FIELDS_DEFAULT);
if let Some(key) = provider_key {
let _ = crate::config::CONFIG.set_string_field("provider_key", key);
}
if let Some(endpoint) = custom_endpoint {
let _ = crate::config::CONFIG.set_string_field("provider_endpoint", endpoint);
}
Self(snapshot)
}
}
impl Drop for ConfigGuard {
fn drop(&mut self) {
crate::config::CONFIG.swap(self.0.clone());
}
}
#[tokio::test]
#[serial_test::serial(config_persist)] async fn pickup_pending_workspace_waits_for_provider() {
init_management_test_stores().await;
let _cfg = ConfigGuard::new(None, None);
let ws = create_test_workspace("/tmp/test_pickup_wait", "ws_pickup_wait").await;
assert_eq!(ws.status, WorkspaceStatus::Pending, "precondition: pending");
pickup_pending_workspace(&ws).await;
let stored = crate::workspace::store()
.get_by_name("ws_pickup_wait")
.await
.expect("fetch")
.expect("exists");
assert_eq!(
stored.status,
WorkspaceStatus::Pending,
"no provider configured → workspace stays pending"
);
assert!(
!stored.paused,
"no provider → not claimed, the pause toggle is untouched"
);
}
#[tokio::test]
#[serial_test::serial(config_persist)] async fn pickup_claim_claims_when_provider_configured() {
init_management_test_stores().await;
let _cfg = ConfigGuard::new(Some("sk-test"), None);
let ws = create_test_workspace("/tmp/test_pickup_claim", "ws_pickup_claim").await;
let claimed = pickup_claim(&ws).await;
let (generation, discover_diagnostics) = claimed.expect("claim should succeed");
assert_eq!(generation, 0, "fresh workspace has discovery_generation 0");
assert!(
discover_diagnostics,
"no diagnostics exist yet → first discovery must run diagnostics"
);
let stored = crate::workspace::store()
.get_by_name("ws_pickup_claim")
.await
.expect("fetch")
.expect("exists");
assert_eq!(
stored.status,
WorkspaceStatus::Analyzing,
"provider key configured → pending workspace claimed into discovery"
);
assert!(
stored.paused,
"the claim must set the analysis pause (blocks pipeline claims while discovery runs)"
);
}
#[tokio::test]
#[serial_test::serial(config_persist)] async fn pickup_claim_claims_without_key_when_custom_endpoint_persisted() {
init_management_test_stores().await;
let _cfg = ConfigGuard::new(None, Some("http://localhost:8080/v1"));
let ws = create_test_workspace("/tmp/test_pickup_endpoint", "ws_pickup_endpoint").await;
let claimed = pickup_claim(&ws).await;
let (generation, discover_diagnostics) = claimed
.expect("a persisted custom endpoint without a key must count as provider configured");
assert_eq!(generation, 0, "fresh workspace has discovery_generation 0");
assert!(
discover_diagnostics,
"no diagnostics exist yet → first discovery must run diagnostics"
);
let stored = crate::workspace::store()
.get_by_name("ws_pickup_endpoint")
.await
.expect("fetch")
.expect("exists");
assert_eq!(
stored.status,
WorkspaceStatus::Analyzing,
"keyless custom endpoint → pending workspace claimed into discovery"
);
assert!(
stored.paused,
"the claim must set the analysis pause (blocks pipeline claims while discovery runs)"
);
}
#[tokio::test]
#[serial_test::serial(config_persist)] async fn pickup_pending_workspace_respects_cooldown() {
init_management_test_stores().await;
let _cfg = ConfigGuard::new(Some("sk-test"), None);
let ws = create_test_workspace("/tmp/test_pickup_cooldown", "ws_pickup_cooldown").await;
crate::workspace::record_pending_pickup_cooldown("ws_pickup_cooldown");
pickup_pending_workspace(&ws).await;
let stored = crate::workspace::store()
.get_by_name("ws_pickup_cooldown")
.await
.expect("fetch")
.expect("exists");
assert_eq!(
stored.status,
WorkspaceStatus::Pending,
"armed cooldown → pickup must hold the workspace in pending"
);
crate::workspace::clear_pending_pickup_cooldown("ws_pickup_cooldown");
}
#[tokio::test]
#[serial_test::serial(config_persist)] async fn pickup_skips_non_pending_workspaces() {
init_management_test_stores().await;
let _cfg = ConfigGuard::new(Some("sk-test"), None);
let ws = create_test_workspace("/tmp/test_pickup_skip", "ws_pickup_skip").await;
crate::workspace::store()
.set_status("ws_pickup_skip", &WorkspaceStatus::Analyzing)
.await
.expect("set status");
pickup_pending_workspace(&ws).await;
let stored = crate::workspace::store()
.get_by_name("ws_pickup_skip")
.await
.expect("fetch")
.expect("exists");
assert_eq!(
stored.status,
WorkspaceStatus::Analyzing,
"pickup only touches pending workspaces"
);
}
#[tokio::test]
#[serial_test::serial(config_persist)] async fn pickup_claim_is_atomic_against_db_state() {
init_management_test_stores().await;
let _cfg = ConfigGuard::new(Some("sk-test"), None);
let ws = create_test_workspace("/tmp/test_pickup_race", "ws_pickup_race").await;
crate::workspace::store()
.claim_pending_for_discovery("ws_pickup_race")
.await
.expect("first claim")
.expect("should claim");
let claimed = pickup_claim(&ws).await;
assert!(
claimed.is_none(),
"an already-claimed row must not be claimed twice"
);
let stored = crate::workspace::store()
.get_by_name("ws_pickup_race")
.await
.expect("fetch")
.expect("exists");
assert_eq!(
stored.status,
WorkspaceStatus::Analyzing,
"already-claimed row stays analyzing (single claim)"
);
}