use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use jiff::{Timestamp, Zoned};
use serde::{Deserialize, Serialize};
use crate::agent::SeatState;
use crate::blind::Leak;
use crate::config::{Config, MergeMode};
use crate::verdict::{Finding, Rejection, ReviewVote, Severity};
pub const SCHEMA: u32 = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Prep,
Implementing,
Judging,
Deliberating,
Voting,
Reviewing,
Gating,
Landing,
Merged,
Ready,
Stalled,
Blocked,
Failed,
VerifiedNoop,
}
impl RunStatus {
pub fn done(self) -> bool {
matches!(
self,
Self::Merged
| Self::Ready
| Self::Stalled
| Self::Blocked
| Self::Failed
| Self::VerifiedNoop
)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Prep => "prep",
Self::Implementing => "implementing",
Self::Judging => "judging",
Self::Deliberating => "deliberating",
Self::Voting => "voting",
Self::Reviewing => "reviewing",
Self::Gating => "gating",
Self::Landing => "landing",
Self::Merged => "merged",
Self::Ready => "ready",
Self::Stalled => "stalled",
Self::Blocked => "blocked",
Self::Failed => "failed",
Self::VerifiedNoop => "verified_noop",
}
}
pub fn display_label(self) -> &'static str {
match self {
Self::VerifiedNoop => "agent-verified no-op",
other => other.as_str(),
}
}
pub fn resumable(self) -> bool {
!matches!(
self,
Self::Merged | Self::Ready | Self::Failed | Self::VerifiedNoop
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Candidate {
pub index: usize,
pub label: char,
pub agent: String,
pub branch: String,
pub worktree: PathBuf,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub stat: String,
#[serde(default)]
pub files: usize,
#[serde(default)]
pub commits: usize,
#[serde(default)]
pub empty: bool,
#[serde(default)]
pub failed: Option<String>,
#[serde(default)]
pub verified_noop: Option<String>,
#[serde(default)]
pub duration_ms: u64,
#[serde(default)]
pub folded: bool,
}
impl Candidate {
pub fn viable(&self) -> bool {
self.failed.is_none() && !self.empty
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Judgement {
pub judge: usize,
pub seat: String,
pub agent: String,
#[serde(default)]
pub ranking: Vec<char>,
#[serde(default)]
pub reasons: BTreeMap<String, String>,
#[serde(default)]
pub confidence: Option<u8>,
#[serde(default)]
pub order: Vec<usize>,
#[serde(default)]
pub failed: Option<String>,
#[serde(default)]
pub duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeliberationTurn {
pub judge: usize,
pub agent: String,
pub body: String,
#[serde(default)]
pub tentative: Option<char>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeliberationRound {
pub round: usize,
pub turns: Vec<DeliberationTurn>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoteRecord {
pub judge: usize,
pub agent: String,
#[serde(default)]
pub vote: Option<char>,
#[serde(default)]
pub reason: String,
#[serde(default)]
pub changed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuotaLoss {
pub seat: String,
pub node: String,
pub at: Timestamp,
#[serde(default)]
pub reset: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tally {
pub first_choice: BTreeMap<char, usize>,
pub borda: BTreeMap<char, usize>,
pub winner: char,
#[serde(default)]
pub rankings: usize,
pub unanimous_initial: bool,
pub deliberated: bool,
pub changed_votes: usize,
pub unanimous_final: bool,
#[serde(default)]
pub tie_break: Option<String>,
#[serde(default)]
pub judges: usize,
#[serde(default)]
pub present: usize,
#[serde(default)]
pub quorum: usize,
#[serde(default)]
pub met_quorum: bool,
#[serde(default)]
pub uncontested: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewRecord {
pub reviewer: usize,
pub agent: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub findings: Vec<Finding>,
#[serde(default)]
pub vote: Option<ReviewVote>,
#[serde(default)]
pub failed: Option<String>,
#[serde(default)]
pub duration_ms: u64,
#[serde(default)]
pub attempts: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewRevoteRecord {
pub reviewer: usize,
pub agent: String,
#[serde(default)]
pub vote: Option<ReviewVote>,
#[serde(default)]
pub reason: String,
#[serde(default)]
pub failed: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixRecord {
pub agent: String,
#[serde(default)]
pub addressed: Vec<String>,
#[serde(default)]
pub rejected: Vec<Rejection>,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub committed: bool,
#[serde(default)]
pub failed: Option<String>,
#[serde(default)]
pub duration_ms: u64,
#[serde(default)]
pub continuation: Option<ContinuationRecord>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ContinuationOutcome {
NotNeeded,
Resumed,
Exhausted,
QuotaLost,
NoSession,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct ContinuationRecord {
pub attempts: usize,
pub cumulative_wait_ms: u64,
pub outcome: ContinuationOutcome,
}
impl ContinuationRecord {
pub fn not_needed() -> Self {
Self {
attempts: 0,
cumulative_wait_ms: 0,
outcome: ContinuationOutcome::NotNeeded,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OperatorFixOutcome {
#[default]
Pending,
Addressed,
Rejected {
why: String,
},
Unreported,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperatorFixFinding {
pub id: String,
pub severity: Severity,
#[serde(default)]
pub reviewer_vote: Option<ReviewVote>,
pub round: usize,
pub round_head: String,
pub reviewer: usize,
pub agent: String,
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub line: Option<u32>,
pub title: String,
#[serde(default)]
pub detail: String,
#[serde(default)]
pub outcome: OperatorFixOutcome,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperatorFixRequest {
pub requested_at: Timestamp,
pub reason: String,
pub findings: Vec<OperatorFixFinding>,
pub head_at_request: String,
pub allow_stale: bool,
pub stale: bool,
#[serde(default)]
pub fix: Option<FixRecord>,
#[serde(default)]
pub result_head: Option<String>,
#[serde(default)]
pub follow_up_review_run: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobRecord {
pub node: String,
#[serde(default)]
pub round: Option<usize>,
pub seat: String,
pub id: String,
pub description: String,
pub checked_at: Timestamp,
pub status: JobStatus,
pub exit_code: Option<i32>,
#[serde(default)]
pub result_summary: String,
pub source: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum JobStatus {
Completed,
Failed,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandOutcome {
pub command: String,
pub code: Option<i32>,
#[serde(default)]
pub output_tail: String,
#[serde(default)]
pub duration_ms: u64,
#[serde(default)]
pub resource_blocked: bool,
}
const BUILD_FAILURE_MARKERS: &[&str] = &[
"error: could not compile",
"error: linking with",
"LINK : fatal error",
"fatal error LNK",
];
impl CommandOutcome {
pub fn ok(&self) -> bool {
self.code == Some(0)
}
pub fn build_failed(&self) -> bool {
!self.ok()
&& BUILD_FAILURE_MARKERS
.iter()
.any(|m| self.output_tail.contains(m))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewRound {
pub round: usize,
pub head: String,
#[serde(default)]
pub verified_head: Option<String>,
#[serde(default)]
pub verified_at: Option<Timestamp>,
pub reviews: Vec<ReviewRecord>,
#[serde(default)]
pub e2e: Vec<CommandOutcome>,
#[serde(default)]
pub verify_retried: bool,
#[serde(default)]
pub e2e_deferred: bool,
#[serde(default)]
pub e2e_defer_reason: Option<String>,
#[serde(default)]
pub fix: Option<FixRecord>,
#[serde(default)]
pub blocking: usize,
#[serde(default)]
pub answered: usize,
#[serde(default)]
pub expected: usize,
#[serde(default)]
pub clean: bool,
#[serde(default)]
pub progressed: bool,
#[serde(default)]
pub vote_split: bool,
#[serde(default)]
pub reconsideration: Vec<ReviewRevoteRecord>,
#[serde(default)]
pub verdict: Option<ReviewVote>,
}
impl ReviewRound {
pub fn incomplete(&self) -> bool {
self.answered < self.expected
}
pub fn e2e_status(&self) -> E2eStatus {
if self.e2e.iter().any(|o| o.resource_blocked) {
E2eStatus::ResourceBlocked
} else if !self.e2e.is_empty() {
if self.e2e.iter().all(CommandOutcome::ok) {
E2eStatus::Passed
} else {
E2eStatus::Failed
}
} else if self.e2e_deferred {
E2eStatus::Deferred
} else {
E2eStatus::NotConfigured
}
}
pub fn verification_summary(&self, current_head: &str) -> Option<VerificationSummary> {
let status = self.e2e_status();
if matches!(status, E2eStatus::NotConfigured | E2eStatus::Passed) {
return None;
}
let commit = match &self.verified_head {
Some(h) if h == current_head => {
format!("commit {} (this is the head being looked at now)", short(h))
}
Some(h) => format!("commit {} (an earlier head, since superseded)", short(h)),
None => "commit unknown (no command finished checking one)".to_owned(),
};
let checked_at = match self.verified_at {
Some(t) => format!("checked at {t}"),
None => "checked at: unknown (recorded before this was tracked)".to_owned(),
};
let result = match status {
E2eStatus::NotConfigured | E2eStatus::Passed => unreachable!("checked above"),
E2eStatus::Failed => "result: FAILED".to_owned(),
E2eStatus::Deferred => format!(
"result: not run this round yet — deferred to the fixer{}. Not passed, not \
failed.",
self.e2e_defer_reason
.as_deref()
.map(|why| format!(" ({why})"))
.unwrap_or_default()
),
E2eStatus::ResourceBlocked => "result: could not run — the shared build cache was \
not available. This is evidence about the machine, \
not about the patch."
.to_owned(),
};
let label = format!("round {}, {commit}, {checked_at}\n{result}", self.round);
let tail = matches!(status, E2eStatus::Failed | E2eStatus::ResourceBlocked).then(|| {
self.e2e
.iter()
.filter(|o| !o.ok())
.map(|o| format!("$ {}\n{}\n", o.command, o.output_tail))
.collect::<String>()
});
Some(VerificationSummary { label, tail })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum E2eStatus {
NotConfigured,
Deferred,
Passed,
Failed,
ResourceBlocked,
}
#[derive(Debug, Clone)]
pub struct VerificationSummary {
pub label: String,
pub tail: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateStatus {
NotRun,
PassedWithNoCommands,
Passed,
Failed,
}
impl GateStatus {
pub fn ok(self) -> bool {
matches!(self, Self::PassedWithNoCommands | Self::Passed)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergeOutcome {
pub mode: MergeMode,
pub ok: bool,
#[serde(default)]
pub detail: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveSeat {
pub node: String,
pub started_at: Timestamp,
pub timeout_secs: u64,
#[serde(default)]
pub attempt: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total: Option<usize>,
}
impl ActiveSeat {
#[must_use]
pub fn elapsed_secs(&self, now: Timestamp) -> i64 {
(now.as_second() - self.started_at.as_second()).max(0)
}
#[must_use]
pub fn remaining_secs(&self, now: Timestamp) -> i64 {
(self.timeout_secs as i64 - self.elapsed_secs(now)).max(0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Liveness {
Live,
Dead,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub at: Timestamp,
pub node: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaseSync {
pub tip: String,
pub behind: usize,
pub attempts: usize,
#[serde(default)]
pub conflict: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrRecord {
pub url: String,
pub number: u64,
pub state: String,
pub checks: String,
pub round: usize,
pub rounds: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunState {
pub schema: u32,
pub id: String,
pub repo: PathBuf,
pub base_branch: String,
pub base_commit: String,
pub instruction: String,
pub created_at: Timestamp,
pub updated_at: Timestamp,
pub status: RunStatus,
pub seed: u64,
pub config: Config,
#[serde(default)]
pub enabled_worktree_config: bool,
#[serde(default)]
pub candidates: Vec<Candidate>,
#[serde(default)]
pub judgements: Vec<Judgement>,
#[serde(default)]
pub judge_skipped: bool,
#[serde(default)]
pub deliberation: Vec<DeliberationRound>,
#[serde(default)]
pub votes: Vec<VoteRecord>,
#[serde(default)]
pub tally: Option<Tally>,
#[serde(default)]
pub reviews: Vec<ReviewRound>,
#[serde(default)]
pub gate: Vec<CommandOutcome>,
#[serde(default)]
pub gate_ran: bool,
#[serde(default)]
pub merge: Option<MergeOutcome>,
#[serde(default)]
pub leaks: Vec<Leak>,
#[serde(default)]
pub quota: Vec<QuotaLoss>,
#[serde(default)]
pub parked: bool,
#[serde(default)]
pub seats: BTreeMap<String, SeatState>,
#[serde(default)]
pub active: BTreeMap<String, ActiveSeat>,
#[serde(default)]
pub driver_pid: Option<u32>,
#[serde(default)]
pub driver_started_at: Option<String>,
#[serde(default)]
pub pr: Option<PrRecord>,
#[serde(default)]
pub base_sync: Option<BaseSync>,
#[serde(default)]
pub advice: Option<crate::advise::Advice>,
#[serde(default)]
pub advise_attempted: bool,
#[serde(default)]
pub events: Vec<Event>,
#[serde(default)]
pub jobs: Vec<JobRecord>,
#[serde(default)]
pub operator_fixes: Vec<OperatorFixRequest>,
}
impl RunState {
pub fn new(
repo: PathBuf,
base_branch: String,
base_commit: String,
instruction: String,
config: Config,
) -> Self {
let now = Timestamp::now();
let seed = config.blind.seed.unwrap_or_else(crate::rng::entropy);
Self {
schema: SCHEMA,
id: new_id(),
repo,
base_branch,
base_commit,
instruction,
created_at: now,
updated_at: now,
status: RunStatus::Prep,
seed,
config,
enabled_worktree_config: false,
candidates: Vec::new(),
judgements: Vec::new(),
judge_skipped: false,
deliberation: Vec::new(),
votes: Vec::new(),
tally: None,
reviews: Vec::new(),
gate: Vec::new(),
gate_ran: false,
merge: None,
leaks: Vec::new(),
quota: Vec::new(),
parked: false,
seats: BTreeMap::new(),
active: BTreeMap::new(),
driver_pid: None,
driver_started_at: None,
pr: None,
base_sync: None,
advice: None,
advise_attempted: false,
events: Vec::new(),
jobs: Vec::new(),
operator_fixes: Vec::new(),
}
}
pub fn dir(&self) -> PathBuf {
run_dir(&self.id)
}
pub fn short(&self) -> &str {
short_of(&self.id)
}
pub fn branch_for(&self, label: char) -> String {
format!("magi/{}/{}", self.short(), label)
}
pub fn worktree_root(&self) -> PathBuf {
self.config
.graph
.worktree_root
.clone()
.unwrap_or_else(default_worktree_root)
.join(self.short())
}
pub fn event(&mut self, node: &str, message: impl Into<String>) {
let message = message.into();
tracing::info!(node, "{message}");
self.events.push(Event {
at: Timestamp::now(),
node: node.to_owned(),
message,
});
}
pub fn gate_status(&self) -> GateStatus {
if !self.gate_ran {
GateStatus::NotRun
} else if self.gate.is_empty() {
GateStatus::PassedWithNoCommands
} else if self.gate.iter().all(CommandOutcome::ok) {
GateStatus::Passed
} else {
GateStatus::Failed
}
}
pub fn seat_started(
&mut self,
node: &str,
seat: &str,
timeout: std::time::Duration,
attempt: usize,
) {
self.active.insert(
seat.to_owned(),
ActiveSeat {
node: node.to_owned(),
started_at: Timestamp::now(),
timeout_secs: timeout.as_secs(),
attempt,
task: None,
command: None,
index: None,
total: None,
},
);
}
pub fn seat_finished(&mut self, seat: &str) {
self.active.remove(seat);
}
#[allow(clippy::too_many_arguments)]
pub fn task_command(
&mut self,
task: &str,
node: &str,
attempt: usize,
command: &str,
index: usize,
total: usize,
timeout: std::time::Duration,
) {
self.active.insert(
task.to_owned(),
ActiveSeat {
node: node.to_owned(),
started_at: Timestamp::now(),
timeout_secs: timeout.as_secs(),
attempt,
task: Some(task.to_owned()),
command: Some(command.to_owned()),
index: Some(index),
total: Some(total),
},
);
}
pub fn task_finished(&mut self, task: &str) {
self.active.remove(task);
}
pub fn seats_active(&self) -> impl Iterator<Item = (&String, &ActiveSeat)> {
self.active.iter().filter(|(_, a)| a.task.is_none())
}
pub fn tasks_active(&self) -> impl Iterator<Item = (&String, &ActiveSeat)> {
self.active.iter().filter(|(_, a)| a.task.is_some())
}
pub fn clear_active(&mut self) -> bool {
if self.active.is_empty() {
return false;
}
self.active.clear();
true
}
#[must_use]
pub fn active_all_overrun(&self, now: Timestamp) -> bool {
!self.active.is_empty()
&& self
.active
.values()
.all(|a| a.elapsed_secs(now) > a.timeout_secs as i64)
}
#[must_use]
pub fn liveness_with<F, G>(&self, daemon_claims: bool, query: F, identity: G) -> Liveness
where
F: FnOnce(u32) -> Option<bool>,
G: FnOnce(u32) -> Option<String>,
{
if daemon_claims {
return Liveness::Live;
}
let Some(pid) = self.driver_pid else {
return Liveness::Unknown;
};
match query(pid) {
Some(false) => Liveness::Dead,
None => Liveness::Unknown,
Some(true) => match (&self.driver_started_at, identity(pid)) {
(Some(recorded), Some(current)) if *recorded == current => Liveness::Live,
(Some(_), Some(_)) => Liveness::Dead,
_ => Liveness::Unknown,
},
}
}
#[must_use]
pub fn liveness(&self, daemon_claims: bool) -> Liveness {
self.liveness_with(
daemon_claims,
crate::proc::pid_status,
crate::proc::process_started_at,
)
}
pub fn abandon(&mut self, by: &str) {
let seats: Vec<String> = self.active.keys().cloned().collect();
self.clear_active();
if !self.status.done() {
self.status = RunStatus::Failed;
}
self.event(
by,
format!(
"abandoned: seat(s) {} left behind by a killed process, past their own \
timeout with no live daemon claiming this run",
seats.join(", ")
),
);
}
pub fn save(&mut self) -> Result<()> {
let home = home();
self.save_under(&home)
}
pub fn save_under(&mut self, home: &Path) -> Result<()> {
self.updated_at = Timestamp::now();
let dir = home.join("runs").join(&self.id);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let body = serde_json::to_string_pretty(self).context("serialize run state")?;
let tmp = dir.join("run.json.tmp");
std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, dir.join("run.json")).with_context(|| "replace run.json")?;
Ok(())
}
pub fn load(id: &str) -> Result<Self> {
let resolved = resolve_id(id)?;
let path = run_dir(&resolved).join("run.json");
let body =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let state: Self =
serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
migrate_schema(state)
}
}
fn migrate_schema(mut state: RunState) -> Result<RunState> {
if state.schema == 5 {
state.schema = 6;
}
if state.schema == 6 {
state.gate_ran = !state.gate.is_empty();
state.schema = 7;
}
if state.schema == 7 {
for round in &mut state.reviews {
if round.verified_head.is_none()
&& matches!(round.e2e_status(), E2eStatus::Passed | E2eStatus::Failed)
{
round.verified_head = Some(round.head.clone());
}
}
state.schema = 8;
}
if state.schema == 8 {
state.schema = 9;
}
if state.schema == 9 {
state.schema = SCHEMA;
}
if state.schema != SCHEMA {
bail!(
"run {} was written by a different magi (schema {}, this build \
speaks {SCHEMA})",
state.id,
state.schema
);
}
Ok(state)
}
impl RunState {
pub fn winner(&self) -> Option<&Candidate> {
let label = self.tally.as_ref()?.winner;
self.candidates.iter().find(|c| c.label == label)
}
pub fn viable(&self) -> Vec<&Candidate> {
self.candidates.iter().filter(|c| c.viable()).collect()
}
pub fn all_candidates_verified_noop(&self) -> bool {
!self.candidates.is_empty()
&& self
.candidates
.iter()
.all(|c| c.empty && c.verified_noop.is_some())
}
pub fn open_findings(&self) -> Vec<&Finding> {
match self.reviews.last() {
Some(r) if !r.clean => r
.reviews
.iter()
.flat_map(|rec| rec.findings.iter())
.collect(),
_ => Vec::new(),
}
}
pub fn last_round_findings(&self) -> Vec<&Finding> {
self.reviews
.last()
.into_iter()
.flat_map(|r| r.reviews.iter())
.flat_map(|rec| rec.findings.iter())
.collect()
}
pub fn finding(&self, id: &str) -> Option<(&ReviewRound, &ReviewRecord, &Finding)> {
self.reviews.iter().find_map(|round| {
round.reviews.iter().find_map(|rec| {
rec.findings
.iter()
.find(|f| f.id == id)
.map(|f| (round, rec, f))
})
})
}
pub fn handed_off_with_open_findings(&self) -> bool {
matches!(self.status, RunStatus::Ready | RunStatus::Merged)
&& self.reviews.last().is_some_and(|r| !r.clean)
}
pub fn unmerged_by_design(&self) -> bool {
self.status == RunStatus::Ready
&& self
.merge
.as_ref()
.is_some_and(|m| m.mode == MergeMode::None)
}
pub fn created_local(&self) -> String {
self.created_at
.to_zoned(jiff::tz::TimeZone::system())
.strftime("%Y-%m-%d %H:%M:%S")
.to_string()
}
pub fn ensure_can_delete(&self, in_flight: bool) -> Result<()> {
if in_flight {
bail!(
"run {} is being worked on by a live daemon right now",
self.short()
);
}
if self.candidates.iter().any(|c| !c.folded) {
bail!(
"run {} has unfolded candidates; fold first with `magi fold`",
self.short()
);
}
Ok(())
}
}
fn short(commit: &str) -> String {
commit.chars().take(7).collect()
}
pub fn short_of(id: &str) -> &str {
id.split('-').next_back().unwrap_or(id)
}
pub fn home() -> PathBuf {
resolve_home(HOME.get().cloned(), std::env::var_os("MAGI_HOME"))
}
fn resolve_home(pinned: Option<PathBuf>, magi_home_env: Option<std::ffi::OsString>) -> PathBuf {
if let Some(dir) = pinned {
return dir;
}
if let Some(dir) = magi_home_env {
return PathBuf::from(dir);
}
#[cfg(test)]
{
panic!(
"run::home() was reached in a test without run::set_home() or \
MAGI_HOME; this would write into the operator's real \
<data_local>/magi. Call `run::set_home(temp_dir)` before any \
code path that touches a RunState."
);
}
#[cfg(not(test))]
{
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("magi")
}
}
pub fn set_home(dir: PathBuf) {
let _ = HOME.set(dir);
}
static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
pub fn runs_root() -> PathBuf {
home().join("runs")
}
pub fn default_worktree_root() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("wt")
.join("magi")
}
pub fn run_dir(id: &str) -> PathBuf {
runs_root().join(id)
}
pub fn list_ids() -> Vec<String> {
let mut ids: Vec<String> = std::fs::read_dir(runs_root())
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| is_run_id(name))
.collect();
ids.sort_unstable_by(|a, b| b.cmp(a));
ids
}
pub fn is_run_id(name: &str) -> bool {
let mut parts = name.split('-');
let (Some(day), Some(time), Some(tag), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return false;
};
day.len() == 8
&& day.bytes().all(|b| b.is_ascii_digit())
&& time.len() == 6
&& time.bytes().all(|b| b.is_ascii_digit())
&& tag.len() == 4
&& tag.bytes().all(|b| b.is_ascii_alphanumeric())
}
pub fn resolve_id(prefix: &str) -> Result<String> {
if is_run_id(prefix) && run_dir(prefix).is_dir() {
return Ok(prefix.to_owned());
}
let hits: Vec<String> = list_ids()
.into_iter()
.filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
.collect();
match hits.len() {
1 => Ok(hits.into_iter().next().expect("exactly one hit")),
0 => bail!("no run matches `{prefix}`"),
_ => bail!(
"`{prefix}` matches {} runs: {}",
hits.len(),
hits.join(", ")
),
}
}
pub fn latest_id() -> Option<String> {
list_ids().into_iter().next()
}
fn new_id() -> String {
let stamp = Zoned::now().strftime("%Y%m%d-%H%M%S");
let entropy = crate::rng::entropy();
format!("{stamp}-{:04x}", (entropy ^ (entropy >> 32)) & 0xffff)
}
pub fn tail(text: &str, max: usize) -> String {
if text.len() <= max {
return text.to_owned();
}
let mut cut = text.len() - max;
while cut < text.len() && !text.is_char_boundary(cut) {
cut += 1;
}
let slice = &text[cut..];
let start = slice.find('\n').map_or(0, |i| i + 1);
format!(
"[... {} earlier bytes omitted ...]\n{}",
cut,
&slice[start..]
)
}
pub fn artifact_path(run: &RunState, name: &str) -> PathBuf {
run.dir().join("artifacts").join(name)
}
pub fn write_artifact(run: &RunState, name: &str, body: &str) -> Result<PathBuf> {
let path = artifact_path(run, name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
std::fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
pub fn read_artifact(run: &RunState, name: &str) -> Option<String> {
std::fs::read_to_string(artifact_path(run, name)).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn state() -> RunState {
RunState::new(
PathBuf::from("/repo"),
"main".to_owned(),
"abc1234def".to_owned(),
"add retries".to_owned(),
Config::default(),
)
}
#[test]
fn resolve_home_prefers_the_pin_then_the_env_var() {
let pinned = PathBuf::from("/pinned");
assert_eq!(
resolve_home(Some(pinned.clone()), Some("/env".into())),
pinned,
"a pin wins even over MAGI_HOME"
);
assert_eq!(
resolve_home(None, Some("/env".into())),
PathBuf::from("/env")
);
}
#[test]
#[should_panic(expected = "run::set_home()")]
fn resolve_home_refuses_to_fall_back_to_the_operators_real_home() {
resolve_home(None, None);
}
#[test]
fn a_run_is_named_by_shape_so_a_state_less_directory_is_still_a_run() {
assert!(is_run_id(&new_id()));
assert!(is_run_id("20260904-014540-88c0"));
assert!(!is_run_id("scratch"));
assert!(!is_run_id("20260904-014540"));
assert!(!is_run_id("20260904-014540-88c0f"));
assert!(!is_run_id("2026090x-014540-88c0"));
assert!(!is_run_id("20260904-014540-88c0-A"));
}
#[test]
fn ids_are_sortable_and_short_suffixed() {
let s = state();
let parts: Vec<&str> = s.id.split('-').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0].len(), 8);
assert_eq!(parts[1].len(), 6);
assert_eq!(parts[2].len(), 4);
assert_eq!(s.short(), parts[2]);
}
#[test]
fn branch_names_carry_the_label_not_the_author() {
let s = state();
let b = s.branch_for('B');
assert_eq!(b, format!("magi/{}/B", s.short()));
assert!(!b.contains("claude"));
}
#[test]
fn a_pinned_seed_is_reproducible_but_never_the_run_id() {
let mut cfg = Config::default();
cfg.blind.seed = Some(1234);
let a = RunState::new(
PathBuf::from("/r"),
"main".to_owned(),
"c".to_owned(),
"t".to_owned(),
cfg.clone(),
);
let b = RunState::new(
PathBuf::from("/r"),
"main".to_owned(),
"c".to_owned(),
"t".to_owned(),
cfg,
);
assert_eq!(a.seed, 1234);
assert_eq!(a.seed, b.seed);
assert_ne!(
a.id, b.id,
"two runs sharing an id share a directory, artifacts and worktrees"
);
}
#[test]
fn status_terminality() {
assert!(RunStatus::Merged.done());
assert!(RunStatus::Blocked.done());
assert!(!RunStatus::Reviewing.done());
}
fn overrun_seat(now: Timestamp, elapsed_secs: i64, timeout_secs: u64) -> ActiveSeat {
ActiveSeat {
node: "implement".to_owned(),
started_at: now - jiff::SignedDuration::new(elapsed_secs, 0),
timeout_secs,
attempt: 0,
task: None,
command: None,
index: None,
total: None,
}
}
#[test]
fn active_all_overrun_requires_every_seat_past_its_own_timeout() {
let mut s = state();
let now = Timestamp::now();
assert!(
!s.active_all_overrun(now),
"nothing active is not evidence of anything"
);
s.active
.insert("impl-A".to_owned(), overrun_seat(now, 21_000, 3_600));
assert!(
s.active_all_overrun(now),
"21000s elapsed against a 3600s budget"
);
s.active
.insert("impl-B".to_owned(), overrun_seat(now, 0, 3_600));
assert!(!s.active_all_overrun(now));
}
#[test]
fn liveness_reads_live_from_a_daemon_claim_alone() {
let mut s = state();
s.driver_pid = None;
assert_eq!(
s.liveness_with(
true,
|_| panic!("a daemon claim needs no pid query"),
|_| panic!("a daemon claim needs no identity query")
),
Liveness::Live,
"a daemon claim needs no pid to back it up"
);
}
#[test]
fn liveness_reads_live_from_a_confirmed_pid_with_a_matching_identity() {
let mut s = state();
s.driver_pid = Some(4242);
s.driver_started_at = Some("2026-09-22T10:00:00Z".to_owned());
assert_eq!(
s.liveness_with(
false,
|pid| {
assert_eq!(pid, 4242);
Some(true)
},
|pid| {
assert_eq!(pid, 4242);
Some("2026-09-22T10:00:00Z".to_owned())
}
),
Liveness::Live
);
}
#[test]
fn liveness_reads_dead_from_a_confirmed_dead_pid() {
let mut s = state();
s.driver_pid = Some(4242);
assert_eq!(
s.liveness_with(
false,
|_| Some(false),
|_| panic!("a confirmed-dead pid needs no identity query")
),
Liveness::Dead
);
}
#[test]
fn liveness_reads_dead_when_a_live_pid_no_longer_matches_the_recorded_start_time() {
let mut s = state();
s.driver_pid = Some(4242);
s.driver_started_at = Some("2026-09-22T10:00:00Z".to_owned());
assert_eq!(
s.liveness_with(
false,
|_| Some(true),
|_| Some("2026-09-22T11:30:00Z".to_owned())
),
Liveness::Dead,
"the pid is alive, but under a different process than the one this run recorded"
);
}
#[test]
fn liveness_never_guesses_out_of_missing_information() {
let mut s = state();
s.driver_pid = None;
assert_eq!(
s.liveness_with(
false,
|_| panic!("no pid to query"),
|_| panic!("no pid to query")
),
Liveness::Unknown,
"no driver_pid recorded at all — an old run predating this field"
);
s.driver_pid = Some(4242);
assert_eq!(
s.liveness_with(false, |_| None, |_| panic!("inconclusive already")),
Liveness::Unknown,
"a pid to ask, but the platform could not answer for it"
);
s.driver_started_at = None;
assert_eq!(
s.liveness_with(false, |_| Some(true), |_| Some("anything".to_owned())),
Liveness::Unknown,
"a live pid, but no recorded marker to corroborate it against — an old run \
predating `driver_started_at`"
);
s.driver_started_at = Some("2026-09-22T10:00:00Z".to_owned());
assert_eq!(
s.liveness_with(false, |_| Some(true), |_| None),
Liveness::Unknown,
"a live pid and a recorded marker, but the identity re-query itself failed"
);
}
#[test]
fn abandon_clears_active_and_fails_a_non_terminal_run() {
let mut s = state();
s.status = RunStatus::Implementing;
let now = Timestamp::now();
s.active
.insert("impl-A".to_owned(), overrun_seat(now, 21_000, 3_600));
s.abandon("daemon");
assert!(s.active.is_empty());
assert_eq!(s.status, RunStatus::Failed);
assert!(
s.events
.last()
.expect("an event was logged")
.message
.contains("impl-A"),
"the event names the abandoned seat"
);
}
#[test]
fn abandon_never_overwrites_a_status_already_terminal() {
let mut s = state();
s.status = RunStatus::Ready;
let now = Timestamp::now();
s.active
.insert("impl-A".to_owned(), overrun_seat(now, 21_000, 3_600));
s.abandon("daemon");
assert!(s.active.is_empty());
assert_eq!(
s.status,
RunStatus::Ready,
"a run already done must not be relabelled Failed"
);
}
#[test]
fn candidate_viability_excludes_empty_and_failed() {
let mut c = Candidate {
index: 0,
label: 'A',
agent: "a".to_owned(),
branch: "b".to_owned(),
worktree: PathBuf::from("/w"),
summary: String::new(),
stat: String::new(),
files: 1,
commits: 1,
empty: false,
failed: None,
verified_noop: None,
duration_ms: 0,
folded: false,
};
assert!(c.viable());
c.empty = true;
assert!(!c.viable());
c.empty = false;
c.failed = Some("timeout".to_owned());
assert!(!c.viable());
}
#[test]
fn build_failure_is_distinguished_from_a_failing_test() {
let link_race = CommandOutcome {
command: "cargo test".to_owned(),
code: Some(1),
output_tail: "LINK : fatal error LNK1104: cannot open file \
'graph_dirty_tree-71d4dc8e.exe'\n\
error: could not compile `magi-cli` (test \"graph_dirty_tree\")"
.to_owned(),
duration_ms: 500,
resource_blocked: false,
};
assert!(!link_race.ok());
assert!(link_race.build_failed());
let failing_test = CommandOutcome {
command: "cargo test".to_owned(),
code: Some(101),
output_tail: "thread 'it_works' panicked at 'assertion failed'".to_owned(),
duration_ms: 500,
resource_blocked: false,
};
assert!(!failing_test.ok());
assert!(
!failing_test.build_failed(),
"a real test failure must not be classed as a build failure"
);
let passing = CommandOutcome {
command: "cargo test".to_owned(),
code: Some(0),
output_tail: String::new(),
duration_ms: 500,
resource_blocked: false,
};
assert!(passing.ok());
assert!(!passing.build_failed());
}
#[test]
fn tail_keeps_the_end_on_a_line_boundary() {
let text = (0..100).map(|i| format!("line {i}\n")).collect::<String>();
let t = tail(&text, 40);
assert!(t.starts_with("[..."));
assert!(t.ends_with("line 99\n"));
assert!(t.len() < 120);
assert_eq!(tail("short", 40), "short");
}
#[test]
fn tail_survives_multibyte_cuts() {
let text = "あ".repeat(50);
let t = tail(&text, 10);
assert!(t.contains("earlier bytes omitted"));
assert!(t.ends_with('あ'));
}
fn finding(id: &str, severity: crate::verdict::Severity) -> crate::verdict::Finding {
crate::verdict::Finding {
id: id.to_owned(),
severity,
file: None,
line: None,
title: "x".to_owned(),
detail: String::new(),
}
}
fn round(clean: bool, findings: Vec<crate::verdict::Finding>) -> ReviewRound {
ReviewRound {
round: 1,
head: "h".to_owned(),
verified_head: None,
verified_at: None,
reviews: vec![ReviewRecord {
attempts: 0,
reviewer: 1,
agent: "a".to_owned(),
summary: String::new(),
findings,
vote: None,
failed: None,
duration_ms: 0,
}],
e2e: Vec::new(),
verify_retried: false,
e2e_deferred: false,
e2e_defer_reason: None,
fix: None,
blocking: 0,
answered: 1,
expected: 1,
clean,
progressed: false,
vote_split: false,
reconsideration: Vec::new(),
verdict: None,
}
}
#[test]
fn e2e_status_tells_deferred_apart_from_not_configured() {
let mut r = round(false, Vec::new());
assert_eq!(r.e2e_status(), E2eStatus::NotConfigured);
r.e2e_deferred = true;
assert_eq!(
r.e2e_status(),
E2eStatus::Deferred,
"an empty e2e must not read as unconfigured once it was deferred on purpose"
);
r.e2e = vec![CommandOutcome {
command: "test".to_owned(),
code: Some(0),
output_tail: String::new(),
duration_ms: 0,
resource_blocked: false,
}];
assert_eq!(
r.e2e_status(),
E2eStatus::Passed,
"a round with real outcomes is never read as deferred, even if the flag is still set"
);
}
#[test]
fn e2e_status_reports_a_real_failure_as_failed_not_deferred() {
let mut r = round(false, Vec::new());
r.e2e = vec![CommandOutcome {
command: "test".to_owned(),
code: Some(1),
output_tail: "boom".to_owned(),
duration_ms: 0,
resource_blocked: false,
}];
assert_eq!(r.e2e_status(), E2eStatus::Failed);
}
#[test]
fn e2e_status_never_reads_a_resource_block_as_a_failure() {
let mut r = round(false, Vec::new());
r.e2e = vec![CommandOutcome {
command: "(waiting for the shared build cache)".to_owned(),
code: None,
output_tail: "contended".to_owned(),
duration_ms: 0,
resource_blocked: true,
}];
assert_eq!(
r.e2e_status(),
E2eStatus::ResourceBlocked,
"magi's own inability to get a command to run must not read as a verdict on the \
patch"
);
}
#[test]
fn verification_summary_is_silent_when_there_is_nothing_worth_saying() {
let mut r = round(true, Vec::new());
assert!(
r.verification_summary("h").is_none(),
"no verify.e2e configured: nothing to surface"
);
r.e2e = vec![CommandOutcome {
command: "test".to_owned(),
code: Some(0),
output_tail: String::new(),
duration_ms: 0,
resource_blocked: false,
}];
assert!(
r.verification_summary("h").is_none(),
"a green result needs no skepticism attached to it"
);
}
#[test]
fn verification_summary_tells_the_current_head_apart_from_an_earlier_one() {
let mut r = round(false, Vec::new());
r.head = "h1".to_owned();
r.e2e = vec![CommandOutcome {
command: "test".to_owned(),
code: Some(1),
output_tail: "boom".to_owned(),
duration_ms: 0,
resource_blocked: false,
}];
r.verified_head = Some("h1".to_owned());
r.verified_at = Some(Timestamp::now());
let fresh = r.verification_summary("h1").expect("a failure is surfaced");
assert!(
fresh.label.contains("this is the head being looked at now"),
"{}",
fresh.label
);
assert_eq!(fresh.tail.as_deref(), Some("$ test\nboom\n"));
let stale = r.verification_summary("h2").expect("still surfaced");
assert!(
stale.label.contains("an earlier head, since superseded"),
"a result about a different commit than the one being looked at now must say so, \
not read as current: {}",
stale.label
);
}
#[test]
fn verification_summary_marks_a_resource_block_and_a_deferral_distinctly_from_a_failure() {
let mut r = round(false, Vec::new());
r.e2e = vec![CommandOutcome {
command: "(waiting for the shared build cache)".to_owned(),
code: None,
output_tail: "contended".to_owned(),
duration_ms: 0,
resource_blocked: true,
}];
let blocked = r
.verification_summary("h")
.expect("a resource block is still surfaced, never silent");
assert!(blocked.label.contains("could not run"));
let tail = blocked
.tail
.expect("the attempted operation is still named");
assert!(tail.contains("(waiting for the shared build cache)"));
assert!(tail.contains("contended"));
let mut d = round(false, Vec::new());
d.e2e_deferred = true;
d.e2e_defer_reason = Some("2 blocking finding(s) already required a fix".to_owned());
let deferred = d.verification_summary("h").expect("deferred is surfaced");
assert!(deferred.label.contains("deferred to the fixer"));
assert!(deferred.label.contains("2 blocking finding(s)"));
assert!(deferred.tail.is_none());
}
#[test]
fn verification_summary_says_unknown_rather_than_guessing_a_time_or_a_commit() {
let mut r = round(false, Vec::new());
r.e2e = vec![CommandOutcome {
command: "test".to_owned(),
code: Some(1),
output_tail: "boom".to_owned(),
duration_ms: 0,
resource_blocked: false,
}];
let summary = r.verification_summary("h").expect("a failure is surfaced");
assert!(summary.label.contains("commit unknown"));
assert!(summary.label.contains("checked at: unknown"));
}
#[test]
fn gate_status_tells_not_run_apart_from_passed_with_no_commands() {
let mut s = state();
assert_eq!(s.gate_status(), GateStatus::NotRun);
s.gate_ran = true;
assert_eq!(
s.gate_status(),
GateStatus::PassedWithNoCommands,
"an empty gate must read as a real pass once gate_ran says it actually ran"
);
s.gate = vec![CommandOutcome {
command: "cargo make check".to_owned(),
code: Some(0),
output_tail: String::new(),
duration_ms: 0,
resource_blocked: false,
}];
assert_eq!(s.gate_status(), GateStatus::Passed);
s.gate[0].code = Some(1);
assert_eq!(s.gate_status(), GateStatus::Failed);
s.gate_ran = false;
assert_eq!(
s.gate_status(),
GateStatus::NotRun,
"gate_ran false must win even over a non-empty gate left from a stale record"
);
}
#[test]
fn open_findings_is_empty_when_the_last_round_was_clean() {
let mut s = state();
s.reviews = vec![round(
true,
vec![finding("R1-1-1", crate::verdict::Severity::Minor)],
)];
assert!(s.open_findings().is_empty());
}
#[test]
fn open_findings_reads_the_last_non_clean_round() {
let mut s = state();
s.reviews = vec![round(
false,
vec![finding("R1-1-1", crate::verdict::Severity::Major)],
)];
let open = s.open_findings();
assert_eq!(open.len(), 1);
assert_eq!(open[0].id, "R1-1-1");
}
#[test]
fn handed_off_with_open_findings_needs_a_mergeable_status_and_an_open_round() {
let mut s = state();
s.reviews = vec![round(
false,
vec![finding("R1-1-1", crate::verdict::Severity::Major)],
)];
s.status = RunStatus::Blocked;
assert!(
!s.handed_off_with_open_findings(),
"a blocked run is not a hand-off"
);
s.status = RunStatus::Ready;
assert!(s.handed_off_with_open_findings());
s.reviews = vec![round(true, Vec::new())];
assert!(
!s.handed_off_with_open_findings(),
"a clean last round has nothing to hand off"
);
}
#[test]
fn unmerged_by_design_is_only_ready_reached_via_merge_mode_none() {
let mut s = state();
s.status = RunStatus::Ready;
assert!(
!s.unmerged_by_design(),
"no merge outcome recorded at all must not be flagged"
);
s.merge = Some(MergeOutcome {
mode: MergeMode::None,
ok: true,
detail: "git merge --no-ff magi/x/A".to_owned(),
});
assert!(
s.unmerged_by_design(),
"Ready reached through mode none is the case this exists to flag"
);
s.merge = Some(MergeOutcome {
mode: MergeMode::Pr,
ok: false,
detail: "https://example.com/pr/1 was closed without merging".to_owned(),
});
assert!(
!s.unmerged_by_design(),
"a closed pull request is a different Ready and must not be relabelled"
);
s.status = RunStatus::Gating;
s.merge = Some(MergeOutcome {
mode: MergeMode::None,
ok: true,
detail: "git merge --no-ff magi/x/A".to_owned(),
});
assert!(
!s.unmerged_by_design(),
"status must actually be Ready, not merely have a stale mode-none merge record"
);
}
#[test]
fn state_round_trips_through_json() {
let s = state();
let body = serde_json::to_string(&s).unwrap();
let back: RunState = serde_json::from_str(&body).unwrap();
assert_eq!(back.id, s.id);
assert_eq!(back.instruction, "add retries");
assert_eq!(back.status, RunStatus::Prep);
}
#[test]
fn a_round_recorded_before_e2e_deferral_existed_still_loads() {
let body = r#"{
"round": 1,
"head": "deadbeef",
"reviews": [],
"e2e": [],
"verify_retried": false,
"fix": null,
"blocking": 0,
"answered": 1,
"expected": 1,
"clean": true
}"#;
let r: ReviewRound = serde_json::from_str(body).expect("an old-shaped round must load");
assert!(!r.e2e_deferred);
assert!(r.e2e_defer_reason.is_none());
assert_eq!(r.e2e_status(), E2eStatus::NotConfigured);
}
#[test]
fn schema_five_state_migrates_old_empty_e2e_and_legacy_verify_budget() {
let mut value = serde_json::to_value(state()).expect("serialize state");
let object = value.as_object_mut().expect("state object");
object.insert("schema".to_owned(), serde_json::json!(5));
let graph = object["config"]["graph"]
.as_object_mut()
.expect("graph object");
graph.insert("timeout_review".to_owned(), serde_json::json!(3600));
graph.remove("timeout_verify");
let review = object["reviews"].as_array_mut().expect("reviews");
review.push(serde_json::json!({
"round": 1, "head": "old", "reviews": [], "e2e": [],
"verify_retried": false, "blocking": 0, "answered": 1,
"expected": 1, "clean": true
}));
let old: RunState = serde_json::from_value(value).expect("schema-5 shape parses");
let migrated = migrate_schema(old).expect("schema 5 migrates");
assert_eq!(migrated.schema, SCHEMA);
assert_eq!(migrated.config.graph.verify_timeout(), 3600);
assert_eq!(migrated.reviews[0].e2e_status(), E2eStatus::NotConfigured);
}
#[test]
fn schema_six_state_with_a_recorded_gate_migrates_to_gate_ran_true() {
let mut value = serde_json::to_value(state()).expect("serialize state");
let object = value.as_object_mut().expect("state object");
object.insert("schema".to_owned(), serde_json::json!(6));
object.insert(
"gate".to_owned(),
serde_json::json!([{
"command": "cargo make check",
"code": 0,
"output_tail": "",
"duration_ms": 0,
"resource_blocked": false
}]),
);
let old: RunState = serde_json::from_value(value).expect("schema-6 shape parses");
let migrated = migrate_schema(old).expect("schema 6 migrates");
assert_eq!(migrated.schema, SCHEMA);
assert!(
migrated.gate_ran,
"a non-empty recorded gate is a real attempt, not an unrun one"
);
assert_eq!(migrated.gate_status(), GateStatus::Passed);
}
#[test]
fn schema_six_state_with_an_empty_gate_migrates_to_gate_ran_false_and_is_retried() {
let mut value = serde_json::to_value(state()).expect("serialize state");
let object = value.as_object_mut().expect("state object");
object.insert("schema".to_owned(), serde_json::json!(6));
object.insert("gate".to_owned(), serde_json::json!([]));
let old: RunState = serde_json::from_value(value).expect("schema-6 shape parses");
let migrated = migrate_schema(old).expect("schema 6 migrates");
assert_eq!(migrated.schema, SCHEMA);
assert!(
!migrated.gate_ran,
"an empty gate on schema 6 is ambiguous and must be treated as unrun"
);
assert_eq!(migrated.gate_status(), GateStatus::NotRun);
}
#[test]
fn schema_seven_state_reconstructs_verified_head_for_a_round_that_actually_ran_e2e() {
let mut value = serde_json::to_value(state()).expect("serialize state");
let object = value.as_object_mut().expect("state object");
object.insert("schema".to_owned(), serde_json::json!(7));
let reviews = object["reviews"].as_array_mut().expect("reviews");
reviews.push(serde_json::json!({
"round": 1, "head": "deadbeef", "reviews": [],
"e2e": [{
"command": "cargo test", "code": 0, "output_tail": "",
"duration_ms": 0, "resource_blocked": false
}],
"verify_retried": false, "blocking": 0, "answered": 1,
"expected": 1, "clean": true
}));
let old: RunState = serde_json::from_value(value).expect("schema-7 shape parses");
let migrated = migrate_schema(old).expect("schema 7 migrates");
assert_eq!(migrated.schema, SCHEMA);
assert_eq!(
migrated.reviews[0].verified_head.as_deref(),
Some("deadbeef"),
"a schema-7 round's main-loop e2e was always against its own head, even though the \
field never said so"
);
assert!(
migrated.reviews[0].verified_at.is_none(),
"no historical timestamp exists to reconstruct; unknown stays unknown, not a \
guessed 'now'"
);
}
#[test]
fn schema_seven_state_leaves_a_deferred_round_with_no_verified_head() {
let mut value = serde_json::to_value(state()).expect("serialize state");
let object = value.as_object_mut().expect("state object");
object.insert("schema".to_owned(), serde_json::json!(7));
let reviews = object["reviews"].as_array_mut().expect("reviews");
reviews.push(serde_json::json!({
"round": 1, "head": "deadbeef", "reviews": [],
"e2e": [], "e2e_deferred": true,
"verify_retried": false, "blocking": 1, "answered": 1,
"expected": 1, "clean": false
}));
let old: RunState = serde_json::from_value(value).expect("schema-7 shape parses");
let migrated = migrate_schema(old).expect("schema 7 migrates");
assert!(
migrated.reviews[0].verified_head.is_none(),
"a deferred round never ran e2e; there is nothing to reconstruct"
);
}
#[test]
fn schema_six_serialization_is_rejected_by_a_schema_five_reader() {
let body = serde_json::to_value(state()).expect("serialize state");
assert_eq!(body["schema"], serde_json::json!(SCHEMA));
assert_ne!(body["schema"], serde_json::json!(5));
}
#[test]
fn seat_started_and_finished_track_who_has_not_answered_yet() {
let mut s = state();
s.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
s.seat_started("judge", "judge-2", std::time::Duration::from_secs(60), 0);
assert_eq!(s.active.len(), 2, "both seats are still out");
s.seat_finished("judge-1");
assert_eq!(
s.active.keys().collect::<Vec<_>>(),
vec!["judge-2"],
"only the seat that answered drops out; judge-2 is still waited on"
);
}
#[test]
fn seats_active_and_tasks_active_never_cross_over() {
let mut s = state();
s.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
s.task_command(
"e2e",
"verify",
0,
"cargo test",
1,
2,
std::time::Duration::from_secs(600),
);
assert_eq!(
s.seats_active()
.map(|(k, _)| k.as_str())
.collect::<Vec<_>>(),
vec!["judge-1"]
);
assert_eq!(
s.tasks_active()
.map(|(k, _)| k.as_str())
.collect::<Vec<_>>(),
vec!["e2e"]
);
s.task_command(
"e2e",
"verify",
0,
"cargo clippy",
2,
2,
std::time::Duration::from_secs(600),
);
assert_eq!(s.tasks_active().count(), 1);
assert_eq!(s.active["e2e"].command.as_deref(), Some("cargo clippy"));
s.task_finished("e2e");
assert!(s.tasks_active().next().is_none());
assert_eq!(
s.seats_active()
.map(|(k, _)| k.as_str())
.collect::<Vec<_>>(),
vec!["judge-1"],
"clearing the task must not touch the seat entry"
);
}
#[test]
fn a_retry_is_recorded_as_a_later_attempt_on_the_same_seat() {
let mut s = state();
s.seat_started("review", "review-2", std::time::Duration::from_secs(30), 0);
s.seat_finished("review-2");
s.seat_started("review", "review-2", std::time::Duration::from_secs(30), 1);
assert_eq!(s.active["review-2"].attempt, 1);
}
#[test]
fn active_seat_reports_elapsed_and_remaining_time() {
let now = Timestamp::now();
let started = now - jiff::SignedDuration::from_secs(30);
let seat = ActiveSeat {
node: "judge".to_owned(),
started_at: started,
timeout_secs: 100,
attempt: 0,
task: None,
command: None,
index: None,
total: None,
};
assert_eq!(seat.elapsed_secs(now), 30);
assert_eq!(seat.remaining_secs(now), 70);
}
#[test]
fn remaining_time_never_goes_negative_past_the_timeout() {
let now = Timestamp::now();
let started = now - jiff::SignedDuration::from_secs(200);
let seat = ActiveSeat {
node: "implement".to_owned(),
started_at: started,
timeout_secs: 100,
attempt: 1,
task: None,
command: None,
index: None,
total: None,
};
assert_eq!(seat.remaining_secs(now), 0);
}
#[test]
fn clear_active_drops_stale_seats_and_reports_whether_it_did() {
let mut s = state();
assert!(!s.clear_active(), "nothing to clear on a fresh run");
s.seat_started(
"implement",
"impl-B",
std::time::Duration::from_secs(3600),
0,
);
assert!(s.clear_active(), "a leftover entry is reported as cleared");
assert!(s.active.is_empty());
}
#[test]
fn active_seat_carries_nothing_that_could_be_read_as_output_bytes() {
let seat = ActiveSeat {
node: "implement".to_owned(),
started_at: Timestamp::now(),
timeout_secs: 60,
attempt: 0,
task: None,
command: None,
index: None,
total: None,
};
let value = serde_json::to_value(&seat).unwrap();
let keys: std::collections::BTreeSet<String> =
value.as_object().unwrap().keys().cloned().collect();
assert_eq!(
keys,
std::collections::BTreeSet::from([
"node".to_owned(),
"started_at".to_owned(),
"timeout_secs".to_owned(),
"attempt".to_owned(),
]),
"a byte count here would be a lever to declare a silent-but-healthy seat dead, and \
the task-only fields must stay absent (not null) on an ordinary seat entry"
);
}
#[test]
fn task_active_seat_carries_nothing_that_could_be_read_as_output_bytes() {
let seat = ActiveSeat {
node: "verify".to_owned(),
started_at: Timestamp::now(),
timeout_secs: 600,
attempt: 0,
task: Some("e2e".to_owned()),
command: Some("cargo test".to_owned()),
index: Some(1),
total: Some(3),
};
let value = serde_json::to_value(&seat).unwrap();
let keys: std::collections::BTreeSet<String> =
value.as_object().unwrap().keys().cloned().collect();
assert_eq!(
keys,
std::collections::BTreeSet::from([
"node".to_owned(),
"started_at".to_owned(),
"timeout_secs".to_owned(),
"attempt".to_owned(),
"task".to_owned(),
"command".to_owned(),
"index".to_owned(),
"total".to_owned(),
]),
);
}
#[test]
fn an_old_run_json_without_active_seats_still_loads() {
let s = state();
let mut value = serde_json::to_value(&s).unwrap();
value.as_object_mut().unwrap().remove("active");
let back: RunState = serde_json::from_value(value).unwrap();
assert!(back.active.is_empty());
assert_eq!(back.schema, SCHEMA);
}
#[test]
fn an_old_run_json_without_jobs_still_loads() {
let s = state();
let mut value = serde_json::to_value(&s).unwrap();
value.as_object_mut().unwrap().remove("jobs");
let back: RunState = serde_json::from_value(value).unwrap();
assert!(back.jobs.is_empty());
assert_eq!(back.schema, SCHEMA);
}
#[test]
fn ensure_can_delete_guards_live_and_unfolded_runs() {
let mut s = state();
s.status = RunStatus::Prep;
let err = s.ensure_can_delete(true).unwrap_err().to_string();
assert!(err.contains("live daemon"), "{err}");
assert!(s.ensure_can_delete(false).is_ok());
s.status = RunStatus::Merged;
s.candidates.push(Candidate {
index: 0,
label: 'A',
agent: "a".to_owned(),
branch: "b".to_owned(),
worktree: PathBuf::from("/w"),
summary: String::new(),
stat: String::new(),
files: 1,
commits: 1,
empty: false,
failed: None,
verified_noop: None,
duration_ms: 0,
folded: false,
});
let err = s.ensure_can_delete(false).unwrap_err().to_string();
assert!(
err.contains("magi fold"),
"error must suggest `magi fold`: {err}"
);
s.candidates[0].folded = true;
assert!(s.ensure_can_delete(false).is_ok());
}
}