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