use crate::model::{Comment, IssueType, Priority, Status};
use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const COORDINATION_SCHEMA_VERSION: &str = "br.coordination.v1";
pub const SWARM_STALE_CANDIDATE_AFTER_MINUTES: i64 = 2 * 60;
pub const SWARM_ABANDONED_LIKELY_AFTER_MINUTES: i64 = 8 * 60;
pub const HUMAN_STALE_CANDIDATE_AFTER_MINUTES: i64 = 24 * 60;
pub const HUMAN_ABANDONED_LIKELY_AFTER_MINUTES: i64 = 72 * 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ClaimOwnerKind {
SwarmAgent,
Human,
Unknown,
}
impl ClaimOwnerKind {
#[must_use]
pub const fn stale_candidate_after_minutes(self) -> i64 {
match self {
Self::SwarmAgent => SWARM_STALE_CANDIDATE_AFTER_MINUTES,
Self::Human | Self::Unknown => HUMAN_STALE_CANDIDATE_AFTER_MINUTES,
}
}
#[must_use]
pub const fn abandoned_likely_after_minutes(self) -> i64 {
match self {
Self::SwarmAgent => SWARM_ABANDONED_LIKELY_AFTER_MINUTES,
Self::Human | Self::Unknown => HUMAN_ABANDONED_LIKELY_AFTER_MINUTES,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReservationSnapshotMatchReason {
HolderMatchesAssignee,
IssueId,
CommentPath,
}
impl ReservationSnapshotMatchReason {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::HolderMatchesAssignee => "holder_matches_assignee",
Self::IssueId => "issue_id",
Self::CommentPath => "comment_path",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AgentMailReservationSnapshot {
pub holder: String,
pub path_pattern: String,
pub exclusive: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub expires_ts: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub released_ts: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AgentMailAgentSnapshot {
pub name: String,
pub task_description: String,
pub last_active_ts: DateTime<Utc>,
pub contact_policy: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ReservationEvidenceProvenance {
pub path_pattern: String,
pub exclusive: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub expires_ts: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub released_ts: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
pub matched_on: Vec<ReservationSnapshotMatchReason>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case", tag = "state", content = "detail")]
pub enum ReservationEvidence {
NoSnapshot,
NoReservation,
Active {
holder: String,
#[serde(skip_serializing_if = "Option::is_none")]
expires_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
provenance: Option<ReservationEvidenceProvenance>,
},
Expired {
holder: String,
#[serde(skip_serializing_if = "Option::is_none")]
released_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
provenance: Option<ReservationEvidenceProvenance>,
},
InvalidSnapshot { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ClaimClassification {
Unassigned,
Fresh,
BlockedByActiveReservation,
StaleCandidate,
AbandonedLikely,
NoMailSnapshot,
Ambiguous,
}
impl ClaimClassification {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Unassigned => "unassigned",
Self::Fresh => "fresh",
Self::BlockedByActiveReservation => "blocked_by_active_reservation",
Self::StaleCandidate => "stale_candidate",
Self::AbandonedLikely => "abandoned_likely",
Self::NoMailSnapshot => "no_mail_snapshot",
Self::Ambiguous => "ambiguous",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RecommendedAction {
Observe,
AskOwner,
InspectMail,
ReclaimCandidate,
LeaveActive,
}
impl RecommendedAction {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Observe => "observe",
Self::AskOwner => "ask_owner",
Self::InspectMail => "inspect_mail",
Self::ReclaimCandidate => "reclaim_candidate",
Self::LeaveActive => "leave_active",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CoordinationSuggestedCommandPurpose {
AddReclaimAuditComment,
ClaimIssue,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CoordinationSuggestedCommand {
pub purpose: CoordinationSuggestedCommandPurpose,
pub command: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CoordinationClaimAdvisory {
pub reclaim_allowed_by_policy: bool,
pub required_human_confirmation: bool,
pub evidence_summary: String,
pub suggested_commands: Vec<CoordinationSuggestedCommand>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CoordinationEvidenceSource {
IssueMetadata,
PolicyThreshold,
AgentMailSnapshot,
NoAgentMailSnapshot,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CoordinationComment {
pub author: String,
pub text: String,
pub created_at: DateTime<Utc>,
}
impl From<&Comment> for CoordinationComment {
fn from(comment: &Comment) -> Self {
Self {
author: comment.author.clone(),
text: comment.body.clone(),
created_at: comment.created_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClaimAssessmentInput {
pub assignee: Option<String>,
pub updated_at: DateTime<Utc>,
pub now: DateTime<Utc>,
pub owner_kind: ClaimOwnerKind,
pub reservation: ReservationEvidence,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClaimAssessmentPolicy {
pub stale_candidate_after_minutes: i64,
pub abandoned_likely_after_minutes: i64,
}
impl ClaimAssessmentPolicy {
#[must_use]
pub const fn for_owner_kind(owner_kind: ClaimOwnerKind) -> Self {
Self {
stale_candidate_after_minutes: owner_kind.stale_candidate_after_minutes(),
abandoned_likely_after_minutes: owner_kind.abandoned_likely_after_minutes(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ClaimAssessment {
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
pub owner_kind: ClaimOwnerKind,
pub updated_at: DateTime<Utc>,
pub updated_age_minutes: i64,
pub stale_threshold_minutes: i64,
pub abandoned_threshold_minutes: i64,
pub reservation: ReservationEvidence,
pub classification: ClaimClassification,
pub recommended_action: RecommendedAction,
pub evidence_sources: Vec<CoordinationEvidenceSource>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CoordinationIssueRow {
pub id: String,
pub title: String,
pub status: Status,
pub priority: Priority,
pub issue_type: IssueType,
pub labels: Vec<String>,
pub dependency_count: usize,
pub dependent_count: usize,
pub latest_comments: Vec<CoordinationComment>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CoordinationClaimRow {
pub issue: CoordinationIssueRow,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<AgentMailAgentSnapshot>,
pub assessment: ClaimAssessment,
pub reclaim_allowed_by_policy: bool,
pub required_human_confirmation: bool,
pub evidence_summary: String,
pub suggested_commands: Vec<CoordinationSuggestedCommand>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CoordinationWorkspaceCounts {
pub open: usize,
pub ready: usize,
pub blocked: usize,
pub in_progress: usize,
pub deferred: usize,
pub closed: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CoordinationSummary {
pub total_claims: usize,
pub workspace: CoordinationWorkspaceCounts,
pub unassigned: usize,
pub fresh: usize,
pub blocked_by_active_reservation: usize,
pub stale_candidate: usize,
pub abandoned_likely: usize,
pub no_mail_snapshot: usize,
pub ambiguous: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CoordinationStatusOutput {
pub schema_version: String,
pub generated_at: DateTime<Utc>,
pub summary: CoordinationSummary,
pub claims: Vec<CoordinationClaimRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capacity: Vec<crate::format::CapacityStat>,
}
impl CoordinationStatusOutput {
#[must_use]
pub fn new(
generated_at: DateTime<Utc>,
workspace: CoordinationWorkspaceCounts,
claims: Vec<CoordinationClaimRow>,
) -> Self {
let summary = CoordinationSummary::from_claims(&claims, workspace);
Self {
schema_version: COORDINATION_SCHEMA_VERSION.to_string(),
generated_at,
summary,
claims,
capacity: Vec::new(),
}
}
}
impl CoordinationSummary {
#[must_use]
pub fn from_claims(
claims: &[CoordinationClaimRow],
workspace: CoordinationWorkspaceCounts,
) -> Self {
let mut summary = Self {
total_claims: claims.len(),
workspace,
unassigned: 0,
fresh: 0,
blocked_by_active_reservation: 0,
stale_candidate: 0,
abandoned_likely: 0,
no_mail_snapshot: 0,
ambiguous: 0,
};
for claim in claims {
match claim.assessment.classification {
ClaimClassification::Unassigned => summary.unassigned += 1,
ClaimClassification::Fresh => summary.fresh += 1,
ClaimClassification::BlockedByActiveReservation => {
summary.blocked_by_active_reservation += 1;
}
ClaimClassification::StaleCandidate => summary.stale_candidate += 1,
ClaimClassification::AbandonedLikely => summary.abandoned_likely += 1,
ClaimClassification::NoMailSnapshot => summary.no_mail_snapshot += 1,
ClaimClassification::Ambiguous => summary.ambiguous += 1,
}
}
summary
}
}
#[must_use]
pub fn assess_claim(input: ClaimAssessmentInput) -> ClaimAssessment {
let policy = ClaimAssessmentPolicy::for_owner_kind(input.owner_kind);
assess_claim_with_policy(input, policy)
}
#[must_use]
pub fn assess_claim_with_policy(
input: ClaimAssessmentInput,
policy: ClaimAssessmentPolicy,
) -> ClaimAssessment {
let assignee = input
.assignee
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let updated_age_minutes = input
.now
.signed_duration_since(input.updated_at)
.num_minutes()
.max(0);
let stale_threshold_minutes = policy.stale_candidate_after_minutes.max(0);
let abandoned_threshold_minutes = policy
.abandoned_likely_after_minutes
.max(stale_threshold_minutes);
let (classification, recommended_action) = classify_claim(
assignee.as_deref(),
input.owner_kind,
&input.reservation,
updated_age_minutes,
stale_threshold_minutes,
abandoned_threshold_minutes,
);
let evidence_sources = evidence_sources_for(&input.reservation);
ClaimAssessment {
assignee,
owner_kind: input.owner_kind,
updated_at: input.updated_at,
updated_age_minutes,
stale_threshold_minutes,
abandoned_threshold_minutes,
reservation: input.reservation,
classification,
recommended_action,
evidence_sources,
}
}
fn classify_claim(
assignee: Option<&str>,
owner_kind: ClaimOwnerKind,
reservation: &ReservationEvidence,
updated_age_minutes: i64,
stale_threshold_minutes: i64,
abandoned_threshold_minutes: i64,
) -> (ClaimClassification, RecommendedAction) {
if assignee.is_none() {
return (ClaimClassification::Unassigned, RecommendedAction::Observe);
}
if updated_age_minutes < stale_threshold_minutes {
return (ClaimClassification::Fresh, RecommendedAction::Observe);
}
match reservation {
ReservationEvidence::Active { .. } => (
ClaimClassification::BlockedByActiveReservation,
RecommendedAction::LeaveActive,
),
ReservationEvidence::NoSnapshot => (
ClaimClassification::NoMailSnapshot,
RecommendedAction::InspectMail,
),
ReservationEvidence::InvalidSnapshot { .. } => (
ClaimClassification::Ambiguous,
RecommendedAction::InspectMail,
),
ReservationEvidence::NoReservation | ReservationEvidence::Expired { .. } => {
if updated_age_minutes >= abandoned_threshold_minutes {
(
ClaimClassification::AbandonedLikely,
recommended_reclaim_action(owner_kind),
)
} else {
(
ClaimClassification::StaleCandidate,
recommended_reclaim_action(owner_kind),
)
}
}
}
}
const fn recommended_reclaim_action(owner_kind: ClaimOwnerKind) -> RecommendedAction {
match owner_kind {
ClaimOwnerKind::SwarmAgent => RecommendedAction::ReclaimCandidate,
ClaimOwnerKind::Human | ClaimOwnerKind::Unknown => RecommendedAction::AskOwner,
}
}
fn evidence_sources_for(reservation: &ReservationEvidence) -> Vec<CoordinationEvidenceSource> {
let mut sources = vec![
CoordinationEvidenceSource::IssueMetadata,
CoordinationEvidenceSource::PolicyThreshold,
];
match reservation {
ReservationEvidence::NoSnapshot => {
sources.push(CoordinationEvidenceSource::NoAgentMailSnapshot);
}
ReservationEvidence::NoReservation
| ReservationEvidence::Active { .. }
| ReservationEvidence::Expired { .. }
| ReservationEvidence::InvalidSnapshot { .. } => {
sources.push(CoordinationEvidenceSource::AgentMailSnapshot);
}
}
sources
}
#[must_use]
pub fn advisory_for_claim(
issue_id: &str,
assessment: &ClaimAssessment,
) -> CoordinationClaimAdvisory {
let reclaim_allowed_by_policy = reclaim_allowed_by_policy(assessment);
let required_human_confirmation =
matches!(assessment.recommended_action, RecommendedAction::AskOwner);
let evidence_summary = evidence_summary_for(assessment);
let suggested_commands = if reclaim_allowed_by_policy {
reclaim_suggested_commands(issue_id, &evidence_summary)
} else {
Vec::new()
};
CoordinationClaimAdvisory {
reclaim_allowed_by_policy,
required_human_confirmation,
evidence_summary,
suggested_commands,
}
}
fn reclaim_allowed_by_policy(assessment: &ClaimAssessment) -> bool {
matches!(assessment.owner_kind, ClaimOwnerKind::SwarmAgent)
&& matches!(
assessment.classification,
ClaimClassification::StaleCandidate | ClaimClassification::AbandonedLikely
)
&& matches!(
assessment.recommended_action,
RecommendedAction::ReclaimCandidate
)
&& matches!(
assessment.reservation,
ReservationEvidence::NoReservation | ReservationEvidence::Expired { .. }
)
}
fn evidence_summary_for(assessment: &ClaimAssessment) -> String {
let assignee = assessment.assignee.as_deref().unwrap_or("(unassigned)");
format!(
"updated_at={}, assignee={}, owner_kind={}, age_minutes={}, stale_threshold_minutes={}, abandoned_threshold_minutes={}, reservation_status={}",
assessment.updated_at,
assignee,
owner_kind_summary(assessment.owner_kind),
assessment.updated_age_minutes,
assessment.stale_threshold_minutes,
assessment.abandoned_threshold_minutes,
reservation_summary(&assessment.reservation)
)
}
const fn owner_kind_summary(owner_kind: ClaimOwnerKind) -> &'static str {
match owner_kind {
ClaimOwnerKind::SwarmAgent => "swarm_agent",
ClaimOwnerKind::Human => "human",
ClaimOwnerKind::Unknown => "unknown",
}
}
fn reservation_summary(reservation: &ReservationEvidence) -> String {
match reservation {
ReservationEvidence::NoSnapshot => "no_snapshot".to_string(),
ReservationEvidence::NoReservation => "no_reservation".to_string(),
ReservationEvidence::Active {
holder, expires_at, ..
} => format!(
"active(holder={},expires_at={})",
holder,
optional_timestamp(expires_at.as_ref())
),
ReservationEvidence::Expired {
holder,
released_at,
..
} => format!(
"expired(holder={},released_at={})",
holder,
optional_timestamp(released_at.as_ref())
),
ReservationEvidence::InvalidSnapshot { reason } => {
format!("invalid_snapshot(reason={reason})")
}
}
}
fn optional_timestamp(timestamp: Option<&DateTime<Utc>>) -> String {
timestamp.map_or_else(|| "none".to_string(), ToString::to_string)
}
fn reclaim_suggested_commands(
issue_id: &str,
evidence_summary: &str,
) -> Vec<CoordinationSuggestedCommand> {
let audit_message = format!(
"reclaim: previous in_progress claim appears abandoned; evidence: {evidence_summary}; no active reservation found in supplied snapshot; pane_status=not_checked_by_br"
);
vec![
CoordinationSuggestedCommand {
purpose: CoordinationSuggestedCommandPurpose::AddReclaimAuditComment,
command: format!(
"br comments add {} --author \"$AGENT_NAME\" --message {} --json",
shell_quote(issue_id),
shell_quote(&audit_message)
),
},
CoordinationSuggestedCommand {
purpose: CoordinationSuggestedCommandPurpose::ClaimIssue,
command: format!("br update {} --claim --json", shell_quote(issue_id)),
},
]
}
fn shell_quote(value: &str) -> String {
if value.is_empty() {
return "''".to_string();
}
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[must_use]
pub fn reservation_evidence_from_snapshots(
issue_id: &str,
assignee: Option<&str>,
comments: &[Comment],
reservations: Option<&[AgentMailReservationSnapshot]>,
now: DateTime<Utc>,
) -> ReservationEvidence {
let Some(reservations) = reservations else {
return ReservationEvidence::NoSnapshot;
};
let mut expired_match = None;
for reservation in reservations {
let matched_on = reservation_match_reasons(issue_id, assignee, comments, reservation);
if matched_on.is_empty() {
continue;
}
let provenance = reservation.provenance(matched_on);
if reservation.is_active(now) {
return ReservationEvidence::Active {
holder: reservation.holder.clone(),
expires_at: Some(reservation.expires_ts),
provenance: Some(provenance),
};
}
expired_match.get_or_insert_with(|| ReservationEvidence::Expired {
holder: reservation.holder.clone(),
released_at: reservation.released_ts,
provenance: Some(provenance),
});
}
expired_match.unwrap_or(ReservationEvidence::NoReservation)
}
fn reservation_match_reasons(
issue_id: &str,
assignee: Option<&str>,
comments: &[Comment],
reservation: &AgentMailReservationSnapshot,
) -> Vec<ReservationSnapshotMatchReason> {
let mut reasons = Vec::new();
let holder = reservation.holder.trim();
if assignee
.map(str::trim)
.is_some_and(|value| !value.is_empty() && value.eq_ignore_ascii_case(holder))
{
reasons.push(ReservationSnapshotMatchReason::HolderMatchesAssignee);
}
if reservation
.reason
.as_deref()
.is_some_and(|reason| reason.contains(issue_id))
|| reservation
.thread_id
.as_deref()
.is_some_and(|thread_id| thread_id == issue_id)
{
reasons.push(ReservationSnapshotMatchReason::IssueId);
}
let path = reservation.path_pattern.trim();
if !path.is_empty() && comments.iter().any(|comment| comment.body.contains(path)) {
reasons.push(ReservationSnapshotMatchReason::CommentPath);
}
reasons
}
impl AgentMailReservationSnapshot {
fn is_active(&self, now: DateTime<Utc>) -> bool {
self.released_ts.is_none() && self.expires_ts > now
}
fn provenance(
&self,
matched_on: Vec<ReservationSnapshotMatchReason>,
) -> ReservationEvidenceProvenance {
ReservationEvidenceProvenance {
path_pattern: self.path_pattern.clone(),
exclusive: self.exclusive,
reason: self.reason.clone(),
expires_ts: self.expires_ts,
released_ts: self.released_ts,
thread_id: self.thread_id.clone(),
matched_on,
}
}
}
#[cfg(test)]
mod tests {
use super::{
AgentMailReservationSnapshot, COORDINATION_SCHEMA_VERSION, ClaimAssessmentInput,
ClaimClassification, ClaimOwnerKind, CoordinationClaimRow, CoordinationComment,
CoordinationEvidenceSource, CoordinationIssueRow, CoordinationStatusOutput,
CoordinationSuggestedCommandPurpose, CoordinationWorkspaceCounts, RecommendedAction,
ReservationEvidence, advisory_for_claim, assess_claim, reservation_evidence_from_snapshots,
};
use crate::model::{Comment, IssueType, Priority, Status};
use chrono::{Duration, TimeZone, Utc};
fn now() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 5, 8, 9, 0, 0)
.single()
.expect("valid timestamp")
}
fn input(
minutes_old: i64,
owner_kind: ClaimOwnerKind,
reservation: ReservationEvidence,
) -> ClaimAssessmentInput {
ClaimAssessmentInput {
assignee: Some("TopazFox".to_string()),
updated_at: now() - Duration::minutes(minutes_old),
now: now(),
owner_kind,
reservation,
}
}
fn claim_row(id: &str, assessment: super::ClaimAssessment) -> CoordinationClaimRow {
let advisory = advisory_for_claim(id, &assessment);
CoordinationClaimRow {
issue: CoordinationIssueRow {
id: id.to_string(),
title: format!("Claim {id}"),
status: Status::InProgress,
priority: Priority(1),
issue_type: IssueType::Task,
labels: vec!["coordination".to_string()],
dependency_count: 1,
dependent_count: 2,
latest_comments: vec![CoordinationComment {
author: "TopazFox".to_string(),
text: "latest evidence".to_string(),
created_at: now(),
}],
},
agent: None,
assessment,
reclaim_allowed_by_policy: advisory.reclaim_allowed_by_policy,
required_human_confirmation: advisory.required_human_confirmation,
evidence_summary: advisory.evidence_summary,
suggested_commands: advisory.suggested_commands,
}
}
#[test]
fn swarm_claim_below_two_hours_is_fresh() {
let assessment = assess_claim(input(
119,
ClaimOwnerKind::SwarmAgent,
ReservationEvidence::NoReservation,
));
assert_eq!(assessment.updated_age_minutes, 119);
assert_eq!(assessment.stale_threshold_minutes, 120);
assert_eq!(assessment.classification, ClaimClassification::Fresh);
assert_eq!(assessment.recommended_action, RecommendedAction::Observe);
}
#[test]
fn old_swarm_claim_without_mail_snapshot_requires_mail_inspection() {
let assessment = assess_claim(input(
120,
ClaimOwnerKind::SwarmAgent,
ReservationEvidence::NoSnapshot,
));
assert_eq!(
assessment.classification,
ClaimClassification::NoMailSnapshot
);
assert_eq!(
assessment.recommended_action,
RecommendedAction::InspectMail
);
assert!(
assessment
.evidence_sources
.contains(&CoordinationEvidenceSource::NoAgentMailSnapshot)
);
}
#[test]
fn absent_reservation_snapshot_match_marks_stale_candidate() {
let assessment = assess_claim(input(
120,
ClaimOwnerKind::SwarmAgent,
ReservationEvidence::NoReservation,
));
assert_eq!(
assessment.classification,
ClaimClassification::StaleCandidate
);
assert_eq!(
assessment.recommended_action,
RecommendedAction::ReclaimCandidate
);
}
#[test]
fn very_old_swarm_claim_is_abandoned_likely() {
let assessment = assess_claim(input(
8 * 60,
ClaimOwnerKind::SwarmAgent,
ReservationEvidence::Expired {
holder: "TopazFox".to_string(),
released_at: None,
provenance: None,
},
));
assert_eq!(
assessment.classification,
ClaimClassification::AbandonedLikely
);
assert_eq!(assessment.abandoned_threshold_minutes, 8 * 60);
assert_eq!(
assessment.recommended_action,
RecommendedAction::ReclaimCandidate
);
}
#[test]
fn active_reservation_blocks_reclaim_even_when_old() {
let assessment = assess_claim(input(
12 * 60,
ClaimOwnerKind::SwarmAgent,
ReservationEvidence::Active {
holder: "TopazFox".to_string(),
expires_at: Some(now() + Duration::minutes(30)),
provenance: None,
},
));
assert_eq!(
assessment.classification,
ClaimClassification::BlockedByActiveReservation
);
assert_eq!(
assessment.recommended_action,
RecommendedAction::LeaveActive
);
}
#[test]
fn human_and_unknown_claims_use_one_business_day_threshold() {
let human_fresh = assess_claim(input(
23 * 60,
ClaimOwnerKind::Human,
ReservationEvidence::NoReservation,
));
let unknown_stale = assess_claim(input(
24 * 60,
ClaimOwnerKind::Unknown,
ReservationEvidence::NoReservation,
));
assert_eq!(human_fresh.classification, ClaimClassification::Fresh);
assert_eq!(unknown_stale.stale_threshold_minutes, 24 * 60);
assert_eq!(
unknown_stale.classification,
ClaimClassification::StaleCandidate
);
assert_eq!(
unknown_stale.recommended_action,
RecommendedAction::AskOwner
);
}
#[test]
fn future_updated_at_saturates_age_to_zero() {
let assessment = assess_claim(ClaimAssessmentInput {
assignee: Some("TopazFox".to_string()),
updated_at: now() + Duration::minutes(5),
now: now(),
owner_kind: ClaimOwnerKind::SwarmAgent,
reservation: ReservationEvidence::NoReservation,
});
assert_eq!(assessment.updated_age_minutes, 0);
assert_eq!(assessment.classification, ClaimClassification::Fresh);
}
#[test]
fn empty_or_whitespace_assignee_is_unassigned() {
let assessment = assess_claim(ClaimAssessmentInput {
assignee: Some(" ".to_string()),
updated_at: now() - Duration::hours(12),
now: now(),
owner_kind: ClaimOwnerKind::SwarmAgent,
reservation: ReservationEvidence::NoReservation,
});
assert_eq!(assessment.assignee, None);
assert_eq!(assessment.classification, ClaimClassification::Unassigned);
assert_eq!(assessment.recommended_action, RecommendedAction::Observe);
}
#[test]
fn invalid_snapshot_is_ambiguous_not_abandoned() {
let assessment = assess_claim(input(
8 * 60,
ClaimOwnerKind::SwarmAgent,
ReservationEvidence::InvalidSnapshot {
reason: "missing holder field".to_string(),
},
));
assert_eq!(assessment.classification, ClaimClassification::Ambiguous);
assert_eq!(
assessment.recommended_action,
RecommendedAction::InspectMail
);
}
#[test]
fn status_output_sets_schema_and_counts_classifications() {
let fresh = assess_claim(input(
30,
ClaimOwnerKind::SwarmAgent,
ReservationEvidence::NoReservation,
));
let stale = assess_claim(input(
120,
ClaimOwnerKind::SwarmAgent,
ReservationEvidence::NoReservation,
));
let workspace = CoordinationWorkspaceCounts {
open: 3,
ready: 1,
blocked: 1,
in_progress: 2,
deferred: 0,
closed: 5,
};
let output = CoordinationStatusOutput::new(
now(),
workspace,
vec![claim_row("bd-fresh", fresh), claim_row("bd-stale", stale)],
);
assert_eq!(output.schema_version, COORDINATION_SCHEMA_VERSION);
assert_eq!(output.summary.total_claims, 2);
assert_eq!(output.summary.workspace.in_progress, 2);
assert_eq!(output.summary.fresh, 1);
assert_eq!(output.summary.stale_candidate, 1);
assert_eq!(
output.claims[0].issue.latest_comments[0].text,
"latest evidence"
);
}
#[test]
fn coordination_status_schema_declares_required_sections() {
let schema = schemars::schema_for!(CoordinationStatusOutput);
let value = serde_json::to_value(schema).expect("schema serializes");
let required = value["required"].as_array().expect("schema has required");
for field in ["schema_version", "generated_at", "summary", "claims"] {
assert!(
required.iter().any(|entry| entry.as_str() == Some(field)),
"CoordinationStatusOutput schema should require {field}"
);
}
}
#[test]
fn reservation_snapshot_active_match_blocks_reclaim_with_provenance() {
let reservations = vec![AgentMailReservationSnapshot {
holder: "TopazFox".to_string(),
path_pattern: "src/coordination.rs".to_string(),
exclusive: true,
reason: Some("beads_rust-sc6u optional snapshot work".to_string()),
expires_ts: now() + Duration::minutes(30),
released_ts: None,
thread_id: Some("beads_rust-sc6u".to_string()),
}];
let evidence = reservation_evidence_from_snapshots(
"beads_rust-sc6u",
Some("TopazFox"),
&[],
Some(&reservations),
now(),
);
let assessment = assess_claim(ClaimAssessmentInput {
assignee: Some("TopazFox".to_string()),
updated_at: now() - Duration::hours(12),
now: now(),
owner_kind: ClaimOwnerKind::SwarmAgent,
reservation: evidence,
});
assert_eq!(
assessment.classification,
ClaimClassification::BlockedByActiveReservation
);
assert!(matches!(
assessment.reservation,
ReservationEvidence::Active {
provenance: Some(_),
..
}
));
}
#[test]
fn reservation_snapshot_absent_match_marks_no_reservation() {
let reservations = vec![AgentMailReservationSnapshot {
holder: "AmberLion".to_string(),
path_pattern: "src/other.rs".to_string(),
exclusive: true,
reason: Some("different bead".to_string()),
expires_ts: now() + Duration::minutes(30),
released_ts: None,
thread_id: Some("beads_rust-other".to_string()),
}];
let evidence = reservation_evidence_from_snapshots(
"beads_rust-sc6u",
Some("TopazFox"),
&[],
Some(&reservations),
now(),
);
assert_eq!(evidence, ReservationEvidence::NoReservation);
}
#[test]
fn reservation_snapshot_comment_path_match_is_enough_to_correlate() {
let reservations = vec![AgentMailReservationSnapshot {
holder: "AmberLion".to_string(),
path_pattern: "src/coordination.rs".to_string(),
exclusive: true,
reason: None,
expires_ts: now() - Duration::minutes(5),
released_ts: None,
thread_id: None,
}];
let comments = vec![Comment {
id: 1,
issue_id: "beads_rust-sc6u".to_string(),
author: "TopazFox".to_string(),
body: "files: src/coordination.rs".to_string(),
created_at: now(),
}];
let evidence = reservation_evidence_from_snapshots(
"beads_rust-sc6u",
Some("TopazFox"),
&comments,
Some(&reservations),
now(),
);
assert!(matches!(
evidence,
ReservationEvidence::Expired {
provenance: Some(_),
..
}
));
}
#[test]
fn advisory_emits_commands_only_for_swarm_reclaim_candidates() {
let stale = assess_claim(input(
120,
ClaimOwnerKind::SwarmAgent,
ReservationEvidence::NoReservation,
));
let advisory = advisory_for_claim("bd-stale", &stale);
assert!(advisory.reclaim_allowed_by_policy);
assert!(!advisory.required_human_confirmation);
assert_eq!(advisory.suggested_commands.len(), 2);
assert_eq!(
advisory.suggested_commands[0].purpose,
CoordinationSuggestedCommandPurpose::AddReclaimAuditComment
);
assert_eq!(
advisory.suggested_commands[1].purpose,
CoordinationSuggestedCommandPurpose::ClaimIssue
);
assert!(
advisory.suggested_commands[0]
.command
.contains("br comments add")
);
assert!(advisory.suggested_commands[1].command.contains("br update"));
assert!(
advisory.suggested_commands[0]
.command
.contains("updated_at=2026-05-08")
);
assert!(
advisory.suggested_commands[0]
.command
.contains("assignee=TopazFox")
);
assert!(
advisory.suggested_commands[0]
.command
.contains("stale_threshold_minutes=120")
);
assert!(
advisory.suggested_commands[0]
.command
.contains("reservation_status=no_reservation")
);
let missing_snapshot = assess_claim(input(
120,
ClaimOwnerKind::SwarmAgent,
ReservationEvidence::NoSnapshot,
));
let advisory = advisory_for_claim("bd-stale", &missing_snapshot);
assert!(!advisory.reclaim_allowed_by_policy);
assert!(advisory.suggested_commands.is_empty());
}
#[test]
fn advisory_requires_confirmation_for_human_or_unknown_stale_claims() {
let human = assess_claim(input(
24 * 60,
ClaimOwnerKind::Human,
ReservationEvidence::NoReservation,
));
let unknown = assess_claim(input(
24 * 60,
ClaimOwnerKind::Unknown,
ReservationEvidence::NoReservation,
));
for assessment in [human, unknown] {
let advisory = advisory_for_claim("bd-stale", &assessment);
assert!(!advisory.reclaim_allowed_by_policy);
assert!(advisory.required_human_confirmation);
assert!(advisory.suggested_commands.is_empty());
}
}
#[test]
fn advisory_omits_commands_for_fresh_or_active_claims() {
let fresh = assess_claim(input(
119,
ClaimOwnerKind::SwarmAgent,
ReservationEvidence::NoReservation,
));
let active = assess_claim(input(
12 * 60,
ClaimOwnerKind::SwarmAgent,
ReservationEvidence::Active {
holder: "TopazFox".to_string(),
expires_at: Some(now() + Duration::minutes(30)),
provenance: None,
},
));
for assessment in [fresh, active] {
let advisory = advisory_for_claim("bd-stale", &assessment);
assert!(!advisory.reclaim_allowed_by_policy);
assert!(!advisory.required_human_confirmation);
assert!(advisory.suggested_commands.is_empty());
}
}
}