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(),
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(default_worktree_root)
577            .join(self.short())
578    }
579
580    /// Note something in the run log and on the tracing stream.
581    pub fn event(&mut self, node: &str, message: impl Into<String>) {
582        let message = message.into();
583        tracing::info!(node, "{message}");
584        self.events.push(Event {
585            at: Timestamp::now(),
586            node: node.to_owned(),
587            message,
588        });
589    }
590
591    /// Flush to `run.json`, atomically.
592    pub fn save(&mut self) -> Result<()> {
593        self.updated_at = Timestamp::now();
594        let dir = self.dir();
595        std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
596        let body = serde_json::to_string_pretty(self).context("serialize run state")?;
597        let tmp = dir.join("run.json.tmp");
598        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
599        std::fs::rename(&tmp, dir.join("run.json")).with_context(|| "replace run.json")?;
600        Ok(())
601    }
602
603    /// Load a run by id or unambiguous id prefix.
604    pub fn load(id: &str) -> Result<Self> {
605        let resolved = resolve_id(id)?;
606        let path = run_dir(&resolved).join("run.json");
607        let body =
608            std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
609        let state: Self =
610            serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
611        if state.schema != SCHEMA {
612            bail!(
613                "run {} was written by a different magi (schema {}, this build \
614                 speaks {SCHEMA})",
615                state.id,
616                state.schema
617            );
618        }
619        Ok(state)
620    }
621
622    /// The winning candidate, once the tally has run.
623    pub fn winner(&self) -> Option<&Candidate> {
624        let label = self.tally.as_ref()?.winner;
625        self.candidates.iter().find(|c| c.label == label)
626    }
627
628    /// Candidates eligible for judging.
629    pub fn viable(&self) -> Vec<&Candidate> {
630        self.candidates.iter().filter(|c| c.viable()).collect()
631    }
632
633    /// Local-time creation stamp for reports.
634    pub fn created_local(&self) -> String {
635        self.created_at
636            .to_zoned(jiff::tz::TimeZone::system())
637            .strftime("%Y-%m-%d %H:%M:%S")
638            .to_string()
639    }
640
641    /// Assert that this run is safe to delete.
642    ///
643    /// Refuses a run a live daemon is working on, and refuses any run whose
644    /// candidate worktrees and branches have not been folded away with `magi
645    /// fold`. The fold requirement is the real protection: it is what makes
646    /// "delete" mean "remove a record" rather than "throw away a worktree
647    /// somebody may still be editing".
648    ///
649    /// `in_flight` has to come from the caller, because a run's own status
650    /// cannot answer the question. A daemon killed mid-run leaves its status at
651    /// `implementing` forever, and a guard that trusted that would make every
652    /// interrupted run permanently undeletable - the operator's only recourse
653    /// being to edit `run.json` by hand, which is exactly the sort of thing
654    /// this command exists to avoid. The queue already treats an orphaned
655    /// `.lock` from a `SIGKILL`ed daemon the same way; this is that rule for
656    /// runs.
657    pub fn ensure_can_delete(&self, in_flight: bool) -> Result<()> {
658        if in_flight {
659            bail!(
660                "run {} is being worked on by a live daemon right now",
661                self.short()
662            );
663        }
664        if self.candidates.iter().any(|c| !c.folded) {
665            bail!(
666                "run {} has unfolded candidates; fold first with `magi fold`",
667                self.short()
668            );
669        }
670        Ok(())
671    }
672}
673
674/// The short form of a run id: the trailing block after the last `-`.
675///
676/// A free function as well as [`RunState::short`], because callers that have
677/// only an id - an error message, a daemon status, a route handler - were
678/// otherwise reimplementing the split, and two spellings of "short id" is one
679/// rename away from branch names that no longer match their run.
680pub fn short_of(id: &str) -> &str {
681    id.split('-').next_back().unwrap_or(id)
682}
683
684/// Where magi keeps its runs.
685///
686/// `MAGI_HOME` overrides the default, and [`set_home`] overrides both — which
687/// is what lets the integration tests drive a whole graph without writing into
688/// the operator's real history.
689///
690/// In a unit test build (`cfg(test)`), falling through to the real
691/// `<data_local>/magi` is not a fallback worth having: it is exactly how
692/// three broken fixture runs ended up in the operator's actual history and
693/// were counted as `unreadable` by the deck. A test that reaches this point
694/// forgot to call [`set_home`] (or set `MAGI_HOME`) - that is a bug in the
695/// test, not a case to serve, so it panics instead of writing anywhere.
696pub fn home() -> PathBuf {
697    resolve_home(HOME.get().cloned(), std::env::var_os("MAGI_HOME"))
698}
699
700/// The decision `home` makes, taking its two overrides as plain values
701/// instead of reading the `OnceLock` and the environment itself.
702///
703/// Pulled out so the `cfg(test)` panic is asserted directly against a
704/// `None, None` input, rather than racing every other unit test in the
705/// binary for who touches the process-global `HOME` first.
706fn resolve_home(pinned: Option<PathBuf>, magi_home_env: Option<std::ffi::OsString>) -> PathBuf {
707    if let Some(dir) = pinned {
708        return dir;
709    }
710    if let Some(dir) = magi_home_env {
711        return PathBuf::from(dir);
712    }
713    #[cfg(test)]
714    {
715        panic!(
716            "run::home() was reached in a test without run::set_home() or \
717             MAGI_HOME; this would write into the operator's real \
718             <data_local>/magi. Call `run::set_home(temp_dir)` before any \
719             code path that touches a RunState."
720        );
721    }
722    #[cfg(not(test))]
723    {
724        dirs::data_local_dir()
725            .unwrap_or_else(|| PathBuf::from("."))
726            .join("magi")
727    }
728}
729
730/// Pin the run home for this process. The first call wins.
731pub fn set_home(dir: PathBuf) {
732    let _ = HOME.set(dir);
733}
734
735static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
736
737/// `<home>/runs`.
738pub fn runs_root() -> PathBuf {
739    home().join("runs")
740}
741
742/// The worktree root a run uses when the config sets none: `~/wt/magi`.
743///
744/// One definition of the default, so the folder the janitor folds and the
745/// folder the health view sizes cannot drift apart: a run with no configured
746/// [`crate::config::Graph::worktree_root`] lays its worktrees exactly here.
747pub fn default_worktree_root() -> PathBuf {
748    dirs::home_dir()
749        .unwrap_or_else(|| PathBuf::from("."))
750        .join("wt")
751        .join("magi")
752}
753
754/// Directory for one run id.
755pub fn run_dir(id: &str) -> PathBuf {
756    runs_root().join(id)
757}
758
759/// Every run id on disk, newest first.
760///
761/// A directory is a run because of its **name**, not because it holds a
762/// readable `run.json`. A run whose very first save lost the machine's last
763/// free bytes leaves `<id>/run.json.tmp` and nothing else, and filtering on
764/// `run.json` made that run invisible everywhere: not in `magi list`, not in
765/// `runs_unreadable`, not on the phone, so nothing could report it and no
766/// route could clear it. `88c0` sat like that for two days. Unreadable is
767/// counted, never hidden - the readers already say why each one cannot be
768/// read, and `fold_unreadable` is how a record like this leaves.
769pub fn list_ids() -> Vec<String> {
770    let mut ids: Vec<String> = std::fs::read_dir(runs_root())
771        .into_iter()
772        .flatten()
773        .flatten()
774        .filter(|e| e.path().is_dir())
775        .map(|e| e.file_name().to_string_lossy().into_owned())
776        .filter(|name| is_run_id(name))
777        .collect();
778    // Ids start with a sortable timestamp.
779    ids.sort_unstable_by(|a, b| b.cmp(a));
780    ids
781}
782
783/// Does `name` have the shape [`new_id`] mints: `YYYYMMDD-HHMMSS-xxxx`?
784///
785/// The test for "this directory is a run", so a stray folder under
786/// `<home>/runs` is not reported as a broken run.
787///
788/// The tag is checked for length and for being alphanumeric, not for being
789/// hex: real ids are hex, but fixtures across this crate name runs
790/// `...-dead` / `...-gone` / `...-once`, and a predicate that disowned those
791/// would be asserting the fixtures' spelling rather than the shape.
792pub fn is_run_id(name: &str) -> bool {
793    let mut parts = name.split('-');
794    let (Some(day), Some(time), Some(tag), None) =
795        (parts.next(), parts.next(), parts.next(), parts.next())
796    else {
797        return false;
798    };
799    day.len() == 8
800        && day.bytes().all(|b| b.is_ascii_digit())
801        && time.len() == 6
802        && time.bytes().all(|b| b.is_ascii_digit())
803        && tag.len() == 4
804        && tag.bytes().all(|b| b.is_ascii_alphanumeric())
805}
806
807/// Expand an id prefix to exactly one run id.
808pub fn resolve_id(prefix: &str) -> Result<String> {
809    // A whole id names its directory, readable state or not: the run whose
810    // `run.json` never landed still has to be reachable by `magi show` and
811    // by the fold route, which is the only way its record ever leaves.
812    if is_run_id(prefix) && run_dir(prefix).is_dir() {
813        return Ok(prefix.to_owned());
814    }
815    let hits: Vec<String> = list_ids()
816        .into_iter()
817        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
818        .collect();
819    match hits.len() {
820        1 => Ok(hits.into_iter().next().expect("exactly one hit")),
821        0 => bail!("no run matches `{prefix}`"),
822        _ => bail!(
823            "`{prefix}` matches {} runs: {}",
824            hits.len(),
825            hits.join(", ")
826        ),
827    }
828}
829
830/// The most recent run, if any.
831pub fn latest_id() -> Option<String> {
832    list_ids().into_iter().next()
833}
834
835/// `YYYYMMDD-HHMMSS-xxxx`, sortable and short enough for a branch name.
836///
837/// The four hex digits are fresh entropy, **not** `blind.seed`. They were the
838/// seed, and a pinned seed then made the whole id a function of the second it
839/// started in: two runs a second apart were distinguishable, two in the same
840/// second were not. Everything keyed on the id collided with them - the run
841/// directory, `artifacts/`, and the candidate worktrees under
842/// `wt/magi/<short>/`.
843///
844/// `tests/common` pins the seed on purpose, so its integration tests all share
845/// one suffix. On Windows the suite is slow enough that the seconds differ and
846/// nothing showed; on Linux `graph_dropped_stream`'s three tests run inside
847/// 16s, so two of them shared a run directory and the second read an artifact
848/// the first had written (`impl-B-resume.out`) - a failure that looked like the
849/// resume logic misbehaving and was really two runs in one directory.
850///
851/// A seed exists to make the *blind* decisions reproducible: label assignment
852/// and per-judge presentation order. It was never meant to name the run, and
853/// `RunState::seed` still carries it for what it is for.
854fn new_id() -> String {
855    let stamp = Zoned::now().strftime("%Y%m%d-%H%M%S");
856    let entropy = crate::rng::entropy();
857    format!("{stamp}-{:04x}", (entropy ^ (entropy >> 32)) & 0xffff)
858}
859
860/// Keep the last `max` bytes of `text`, on a line boundary.
861pub fn tail(text: &str, max: usize) -> String {
862    if text.len() <= max {
863        return text.to_owned();
864    }
865    let mut cut = text.len() - max;
866    while cut < text.len() && !text.is_char_boundary(cut) {
867        cut += 1;
868    }
869    let slice = &text[cut..];
870    let start = slice.find('\n').map_or(0, |i| i + 1);
871    format!(
872        "[... {} earlier bytes omitted ...]\n{}",
873        cut,
874        &slice[start..]
875    )
876}
877
878/// Path of a run artifact.
879pub fn artifact_path(run: &RunState, name: &str) -> PathBuf {
880    run.dir().join("artifacts").join(name)
881}
882
883/// Write an artifact, creating the directory if needed.
884pub fn write_artifact(run: &RunState, name: &str, body: &str) -> Result<PathBuf> {
885    let path = artifact_path(run, name);
886    if let Some(parent) = path.parent() {
887        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
888    }
889    std::fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
890    Ok(path)
891}
892
893/// Read an artifact back, e.g. a stored patch on resume.
894pub fn read_artifact(run: &RunState, name: &str) -> Option<String> {
895    std::fs::read_to_string(artifact_path(run, name)).ok()
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901
902    fn state() -> RunState {
903        RunState::new(
904            PathBuf::from("/repo"),
905            "main".to_owned(),
906            "abc1234def".to_owned(),
907            "add retries".to_owned(),
908            Config::default(),
909        )
910    }
911
912    #[test]
913    fn resolve_home_prefers_the_pin_then_the_env_var() {
914        let pinned = PathBuf::from("/pinned");
915        assert_eq!(
916            resolve_home(Some(pinned.clone()), Some("/env".into())),
917            pinned,
918            "a pin wins even over MAGI_HOME"
919        );
920        assert_eq!(
921            resolve_home(None, Some("/env".into())),
922            PathBuf::from("/env")
923        );
924    }
925
926    #[test]
927    #[should_panic(expected = "run::set_home()")]
928    fn resolve_home_refuses_to_fall_back_to_the_operators_real_home() {
929        // Neither override present is exactly the state a test reaches by
930        // forgetting `set_home`/`MAGI_HOME` - the accident that put three
931        // broken fixture runs into the operator's real history. Asserted
932        // against the pure decision directly, not `home()` itself, because
933        // `HOME` is a process-wide `OnceLock` another test may have already
934        // set - this must not depend on test execution order.
935        resolve_home(None, None);
936    }
937
938    #[test]
939    fn a_run_is_named_by_shape_so_a_state_less_directory_is_still_a_run() {
940        // The shape `new_id` mints. A directory answering to it is a run even
941        // with no readable `run.json`: that is how a save that ran out of
942        // disk stays visible instead of vanishing from every listing.
943        assert!(is_run_id(&new_id()));
944        assert!(is_run_id("20260904-014540-88c0"));
945        // Not runs: a stray folder, a truncated id, a non-hex tag, and an id
946        // with an extra segment (a worktree label, say).
947        assert!(!is_run_id("scratch"));
948        assert!(!is_run_id("20260904-014540"));
949        assert!(!is_run_id("20260904-014540-88c0f"));
950        assert!(!is_run_id("2026090x-014540-88c0"));
951        assert!(!is_run_id("20260904-014540-88c0-A"));
952    }
953
954    #[test]
955    fn ids_are_sortable_and_short_suffixed() {
956        let s = state();
957        let parts: Vec<&str> = s.id.split('-').collect();
958        assert_eq!(parts.len(), 3);
959        assert_eq!(parts[0].len(), 8);
960        assert_eq!(parts[1].len(), 6);
961        assert_eq!(parts[2].len(), 4);
962        assert_eq!(s.short(), parts[2]);
963    }
964
965    #[test]
966    fn branch_names_carry_the_label_not_the_author() {
967        let s = state();
968        let b = s.branch_for('B');
969        assert_eq!(b, format!("magi/{}/B", s.short()));
970        assert!(!b.contains("claude"));
971    }
972
973    /// A pinned seed reproduces the blind decisions. It must **not** reproduce
974    /// the run's identity.
975    ///
976    /// `assert_eq!(a.short(), b.short())` used to stand where the last
977    /// assertion is now, and it was pinning the defect: with the id's suffix
978    /// derived from the seed, two runs started in the same second were the
979    /// same run as far as the filesystem was concerned - one directory, one
980    /// `artifacts/`, one set of candidate worktrees. `tests/common` pins a
981    /// seed for every integration test, so on Linux, where the suite is fast,
982    /// two tests in `graph_dropped_stream` shared a directory and one read the
983    /// other's artifact.
984    #[test]
985    fn a_pinned_seed_is_reproducible_but_never_the_run_id() {
986        let mut cfg = Config::default();
987        cfg.blind.seed = Some(1234);
988        let a = RunState::new(
989            PathBuf::from("/r"),
990            "main".to_owned(),
991            "c".to_owned(),
992            "t".to_owned(),
993            cfg.clone(),
994        );
995        let b = RunState::new(
996            PathBuf::from("/r"),
997            "main".to_owned(),
998            "c".to_owned(),
999            "t".to_owned(),
1000            cfg,
1001        );
1002        // What the seed is for: the same shuffles, run after run.
1003        assert_eq!(a.seed, 1234);
1004        assert_eq!(a.seed, b.seed);
1005        // What it is not for. Two runs are two runs, in the same second or
1006        // not, and everything keyed on the id depends on that.
1007        assert_ne!(
1008            a.id, b.id,
1009            "two runs sharing an id share a directory, artifacts and worktrees"
1010        );
1011    }
1012
1013    #[test]
1014    fn status_terminality() {
1015        assert!(RunStatus::Merged.done());
1016        assert!(RunStatus::Blocked.done());
1017        assert!(!RunStatus::Reviewing.done());
1018    }
1019
1020    #[test]
1021    fn candidate_viability_excludes_empty_and_failed() {
1022        let mut c = Candidate {
1023            index: 0,
1024            label: 'A',
1025            agent: "a".to_owned(),
1026            branch: "b".to_owned(),
1027            worktree: PathBuf::from("/w"),
1028            summary: String::new(),
1029            stat: String::new(),
1030            files: 1,
1031            commits: 1,
1032            empty: false,
1033            failed: None,
1034            duration_ms: 0,
1035            folded: false,
1036        };
1037        assert!(c.viable());
1038        c.empty = true;
1039        assert!(!c.viable());
1040        c.empty = false;
1041        c.failed = Some("timeout".to_owned());
1042        assert!(!c.viable());
1043    }
1044
1045    #[test]
1046    fn tail_keeps_the_end_on_a_line_boundary() {
1047        let text = (0..100).map(|i| format!("line {i}\n")).collect::<String>();
1048        let t = tail(&text, 40);
1049        assert!(t.starts_with("[..."));
1050        assert!(t.ends_with("line 99\n"));
1051        assert!(t.len() < 120);
1052        assert_eq!(tail("short", 40), "short");
1053    }
1054
1055    #[test]
1056    fn tail_survives_multibyte_cuts() {
1057        let text = "あ".repeat(50);
1058        let t = tail(&text, 10);
1059        assert!(t.contains("earlier bytes omitted"));
1060        assert!(t.ends_with('あ'));
1061    }
1062
1063    #[test]
1064    fn state_round_trips_through_json() {
1065        let s = state();
1066        let body = serde_json::to_string(&s).unwrap();
1067        let back: RunState = serde_json::from_str(&body).unwrap();
1068        assert_eq!(back.id, s.id);
1069        assert_eq!(back.instruction, "add retries");
1070        assert_eq!(back.status, RunStatus::Prep);
1071    }
1072
1073    #[test]
1074    fn ensure_can_delete_guards_live_and_unfolded_runs() {
1075        let mut s = state();
1076        // 1. A daemon is working on it right now.
1077        s.status = RunStatus::Prep;
1078        let err = s.ensure_can_delete(true).unwrap_err().to_string();
1079        assert!(err.contains("live daemon"), "{err}");
1080
1081        // 2. The same unfinished run with no daemon behind it is a leftover
1082        // from a killed process, and deletable. Without this an interrupted
1083        // run could never be removed: its status stays `prep` forever.
1084        assert!(s.ensure_can_delete(false).is_ok());
1085
1086        // 3. Unfolded candidates are refused either way — that is the guard
1087        // that stops a delete from discarding a worktree.
1088        s.status = RunStatus::Merged;
1089        s.candidates.push(Candidate {
1090            index: 0,
1091            label: 'A',
1092            agent: "a".to_owned(),
1093            branch: "b".to_owned(),
1094            worktree: PathBuf::from("/w"),
1095            summary: String::new(),
1096            stat: String::new(),
1097            files: 1,
1098            commits: 1,
1099            empty: false,
1100            failed: None,
1101            duration_ms: 0,
1102            folded: false,
1103        });
1104        let err = s.ensure_can_delete(false).unwrap_err().to_string();
1105        assert!(
1106            err.contains("magi fold"),
1107            "error must suggest `magi fold`: {err}"
1108        );
1109
1110        // 4. Folded and nobody working on it.
1111        s.candidates[0].folded = true;
1112        assert!(s.ensure_can_delete(false).is_ok());
1113    }
1114}