use std::collections::BTreeMap;
use std::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};
pub const SCHEMA: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Prep,
Implementing,
Judging,
Deliberating,
Voting,
Reviewing,
Gating,
Merged,
Ready,
Stalled,
Blocked,
Failed,
}
impl RunStatus {
pub fn done(self) -> bool {
matches!(
self,
Self::Merged | Self::Ready | Self::Stalled | Self::Blocked | Self::Failed
)
}
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::Merged => "merged",
Self::Ready => "ready",
Self::Stalled => "stalled",
Self::Blocked => "blocked",
Self::Failed => "failed",
}
}
pub fn resumable(self) -> bool {
!matches!(self, Self::Merged | Self::Ready | Self::Failed)
}
}
#[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 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,
}
#[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 failed: Option<String>,
#[serde(default)]
pub duration_ms: u64,
}
#[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,
}
#[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,
}
impl CommandOutcome {
pub fn ok(&self) -> bool {
self.code == Some(0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewRound {
pub round: usize,
pub head: String,
pub reviews: Vec<ReviewRecord>,
#[serde(default)]
pub e2e: Vec<CommandOutcome>,
#[serde(default)]
pub fix: Option<FixRecord>,
#[serde(default)]
pub blocking: usize,
#[serde(default)]
pub clean: bool,
}
#[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 Event {
pub at: Timestamp,
pub node: String,
pub message: 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 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 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 pr: Option<PrRecord>,
#[serde(default)]
pub events: Vec<Event>,
}
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(seed),
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(),
deliberation: Vec::new(),
votes: Vec::new(),
tally: None,
reviews: Vec::new(),
gate: Vec::new(),
merge: None,
leaks: Vec::new(),
quota: Vec::new(),
parked: false,
seats: BTreeMap::new(),
pr: None,
events: 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(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("wt")
.join("magi")
})
.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 save(&mut self) -> Result<()> {
self.updated_at = Timestamp::now();
let dir = self.dir();
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()))?;
if state.schema != SCHEMA {
bail!(
"run {} was written by a different magi (schema {}, this build \
speaks {SCHEMA})",
state.id,
state.schema
);
}
Ok(state)
}
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 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(())
}
}
pub fn short_of(id: &str) -> &str {
id.split('-').next_back().unwrap_or(id)
}
pub fn home() -> PathBuf {
if let Some(dir) = HOME.get() {
return dir.clone();
}
if let Some(dir) = std::env::var_os("MAGI_HOME") {
return PathBuf::from(dir);
}
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 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().join("run.json").is_file())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
ids.sort_unstable_by(|a, b| b.cmp(a));
ids
}
pub fn resolve_id(prefix: &str) -> Result<String> {
if run_dir(prefix).join("run.json").is_file() {
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(seed: u64) -> String {
let stamp = Zoned::now().strftime("%Y%m%d-%H%M%S");
format!("{stamp}-{:04x}", (seed ^ (seed >> 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 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 seed_from_config_makes_the_run_reproducible() {
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_eq!(a.short(), b.short());
}
#[test]
fn status_terminality() {
assert!(RunStatus::Merged.done());
assert!(RunStatus::Blocked.done());
assert!(!RunStatus::Reviewing.done());
}
#[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,
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 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('あ'));
}
#[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 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,
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());
}
}