Skip to main content

magi/
run.rs

1//! Run state: what happened, where it is stored, and how a run is resumed.
2//!
3//! Every node writes its result into [`RunState`] and the whole struct is
4//! flushed to `run.json` before the next node starts. That is what makes a run
5//! resumable: a competition can take an hour, and dying in review round four
6//! should not throw away three implementations, nine judge reads and a
7//! deliberation.
8//!
9//! Patches and raw agent transcripts are *not* in `run.json` — they live beside
10//! it under `artifacts/`, so the state file stays small enough to read by hand.
11use std::collections::BTreeMap;
12use std::path::PathBuf;
13
14use anyhow::{Context as _, Result, bail};
15use jiff::{Timestamp, Zoned};
16use serde::{Deserialize, Serialize};
17
18use crate::agent::SeatState;
19use crate::blind::Leak;
20use crate::config::{Config, MergeMode};
21use crate::verdict::{Finding, Rejection};
22
23/// On-disk format version. Bumped when a field changes meaning, so a resumed
24/// run never half-reads a state file written by a different magi.
25///
26/// 2: added `RunStatus::Stalled`, `RunState::quota` (rate-limit losses), and
27/// the quorum fields on `Tally`. `RunState::load` already fails loudly and
28/// clearly on a schema mismatch; an old `run.json` from schema 1 now says so
29/// instead of silently half-reading.
30pub const SCHEMA: u32 = 2;
31
32/// Where a run got to.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum RunStatus {
36    /// Worktrees being prepared.
37    Prep,
38    /// Candidates being implemented.
39    Implementing,
40    /// Judges ranking blind.
41    Judging,
42    /// Judges deliberating after a split.
43    Deliberating,
44    /// Final votes being collected privately.
45    Voting,
46    /// Winner in the review + verification loop.
47    Reviewing,
48    /// Gate commands running.
49    Gating,
50    /// Winner merged.
51    Merged,
52    /// Winner passed the gate; merge was not requested.
53    Ready,
54    /// The judgement did not gather enough judges (e.g. rate limiting took out
55    /// seats), so the verdict is not trustworthy. The run stopped and kept its
56    /// work so it can be resumed or folded — it must never be confused with a
57    /// healthy `Ready`.
58    Stalled,
59    /// Review rounds exhausted with findings still open, or the gate failed.
60    Blocked,
61    /// The graph could not complete.
62    Failed,
63}
64
65impl RunStatus {
66    /// Is this a terminal state?
67    pub fn done(self) -> bool {
68        matches!(
69            self,
70            Self::Merged | Self::Ready | Self::Stalled | Self::Blocked | Self::Failed
71        )
72    }
73
74    /// The name this status is written and shown under, matching the
75    /// `snake_case` serde spelling so a log line, an error message and the
76    /// JSON a phone reads all say the same word.
77    pub fn as_str(self) -> &'static str {
78        match self {
79            Self::Prep => "prep",
80            Self::Implementing => "implementing",
81            Self::Judging => "judging",
82            Self::Deliberating => "deliberating",
83            Self::Voting => "voting",
84            Self::Reviewing => "reviewing",
85            Self::Gating => "gating",
86            Self::Merged => "merged",
87            Self::Ready => "ready",
88            Self::Stalled => "stalled",
89            Self::Blocked => "blocked",
90            Self::Failed => "failed",
91        }
92    }
93
94    /// Can this run be carried on from where it stopped?
95    ///
96    /// Everything except a finished run and a failed one. `execute` skips
97    /// nodes already recorded, so re-entering is cheap wherever the run
98    /// stopped, and the alternative is always a fresh competition against
99    /// work that already exists.
100    ///
101    /// - `Stalled` re-asks only the seats whose absence collapsed the panel,
102    ///   keeping the candidates that were already paid for.
103    /// - `Blocked` re-enters the review loop against a branch that is built.
104    /// - **A non-terminal status** means the run was interrupted: a parked
105    ///   run waiting for its upgrade, or one whose daemon was killed. This
106    ///   used to be excluded, which left run 4043 stuck at `reviewing` with
107    ///   the deck telling the operator it could not be resumed - the one
108    ///   state where resuming is the only sensible answer.
109    ///
110    /// `Failed` does not qualify: the graph could not complete and there is
111    /// no established point to continue from. Nor does a finished run, whose
112    /// answer is a new competition.
113    ///
114    /// Whether anything is *already* driving the run is a separate question,
115    /// answered by `daemon::is_working_on` at the callers that need it.
116    pub fn resumable(self) -> bool {
117        !matches!(self, Self::Merged | Self::Ready | Self::Failed)
118    }
119}
120
121/// One candidate implementation.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct Candidate {
124    /// Position in the implementer list.
125    pub index: usize,
126    /// Blind label as presented to judges.
127    pub label: char,
128    /// Which agent wrote it. Recorded for the stats tables, never shown to a
129    /// judge.
130    pub agent: String,
131    /// Branch, named after the label so judges can inspect it without learning
132    /// the author.
133    pub branch: String,
134    /// Worktree path.
135    pub worktree: PathBuf,
136    /// Sanitized author summary.
137    #[serde(default)]
138    pub summary: String,
139    /// `git diff --stat`.
140    #[serde(default)]
141    pub stat: String,
142    /// Files touched.
143    #[serde(default)]
144    pub files: usize,
145    /// Commits ahead of base.
146    #[serde(default)]
147    pub commits: usize,
148    /// True when the agent produced no change at all.
149    #[serde(default)]
150    pub empty: bool,
151    /// Why this candidate is not in the running.
152    #[serde(default)]
153    pub failed: Option<String>,
154    /// Wall-clock time for the implementation.
155    #[serde(default)]
156    pub duration_ms: u64,
157    /// Whether the worktree has been folded away.
158    #[serde(default)]
159    pub folded: bool,
160}
161
162impl Candidate {
163    /// Can this candidate be judged?
164    pub fn viable(&self) -> bool {
165        self.failed.is_none() && !self.empty
166    }
167}
168
169/// One judge's independent ranking.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct Judgement {
172    /// Judge seat number, 1-based.
173    pub judge: usize,
174    /// Seat key.
175    pub seat: String,
176    /// Agent occupying the seat.
177    pub agent: String,
178    /// Best-first labels.
179    #[serde(default)]
180    pub ranking: Vec<char>,
181    /// Per-label justification.
182    #[serde(default)]
183    pub reasons: BTreeMap<String, String>,
184    /// Self-reported confidence.
185    #[serde(default)]
186    pub confidence: Option<u8>,
187    /// Order the candidates were presented in, as candidate indices.
188    #[serde(default)]
189    pub order: Vec<usize>,
190    /// Why this judge has no ranking.
191    #[serde(default)]
192    pub failed: Option<String>,
193    /// Wall-clock time.
194    #[serde(default)]
195    pub duration_ms: u64,
196}
197
198/// One judge's turn in a deliberation round.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct DeliberationTurn {
201    /// Judge seat number, 1-based.
202    pub judge: usize,
203    /// Agent occupying the seat.
204    pub agent: String,
205    /// The argument, as written.
206    pub body: String,
207    /// Where the judge stood at the end of the turn.
208    #[serde(default)]
209    pub tentative: Option<char>,
210}
211
212/// A deliberation round.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct DeliberationRound {
215    /// 1-based round number.
216    pub round: usize,
217    /// Turns, in the order they were taken.
218    pub turns: Vec<DeliberationTurn>,
219}
220
221/// A final vote, collected privately.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct VoteRecord {
224    /// Judge seat number, 1-based.
225    pub judge: usize,
226    /// Agent occupying the seat.
227    pub agent: String,
228    /// The vote.
229    #[serde(default)]
230    pub vote: Option<char>,
231    /// Why.
232    #[serde(default)]
233    pub reason: String,
234    /// Did this judge move from its initial first choice?
235    #[serde(default)]
236    pub changed: bool,
237}
238
239/// A seat that was taken out by a CLI rate limit / quota, recorded so a run
240/// whose panel collapsed does not masquerade as a healthy one.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct QuotaLoss {
243    /// Seat key, e.g. `judge-1` or `review-2`.
244    pub seat: String,
245    /// Node that was running, e.g. `judge`, `vote`, `review`.
246    pub node: String,
247    /// When the CLI reported the limit.
248    pub at: Timestamp,
249    /// Reset hint if the CLI printed one, free text.
250    #[serde(default)]
251    pub reset: Option<String>,
252}
253
254/// The mechanical count.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Tally {
257    /// First-choice votes per label.
258    pub first_choice: BTreeMap<char, usize>,
259    /// Borda points from the initial rankings, used only to break a tie.
260    pub borda: BTreeMap<char, usize>,
261    /// The winning label.
262    pub winner: char,
263    /// How many judges produced a usable ranking. A panel of one is not a
264    /// consensus and must not be reported as a split.
265    #[serde(default)]
266    pub rankings: usize,
267    /// Did every judge's *initial* first choice agree?
268    pub unanimous_initial: bool,
269    /// Was deliberation run?
270    pub deliberated: bool,
271    /// Judges who moved between their initial ranking and their final vote.
272    pub changed_votes: usize,
273    /// Did the final votes agree?
274    pub unanimous_final: bool,
275    /// How the tie was broken, when it had to be.
276    #[serde(default)]
277    pub tie_break: Option<String>,
278    /// Configured judge count — the size of the full panel.
279    #[serde(default)]
280    pub judges: usize,
281    /// Judges who actually contributed to the decision (not taken out by a
282    /// rate limit and producing a usable rank or vote).
283    #[serde(default)]
284    pub present: usize,
285    /// How many judges are required for a trustworthy verdict. Chosen as a
286    /// strict majority (`judges / 2 + 1`): a verdict backed by a minority must
287    /// never be presented as a healthy one, while a bare majority is still
288    /// real signal. A one-candidate run needs no quorum.
289    #[serde(default)]
290    pub quorum: usize,
291    /// `present >= quorum`, or no quorum was required.
292    #[serde(default)]
293    pub met_quorum: bool,
294}
295
296/// One reviewer's report in a round.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct ReviewRecord {
299    /// Reviewer seat number, 1-based.
300    pub reviewer: usize,
301    /// Agent occupying the seat.
302    pub agent: String,
303    /// Reviewer prose.
304    #[serde(default)]
305    pub summary: String,
306    /// Findings, with magi-assigned ids.
307    #[serde(default)]
308    pub findings: Vec<Finding>,
309    /// Why this reviewer produced nothing.
310    #[serde(default)]
311    pub failed: Option<String>,
312    /// Wall-clock time.
313    #[serde(default)]
314    pub duration_ms: u64,
315}
316
317/// The fixer's response to a round.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct FixRecord {
320    /// Agent that applied the fixes.
321    pub agent: String,
322    /// Finding ids acted on.
323    #[serde(default)]
324    pub addressed: Vec<String>,
325    /// Findings declined, with reasons.
326    #[serde(default)]
327    pub rejected: Vec<Rejection>,
328    /// What changed.
329    #[serde(default)]
330    pub notes: String,
331    /// Did the fix produce a commit?
332    #[serde(default)]
333    pub committed: bool,
334    /// Why the fix step produced nothing.
335    #[serde(default)]
336    pub failed: Option<String>,
337    /// Wall-clock time.
338    #[serde(default)]
339    pub duration_ms: u64,
340}
341
342/// Outcome of one shell command.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct CommandOutcome {
345    /// The command, as configured.
346    pub command: String,
347    /// Exit code, `None` on timeout or signal.
348    pub code: Option<i32>,
349    /// Tail of the combined output, for the report and the fix prompt.
350    #[serde(default)]
351    pub output_tail: String,
352    /// Wall-clock time.
353    #[serde(default)]
354    pub duration_ms: u64,
355}
356
357impl CommandOutcome {
358    /// Did it pass?
359    pub fn ok(&self) -> bool {
360        self.code == Some(0)
361    }
362}
363
364/// One review + verify + fix round.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct ReviewRound {
367    /// 1-based round number.
368    pub round: usize,
369    /// Commit the round reviewed.
370    pub head: String,
371    /// Reviewer reports.
372    pub reviews: Vec<ReviewRecord>,
373    /// E2E command outcomes for this round.
374    #[serde(default)]
375    pub e2e: Vec<CommandOutcome>,
376    /// Fixer response, absent when the round was already clean.
377    #[serde(default)]
378    pub fix: Option<FixRecord>,
379    /// Findings that hold the merge.
380    #[serde(default)]
381    pub blocking: usize,
382    /// Round ended with no blocking findings and green verification.
383    #[serde(default)]
384    pub clean: bool,
385}
386
387/// What happened to the winning branch.
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct MergeOutcome {
390    /// Requested mode.
391    pub mode: MergeMode,
392    /// Did it land?
393    pub ok: bool,
394    /// Command output, or the command the operator should run.
395    #[serde(default)]
396    pub detail: String,
397}
398
399/// A timestamped note about a node.
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct Event {
402    /// When.
403    pub at: Timestamp,
404    /// Node name.
405    pub node: String,
406    /// What happened.
407    pub message: String,
408}
409
410/// What the land loop saw last time it looked at the pull request.
411///
412/// Strings for `state` and `checks` on purpose: they are `gh`'s vocabulary, and
413/// pinning them into an enum here would mean a new GitHub check conclusion
414/// turns a readable status into a deserialisation error on a run someone is
415/// trying to look at.
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct PrRecord {
418    /// Pull request url.
419    pub url: String,
420    /// Pull request number.
421    pub number: u64,
422    /// `open`, `merged` or `closed`.
423    pub state: String,
424    /// `pending`, `green`, `red` or `unknown`.
425    pub checks: String,
426    /// Land round, 1-based, or 0 before the first fix.
427    pub round: usize,
428    /// Land round budget.
429    pub rounds: usize,
430}
431
432/// The whole run.
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct RunState {
435    /// On-disk format version.
436    pub schema: u32,
437    /// Run id, e.g. `20260830-153012-a1b2`.
438    pub id: String,
439    /// Repository the run operates on.
440    pub repo: PathBuf,
441    /// Branch the run started from.
442    pub base_branch: String,
443    /// Commit the run started from.
444    pub base_commit: String,
445    /// The task, verbatim.
446    pub instruction: String,
447    /// When the run was created.
448    pub created_at: Timestamp,
449    /// Last state flush.
450    pub updated_at: Timestamp,
451    /// Current status.
452    pub status: RunStatus,
453    /// Seed for labels and session ids.
454    pub seed: u64,
455    /// Config snapshot, so a resumed run behaves like the original.
456    pub config: Config,
457    /// Did magi enable `extensions.worktreeConfig`? If so, cleanup turns it off.
458    #[serde(default)]
459    pub enabled_worktree_config: bool,
460    /// Candidates.
461    #[serde(default)]
462    pub candidates: Vec<Candidate>,
463    /// Initial blind rankings.
464    #[serde(default)]
465    pub judgements: Vec<Judgement>,
466    /// Deliberation, if it happened.
467    #[serde(default)]
468    pub deliberation: Vec<DeliberationRound>,
469    /// Private final votes.
470    #[serde(default)]
471    pub votes: Vec<VoteRecord>,
472    /// The count.
473    #[serde(default)]
474    pub tally: Option<Tally>,
475    /// Review rounds.
476    #[serde(default)]
477    pub reviews: Vec<ReviewRound>,
478    /// Final gate.
479    #[serde(default)]
480    pub gate: Vec<CommandOutcome>,
481    /// Merge outcome.
482    #[serde(default)]
483    pub merge: Option<MergeOutcome>,
484    /// Vendor tokens seen in judged material.
485    #[serde(default)]
486    pub leaks: Vec<Leak>,
487    /// Seats lost to a CLI rate limit / quota, in the order they hit.
488    #[serde(default)]
489    pub quota: Vec<QuotaLoss>,
490    /// Parked at a node boundary, waiting to be resumed.
491    ///
492    /// A run that is neither finished nor being worked on is otherwise
493    /// indistinguishable from one whose daemon was killed, and the two want
494    /// opposite things from an operator: the first is expected to be resumed,
495    /// the second is a leftover. Cleared by the resume that carries it on.
496    #[serde(default)]
497    pub parked: bool,
498    /// Per-seat conversation state.
499    #[serde(default)]
500    pub seats: BTreeMap<String, SeatState>,
501    /// Last observation of the winner's pull request, when a land loop ran.
502    ///
503    /// Persisted rather than derived from the event log because the phone asks
504    /// two questions about a run that has opened a PR - how are its checks and
505    /// which round is it on - and parsing prose out of events to answer them
506    /// would break the first time an event message was reworded.
507    #[serde(default)]
508    pub pr: Option<PrRecord>,
509    /// Node log.
510    #[serde(default)]
511    pub events: Vec<Event>,
512}
513
514impl RunState {
515    /// A fresh run.
516    pub fn new(
517        repo: PathBuf,
518        base_branch: String,
519        base_commit: String,
520        instruction: String,
521        config: Config,
522    ) -> Self {
523        let now = Timestamp::now();
524        let seed = config.blind.seed.unwrap_or_else(crate::rng::entropy);
525        Self {
526            schema: SCHEMA,
527            id: new_id(seed),
528            repo,
529            base_branch,
530            base_commit,
531            instruction,
532            created_at: now,
533            updated_at: now,
534            status: RunStatus::Prep,
535            seed,
536            config,
537            enabled_worktree_config: false,
538            candidates: Vec::new(),
539            judgements: Vec::new(),
540            deliberation: Vec::new(),
541            votes: Vec::new(),
542            tally: None,
543            reviews: Vec::new(),
544            gate: Vec::new(),
545            merge: None,
546            leaks: Vec::new(),
547            quota: Vec::new(),
548            parked: false,
549            seats: BTreeMap::new(),
550            pr: None,
551            events: Vec::new(),
552        }
553    }
554
555    /// Directory holding this run's state and artifacts.
556    pub fn dir(&self) -> PathBuf {
557        run_dir(&self.id)
558    }
559
560    /// Short form used in branch names and reports.
561    pub fn short(&self) -> &str {
562        short_of(&self.id)
563    }
564
565    /// Branch name for a label.
566    pub fn branch_for(&self, label: char) -> String {
567        format!("magi/{}/{}", self.short(), label)
568    }
569
570    /// Root of this run's worktrees.
571    pub fn worktree_root(&self) -> PathBuf {
572        self.config
573            .graph
574            .worktree_root
575            .clone()
576            .unwrap_or_else(|| {
577                dirs::home_dir()
578                    .unwrap_or_else(|| PathBuf::from("."))
579                    .join("wt")
580                    .join("magi")
581            })
582            .join(self.short())
583    }
584
585    /// Note something in the run log and on the tracing stream.
586    pub fn event(&mut self, node: &str, message: impl Into<String>) {
587        let message = message.into();
588        tracing::info!(node, "{message}");
589        self.events.push(Event {
590            at: Timestamp::now(),
591            node: node.to_owned(),
592            message,
593        });
594    }
595
596    /// Flush to `run.json`, atomically.
597    pub fn save(&mut self) -> Result<()> {
598        self.updated_at = Timestamp::now();
599        let dir = self.dir();
600        std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
601        let body = serde_json::to_string_pretty(self).context("serialize run state")?;
602        let tmp = dir.join("run.json.tmp");
603        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
604        std::fs::rename(&tmp, dir.join("run.json")).with_context(|| "replace run.json")?;
605        Ok(())
606    }
607
608    /// Load a run by id or unambiguous id prefix.
609    pub fn load(id: &str) -> Result<Self> {
610        let resolved = resolve_id(id)?;
611        let path = run_dir(&resolved).join("run.json");
612        let body =
613            std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
614        let state: Self =
615            serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
616        if state.schema != SCHEMA {
617            bail!(
618                "run {} was written by a different magi (schema {}, this build \
619                 speaks {SCHEMA})",
620                state.id,
621                state.schema
622            );
623        }
624        Ok(state)
625    }
626
627    /// The winning candidate, once the tally has run.
628    pub fn winner(&self) -> Option<&Candidate> {
629        let label = self.tally.as_ref()?.winner;
630        self.candidates.iter().find(|c| c.label == label)
631    }
632
633    /// Candidates eligible for judging.
634    pub fn viable(&self) -> Vec<&Candidate> {
635        self.candidates.iter().filter(|c| c.viable()).collect()
636    }
637
638    /// Local-time creation stamp for reports.
639    pub fn created_local(&self) -> String {
640        self.created_at
641            .to_zoned(jiff::tz::TimeZone::system())
642            .strftime("%Y-%m-%d %H:%M:%S")
643            .to_string()
644    }
645
646    /// Assert that this run is safe to delete.
647    ///
648    /// Refuses a run a live daemon is working on, and refuses any run whose
649    /// candidate worktrees and branches have not been folded away with `magi
650    /// fold`. The fold requirement is the real protection: it is what makes
651    /// "delete" mean "remove a record" rather than "throw away a worktree
652    /// somebody may still be editing".
653    ///
654    /// `in_flight` has to come from the caller, because a run's own status
655    /// cannot answer the question. A daemon killed mid-run leaves its status at
656    /// `implementing` forever, and a guard that trusted that would make every
657    /// interrupted run permanently undeletable - the operator's only recourse
658    /// being to edit `run.json` by hand, which is exactly the sort of thing
659    /// this command exists to avoid. The queue already treats an orphaned
660    /// `.lock` from a `SIGKILL`ed daemon the same way; this is that rule for
661    /// runs.
662    pub fn ensure_can_delete(&self, in_flight: bool) -> Result<()> {
663        if in_flight {
664            bail!(
665                "run {} is being worked on by a live daemon right now",
666                self.short()
667            );
668        }
669        if self.candidates.iter().any(|c| !c.folded) {
670            bail!(
671                "run {} has unfolded candidates; fold first with `magi fold`",
672                self.short()
673            );
674        }
675        Ok(())
676    }
677}
678
679/// The short form of a run id: the trailing block after the last `-`.
680///
681/// A free function as well as [`RunState::short`], because callers that have
682/// only an id - an error message, a daemon status, a route handler - were
683/// otherwise reimplementing the split, and two spellings of "short id" is one
684/// rename away from branch names that no longer match their run.
685pub fn short_of(id: &str) -> &str {
686    id.split('-').next_back().unwrap_or(id)
687}
688
689/// Where magi keeps its runs.
690///
691/// `MAGI_HOME` overrides the default, and [`set_home`] overrides both — which
692/// is what lets the integration tests drive a whole graph without writing into
693/// the operator's real history.
694pub fn home() -> PathBuf {
695    if let Some(dir) = HOME.get() {
696        return dir.clone();
697    }
698    if let Some(dir) = std::env::var_os("MAGI_HOME") {
699        return PathBuf::from(dir);
700    }
701    dirs::data_local_dir()
702        .unwrap_or_else(|| PathBuf::from("."))
703        .join("magi")
704}
705
706/// Pin the run home for this process. The first call wins.
707pub fn set_home(dir: PathBuf) {
708    let _ = HOME.set(dir);
709}
710
711static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
712
713/// `<home>/runs`.
714pub fn runs_root() -> PathBuf {
715    home().join("runs")
716}
717
718/// Directory for one run id.
719pub fn run_dir(id: &str) -> PathBuf {
720    runs_root().join(id)
721}
722
723/// Every run id on disk, newest first.
724pub fn list_ids() -> Vec<String> {
725    let mut ids: Vec<String> = std::fs::read_dir(runs_root())
726        .into_iter()
727        .flatten()
728        .flatten()
729        .filter(|e| e.path().join("run.json").is_file())
730        .map(|e| e.file_name().to_string_lossy().into_owned())
731        .collect();
732    // Ids start with a sortable timestamp.
733    ids.sort_unstable_by(|a, b| b.cmp(a));
734    ids
735}
736
737/// Expand an id prefix to exactly one run id.
738pub fn resolve_id(prefix: &str) -> Result<String> {
739    if run_dir(prefix).join("run.json").is_file() {
740        return Ok(prefix.to_owned());
741    }
742    let hits: Vec<String> = list_ids()
743        .into_iter()
744        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
745        .collect();
746    match hits.len() {
747        1 => Ok(hits.into_iter().next().expect("exactly one hit")),
748        0 => bail!("no run matches `{prefix}`"),
749        _ => bail!(
750            "`{prefix}` matches {} runs: {}",
751            hits.len(),
752            hits.join(", ")
753        ),
754    }
755}
756
757/// The most recent run, if any.
758pub fn latest_id() -> Option<String> {
759    list_ids().into_iter().next()
760}
761
762/// `YYYYMMDD-HHMMSS-xxxx`, sortable and short enough for a branch name.
763fn new_id(seed: u64) -> String {
764    let stamp = Zoned::now().strftime("%Y%m%d-%H%M%S");
765    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
766}
767
768/// Keep the last `max` bytes of `text`, on a line boundary.
769pub fn tail(text: &str, max: usize) -> String {
770    if text.len() <= max {
771        return text.to_owned();
772    }
773    let mut cut = text.len() - max;
774    while cut < text.len() && !text.is_char_boundary(cut) {
775        cut += 1;
776    }
777    let slice = &text[cut..];
778    let start = slice.find('\n').map_or(0, |i| i + 1);
779    format!(
780        "[... {} earlier bytes omitted ...]\n{}",
781        cut,
782        &slice[start..]
783    )
784}
785
786/// Path of a run artifact.
787pub fn artifact_path(run: &RunState, name: &str) -> PathBuf {
788    run.dir().join("artifacts").join(name)
789}
790
791/// Write an artifact, creating the directory if needed.
792pub fn write_artifact(run: &RunState, name: &str, body: &str) -> Result<PathBuf> {
793    let path = artifact_path(run, name);
794    if let Some(parent) = path.parent() {
795        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
796    }
797    std::fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
798    Ok(path)
799}
800
801/// Read an artifact back, e.g. a stored patch on resume.
802pub fn read_artifact(run: &RunState, name: &str) -> Option<String> {
803    std::fs::read_to_string(artifact_path(run, name)).ok()
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    fn state() -> RunState {
811        RunState::new(
812            PathBuf::from("/repo"),
813            "main".to_owned(),
814            "abc1234def".to_owned(),
815            "add retries".to_owned(),
816            Config::default(),
817        )
818    }
819
820    #[test]
821    fn ids_are_sortable_and_short_suffixed() {
822        let s = state();
823        let parts: Vec<&str> = s.id.split('-').collect();
824        assert_eq!(parts.len(), 3);
825        assert_eq!(parts[0].len(), 8);
826        assert_eq!(parts[1].len(), 6);
827        assert_eq!(parts[2].len(), 4);
828        assert_eq!(s.short(), parts[2]);
829    }
830
831    #[test]
832    fn branch_names_carry_the_label_not_the_author() {
833        let s = state();
834        let b = s.branch_for('B');
835        assert_eq!(b, format!("magi/{}/B", s.short()));
836        assert!(!b.contains("claude"));
837    }
838
839    #[test]
840    fn seed_from_config_makes_the_run_reproducible() {
841        let mut cfg = Config::default();
842        cfg.blind.seed = Some(1234);
843        let a = RunState::new(
844            PathBuf::from("/r"),
845            "main".to_owned(),
846            "c".to_owned(),
847            "t".to_owned(),
848            cfg.clone(),
849        );
850        let b = RunState::new(
851            PathBuf::from("/r"),
852            "main".to_owned(),
853            "c".to_owned(),
854            "t".to_owned(),
855            cfg,
856        );
857        assert_eq!(a.seed, 1234);
858        assert_eq!(a.seed, b.seed);
859        assert_eq!(a.short(), b.short());
860    }
861
862    #[test]
863    fn status_terminality() {
864        assert!(RunStatus::Merged.done());
865        assert!(RunStatus::Blocked.done());
866        assert!(!RunStatus::Reviewing.done());
867    }
868
869    #[test]
870    fn candidate_viability_excludes_empty_and_failed() {
871        let mut c = Candidate {
872            index: 0,
873            label: 'A',
874            agent: "a".to_owned(),
875            branch: "b".to_owned(),
876            worktree: PathBuf::from("/w"),
877            summary: String::new(),
878            stat: String::new(),
879            files: 1,
880            commits: 1,
881            empty: false,
882            failed: None,
883            duration_ms: 0,
884            folded: false,
885        };
886        assert!(c.viable());
887        c.empty = true;
888        assert!(!c.viable());
889        c.empty = false;
890        c.failed = Some("timeout".to_owned());
891        assert!(!c.viable());
892    }
893
894    #[test]
895    fn tail_keeps_the_end_on_a_line_boundary() {
896        let text = (0..100).map(|i| format!("line {i}\n")).collect::<String>();
897        let t = tail(&text, 40);
898        assert!(t.starts_with("[..."));
899        assert!(t.ends_with("line 99\n"));
900        assert!(t.len() < 120);
901        assert_eq!(tail("short", 40), "short");
902    }
903
904    #[test]
905    fn tail_survives_multibyte_cuts() {
906        let text = "あ".repeat(50);
907        let t = tail(&text, 10);
908        assert!(t.contains("earlier bytes omitted"));
909        assert!(t.ends_with('あ'));
910    }
911
912    #[test]
913    fn state_round_trips_through_json() {
914        let s = state();
915        let body = serde_json::to_string(&s).unwrap();
916        let back: RunState = serde_json::from_str(&body).unwrap();
917        assert_eq!(back.id, s.id);
918        assert_eq!(back.instruction, "add retries");
919        assert_eq!(back.status, RunStatus::Prep);
920    }
921
922    #[test]
923    fn ensure_can_delete_guards_live_and_unfolded_runs() {
924        let mut s = state();
925        // 1. A daemon is working on it right now.
926        s.status = RunStatus::Prep;
927        let err = s.ensure_can_delete(true).unwrap_err().to_string();
928        assert!(err.contains("live daemon"), "{err}");
929
930        // 2. The same unfinished run with no daemon behind it is a leftover
931        // from a killed process, and deletable. Without this an interrupted
932        // run could never be removed: its status stays `prep` forever.
933        assert!(s.ensure_can_delete(false).is_ok());
934
935        // 3. Unfolded candidates are refused either way — that is the guard
936        // that stops a delete from discarding a worktree.
937        s.status = RunStatus::Merged;
938        s.candidates.push(Candidate {
939            index: 0,
940            label: 'A',
941            agent: "a".to_owned(),
942            branch: "b".to_owned(),
943            worktree: PathBuf::from("/w"),
944            summary: String::new(),
945            stat: String::new(),
946            files: 1,
947            commits: 1,
948            empty: false,
949            failed: None,
950            duration_ms: 0,
951            folded: false,
952        });
953        let err = s.ensure_can_delete(false).unwrap_err().to_string();
954        assert!(
955            err.contains("magi fold"),
956            "error must suggest `magi fold`: {err}"
957        );
958
959        // 4. Folded and nobody working on it.
960        s.candidates[0].folded = true;
961        assert!(s.ensure_can_delete(false).is_ok());
962    }
963}