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, ReviewVote};
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.
30///
31/// 3: added `RunState::judge_skipped`. A solo candidate makes `judge` write
32/// only an event, leaving `judgements` empty forever — indistinguishable from
33/// "not yet judged" on every later reentry, which is what let `judge` re-run
34/// on a finished run and clobber its status back to `Judging`. The flag is
35/// the missing record of the fact that judging was skipped on purpose.
36///
37/// Also 3: a single-viable-candidate tally records `Tally::judges` as `0` and
38/// fills `Tally::uncontested`, instead of leaving the full roster size sitting
39/// next to a panel that never sat. A schema-2 record keeps reading as "0 of 3
40/// judges present" forever, because a tally is computed once and never
41/// recomputed on resume; the bump keeps that stale reading from being mixed
42/// with the new meaning.
43///
44/// 4: added `ReviewRound::progressed`. `graph::STAGNANT_LIMIT` counts
45/// consecutive rounds with `progressed == false` to decide whether the
46/// review loop should give up early, and a schema-3 record's default
47/// `false` would misreport a round that, at the time, actually committed a
48/// real diff — the field simply did not exist yet to say so. Without the
49/// bump, resuming an old multi-round review could spuriously trip the
50/// stagnation check on rounds that were never stagnant.
51///
52/// 5: added `RunStatus::Landing`. A run inside [`crate::land`]'s post-merge
53/// loop used to carry whatever status `merge` set before calling it forward
54/// unchanged - `Merged`, even while still watching CI or waiting on the
55/// owner's approval - which is also the one status [`RunStatus::resumable`]
56/// treats as finished. A daemon that gave this run's slot back to poll
57/// something else while an approval was outstanding, or one that simply
58/// crashed mid-land, had no way to tell "still landing" from "actually
59/// merged" and would either restart the whole competition or leave the run
60/// stuck reading as done. A schema-4 record has no notion of `Landing` at
61/// all, so this is a meaning a resumed old run cannot be guessed into rather
62/// than a value it can default to - hence the bump, not a `#[serde(default)]`.
63///
64/// 6: a deferred e2e is represented by an empty outcome list plus
65/// `ReviewRound::e2e_deferred`. Schema 5 treated that same empty list as an
66/// unconfigured, successful check, so schema-5 records are migrated with the
67/// old (not-deferred) meaning while older binaries reject schema-6 records.
68pub const SCHEMA: u32 = 6;
69
70/// Where a run got to.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum RunStatus {
74    /// Worktrees being prepared.
75    Prep,
76    /// Candidates being implemented.
77    Implementing,
78    /// Judges ranking blind.
79    Judging,
80    /// Judges deliberating after a split.
81    Deliberating,
82    /// Final votes being collected privately.
83    Voting,
84    /// Winner in the review + verification loop.
85    Reviewing,
86    /// Gate commands running.
87    Gating,
88    /// Inside [`crate::land`]'s post-merge loop: watching CI, running a fix
89    /// round, rebasing onto a moved base, or waiting on the owner's merge
90    /// approval. A run parked here while an approval is outstanding has
91    /// handed its daemon slot back — see [`crate::daemon`] — and resumes
92    /// through exactly this status, not a fresh competition.
93    Landing,
94    /// Winner merged.
95    Merged,
96    /// Winner passed the gate; merge was not requested.
97    Ready,
98    /// The judgement did not gather enough judges (e.g. rate limiting took out
99    /// seats), so the verdict is not trustworthy. The run stopped and kept its
100    /// work so it can be resumed or folded — it must never be confused with a
101    /// healthy `Ready`.
102    Stalled,
103    /// Review rounds exhausted with findings still open, or the gate failed.
104    Blocked,
105    /// The graph could not complete.
106    Failed,
107}
108
109impl RunStatus {
110    /// Is this a terminal state?
111    pub fn done(self) -> bool {
112        matches!(
113            self,
114            Self::Merged | Self::Ready | Self::Stalled | Self::Blocked | Self::Failed
115        )
116    }
117
118    /// The name this status is written and shown under, matching the
119    /// `snake_case` serde spelling so a log line, an error message and the
120    /// JSON a phone reads all say the same word.
121    pub fn as_str(self) -> &'static str {
122        match self {
123            Self::Prep => "prep",
124            Self::Implementing => "implementing",
125            Self::Judging => "judging",
126            Self::Deliberating => "deliberating",
127            Self::Voting => "voting",
128            Self::Reviewing => "reviewing",
129            Self::Gating => "gating",
130            Self::Landing => "landing",
131            Self::Merged => "merged",
132            Self::Ready => "ready",
133            Self::Stalled => "stalled",
134            Self::Blocked => "blocked",
135            Self::Failed => "failed",
136        }
137    }
138
139    /// Can this run be carried on from where it stopped?
140    ///
141    /// Everything except a finished run and a failed one. `execute` skips
142    /// nodes already recorded, so re-entering is cheap wherever the run
143    /// stopped, and the alternative is always a fresh competition against
144    /// work that already exists.
145    ///
146    /// - `Stalled` re-asks only the seats whose absence collapsed the panel,
147    ///   keeping the candidates that were already paid for.
148    /// - `Blocked` re-enters the review loop against a branch that is built.
149    /// - **A non-terminal status** means the run was interrupted: a parked
150    ///   run waiting for its upgrade, or one whose daemon was killed. This
151    ///   used to be excluded, which left run 4043 stuck at `reviewing` with
152    ///   the deck telling the operator it could not be resumed - the one
153    ///   state where resuming is the only sensible answer.
154    ///
155    /// `Failed` does not qualify: the graph could not complete and there is
156    /// no established point to continue from. Nor does a finished run, whose
157    /// answer is a new competition.
158    ///
159    /// Whether anything is *already* driving the run is a separate question,
160    /// answered by `daemon::is_working_on` at the callers that need it.
161    pub fn resumable(self) -> bool {
162        !matches!(self, Self::Merged | Self::Ready | Self::Failed)
163    }
164}
165
166/// One candidate implementation.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct Candidate {
169    /// Position in the implementer list.
170    pub index: usize,
171    /// Blind label as presented to judges.
172    pub label: char,
173    /// Which agent wrote it. Recorded for the stats tables, never shown to a
174    /// judge.
175    pub agent: String,
176    /// Branch, named after the label so judges can inspect it without learning
177    /// the author.
178    pub branch: String,
179    /// Worktree path.
180    pub worktree: PathBuf,
181    /// Sanitized author summary.
182    #[serde(default)]
183    pub summary: String,
184    /// `git diff --stat`.
185    #[serde(default)]
186    pub stat: String,
187    /// Files touched.
188    #[serde(default)]
189    pub files: usize,
190    /// Commits ahead of base.
191    #[serde(default)]
192    pub commits: usize,
193    /// True when the agent produced no change at all.
194    #[serde(default)]
195    pub empty: bool,
196    /// Why this candidate is not in the running.
197    #[serde(default)]
198    pub failed: Option<String>,
199    /// Wall-clock time for the implementation.
200    #[serde(default)]
201    pub duration_ms: u64,
202    /// Whether the worktree has been folded away.
203    #[serde(default)]
204    pub folded: bool,
205}
206
207impl Candidate {
208    /// Can this candidate be judged?
209    pub fn viable(&self) -> bool {
210        self.failed.is_none() && !self.empty
211    }
212}
213
214/// One judge's independent ranking.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct Judgement {
217    /// Judge seat number, 1-based.
218    pub judge: usize,
219    /// Seat key.
220    pub seat: String,
221    /// Agent occupying the seat.
222    pub agent: String,
223    /// Best-first labels.
224    #[serde(default)]
225    pub ranking: Vec<char>,
226    /// Per-label justification.
227    #[serde(default)]
228    pub reasons: BTreeMap<String, String>,
229    /// Self-reported confidence.
230    #[serde(default)]
231    pub confidence: Option<u8>,
232    /// Order the candidates were presented in, as candidate indices.
233    #[serde(default)]
234    pub order: Vec<usize>,
235    /// Why this judge has no ranking.
236    #[serde(default)]
237    pub failed: Option<String>,
238    /// Wall-clock time.
239    #[serde(default)]
240    pub duration_ms: u64,
241}
242
243/// One judge's turn in a deliberation round.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct DeliberationTurn {
246    /// Judge seat number, 1-based.
247    pub judge: usize,
248    /// Agent occupying the seat.
249    pub agent: String,
250    /// The argument, as written.
251    pub body: String,
252    /// Where the judge stood at the end of the turn.
253    #[serde(default)]
254    pub tentative: Option<char>,
255}
256
257/// A deliberation round.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct DeliberationRound {
260    /// 1-based round number.
261    pub round: usize,
262    /// Turns, in the order they were taken.
263    pub turns: Vec<DeliberationTurn>,
264}
265
266/// A final vote, collected privately.
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct VoteRecord {
269    /// Judge seat number, 1-based.
270    pub judge: usize,
271    /// Agent occupying the seat.
272    pub agent: String,
273    /// The vote.
274    #[serde(default)]
275    pub vote: Option<char>,
276    /// Why.
277    #[serde(default)]
278    pub reason: String,
279    /// Did this judge move from its initial first choice?
280    #[serde(default)]
281    pub changed: bool,
282}
283
284/// A seat that was taken out by a CLI rate limit / quota, recorded so a run
285/// whose panel collapsed does not masquerade as a healthy one.
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct QuotaLoss {
288    /// Seat key, e.g. `judge-1` or `review-2`.
289    pub seat: String,
290    /// Node that was running, e.g. `judge`, `vote`, `review`.
291    pub node: String,
292    /// When the CLI reported the limit.
293    pub at: Timestamp,
294    /// Reset hint if the CLI printed one, free text.
295    #[serde(default)]
296    pub reset: Option<String>,
297}
298
299/// The mechanical count.
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct Tally {
302    /// First-choice votes per label.
303    pub first_choice: BTreeMap<char, usize>,
304    /// Borda points from the initial rankings, used only to break a tie.
305    pub borda: BTreeMap<char, usize>,
306    /// The winning label.
307    pub winner: char,
308    /// How many judges produced a usable ranking. A panel of one is not a
309    /// consensus and must not be reported as a split.
310    #[serde(default)]
311    pub rankings: usize,
312    /// Did every judge's *initial* first choice agree?
313    pub unanimous_initial: bool,
314    /// Was deliberation run?
315    pub deliberated: bool,
316    /// Judges who moved between their initial ranking and their final vote.
317    pub changed_votes: usize,
318    /// Did the final votes agree?
319    pub unanimous_final: bool,
320    /// How the tie was broken, when it had to be.
321    #[serde(default)]
322    pub tie_break: Option<String>,
323    /// Configured judge count — the size of the full panel. `0` when no
324    /// panel was asked (see `uncontested`), not the roster size a panel that
325    /// never sat would have had.
326    #[serde(default)]
327    pub judges: usize,
328    /// Judges who actually contributed to the decision (not taken out by a
329    /// rate limit and producing a usable rank or vote).
330    #[serde(default)]
331    pub present: usize,
332    /// How many judges are required for a trustworthy verdict. Chosen as a
333    /// strict majority (`judges / 2 + 1`): a verdict backed by a minority must
334    /// never be presented as a healthy one, while a bare majority is still
335    /// real signal. A one-candidate run needs no quorum.
336    #[serde(default)]
337    pub quorum: usize,
338    /// `present >= quorum`, or no quorum was required.
339    #[serde(default)]
340    pub met_quorum: bool,
341    /// Why no panel was asked, when none was: a single viable candidate, or
342    /// a review-only run that never competed. `None` when judges actually
343    /// ranked and voted — including when too few of them survived to reach
344    /// quorum, which is a collapse and must keep reading as one.
345    #[serde(default)]
346    pub uncontested: Option<String>,
347}
348
349/// One reviewer's report in a round.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct ReviewRecord {
352    /// Reviewer seat number, 1-based.
353    pub reviewer: usize,
354    /// Agent occupying the seat.
355    pub agent: String,
356    /// Reviewer prose.
357    #[serde(default)]
358    pub summary: String,
359    /// Findings, with magi-assigned ids.
360    #[serde(default)]
361    pub findings: Vec<Finding>,
362    /// This seat's initial vote. `None` on a record predating votes, exactly
363    /// like a round that genuinely had none cast — never a stand-in for a
364    /// vote that was lost.
365    #[serde(default)]
366    pub vote: Option<ReviewVote>,
367    /// Why this reviewer produced nothing.
368    #[serde(default)]
369    pub failed: Option<String>,
370    /// Wall-clock time.
371    #[serde(default)]
372    pub duration_ms: u64,
373}
374
375/// One seat's revote during a round's reconsideration (see
376/// [`ReviewRound::reconsideration`]).
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct ReviewRevoteRecord {
379    /// Reviewer seat number, 1-based.
380    pub reviewer: usize,
381    /// Agent occupying the seat.
382    pub agent: String,
383    /// The revote. `None` when the seat did not answer.
384    #[serde(default)]
385    pub vote: Option<ReviewVote>,
386    /// Why.
387    #[serde(default)]
388    pub reason: String,
389    /// Why this seat produced no revote.
390    #[serde(default)]
391    pub failed: Option<String>,
392}
393
394/// The fixer's response to a round.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct FixRecord {
397    /// Agent that applied the fixes.
398    pub agent: String,
399    /// Finding ids acted on.
400    #[serde(default)]
401    pub addressed: Vec<String>,
402    /// Findings declined, with reasons.
403    #[serde(default)]
404    pub rejected: Vec<Rejection>,
405    /// What changed.
406    #[serde(default)]
407    pub notes: String,
408    /// Did the fix produce a commit?
409    #[serde(default)]
410    pub committed: bool,
411    /// Why the fix step produced nothing.
412    #[serde(default)]
413    pub failed: Option<String>,
414    /// Wall-clock time.
415    #[serde(default)]
416    pub duration_ms: u64,
417}
418
419/// Outcome of one shell command.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct CommandOutcome {
422    /// The command, as configured.
423    pub command: String,
424    /// Exit code, `None` on timeout or signal.
425    pub code: Option<i32>,
426    /// Tail of the combined output, for the report and the fix prompt.
427    #[serde(default)]
428    pub output_tail: String,
429    /// Wall-clock time.
430    #[serde(default)]
431    pub duration_ms: u64,
432}
433
434/// Substrings that mark a Cargo/rustc/link failure: the toolchain could not
435/// produce a binary to run at all, as opposed to producing one that ran and
436/// failed. A Windows link race against a shared `CARGO_TARGET_DIR` (see
437/// AGENTS.md, "Running magi on magi") looks exactly like a red command
438/// otherwise, and a run has concluded `Blocked` on nothing but that race.
439const BUILD_FAILURE_MARKERS: &[&str] = &[
440    "error: could not compile",
441    "error: linking with",
442    "LINK : fatal error",
443    "fatal error LNK",
444];
445
446impl CommandOutcome {
447    /// Did it pass?
448    pub fn ok(&self) -> bool {
449        self.code == Some(0)
450    }
451
452    /// Did this command fail because the code could not be built or linked,
453    /// rather than because it ran and produced a wrong result? A failure here
454    /// is not a verdict on the patch under review.
455    pub fn build_failed(&self) -> bool {
456        !self.ok()
457            && BUILD_FAILURE_MARKERS
458                .iter()
459                .any(|m| self.output_tail.contains(m))
460    }
461}
462
463/// One review + verify + fix round.
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct ReviewRound {
466    /// 1-based round number.
467    pub round: usize,
468    /// Commit the round reviewed.
469    pub head: String,
470    /// Commit actually checked by a catch-up e2e, when it differs from the
471    /// reviewed commit. Kept separate so reports never attribute a command
472    /// result to a review target the command did not inspect.
473    #[serde(default)]
474    pub verified_head: Option<String>,
475    /// Reviewer reports.
476    pub reviews: Vec<ReviewRecord>,
477    /// E2E command outcomes for this round.
478    #[serde(default)]
479    pub e2e: Vec<CommandOutcome>,
480    /// True when the first verify attempt this round could not build or
481    /// link, and `e2e` above holds a second attempt run before concluding.
482    /// A run must never be decided on a red it could not tell from an
483    /// unrelated build race.
484    #[serde(default)]
485    pub verify_retried: bool,
486    /// True when `e2e` was intentionally left empty this round: the round
487    /// already had blocking findings and another round was available, so
488    /// `graph::Runner::review_loop` sent the fixer straight at them instead
489    /// of spending a full verify run on a head it already knew would need
490    /// another fix. Distinct from an `e2e` that is simply empty because
491    /// `verify.e2e` has no commands configured — `e2e.is_empty()` alone
492    /// cannot tell those apart, and conflating them is exactly how a
493    /// deferred check would get painted green. A record written before this
494    /// field existed defaults to `false`, which is the truth for it: every
495    /// round used to run e2e unconditionally.
496    #[serde(default)]
497    pub e2e_deferred: bool,
498    /// Why `e2e` was deferred, set only when [`Self::e2e_deferred`] is true.
499    /// Carried to the fixer's prompt and shown in the report so "deferred"
500    /// never reads as silence.
501    #[serde(default)]
502    pub e2e_defer_reason: Option<String>,
503    /// Fixer response, absent when the round was already clean.
504    #[serde(default)]
505    pub fix: Option<FixRecord>,
506    /// Findings that hold the merge.
507    #[serde(default)]
508    pub blocking: usize,
509    /// Reviewer seats that answered (did not time out, crash, or return
510    /// something unparsable).
511    #[serde(default)]
512    pub answered: usize,
513    /// Reviewer seats the round expected an answer from — normally
514    /// `graph.reviewers`, but recorded per round so a config change between
515    /// runs never has to be inferred from history.
516    #[serde(default)]
517    pub expected: usize,
518    /// Round ended with no blocking findings and green verification, judged
519    /// against the seats that answered. See [`Self::incomplete`] for whether
520    /// that verdict is missing input.
521    #[serde(default)]
522    pub clean: bool,
523    /// Did the tree actually move against `base` this round, comparing the
524    /// diff after the fix to the diff the reviewers saw at the start of the
525    /// round?
526    ///
527    /// Never derived from the fixer's own `addressed`/`rejected` count: that
528    /// self-report has been caught lying twice on this workload (runs `b455`
529    /// and `6218`, both of which committed a real, substantial diff while
530    /// reporting `0 addressed`). `git` does not lie about whether the tree
531    /// changed, so this is what `graph::Runner::review_loop` counts rounds of
532    /// no progress against. Absent on a round with no fix attempt (already
533    /// clean, or the round the budget ran out on), where it defaults to
534    /// `false` and is not consulted.
535    #[serde(default)]
536    pub progressed: bool,
537    /// Did the seats' initial votes ([`ReviewRecord::vote`]) disagree?
538    #[serde(default)]
539    pub vote_split: bool,
540    /// One round of revoting, run only when `vote_split`: each seat that cast
541    /// an initial vote reads every seat's findings and votes, then revotes.
542    /// Empty when the initial votes already agreed, the same as a solo
543    /// candidate leaving `deliberation` empty.
544    #[serde(default)]
545    pub reconsideration: Vec<ReviewRevoteRecord>,
546    /// The round's verdict: the most cautious vote among the seats that
547    /// answered, using each seat's revote where reconsideration ran and its
548    /// initial vote otherwise. `None` when no seat produced a usable vote —
549    /// including every record written before votes existed, which is the
550    /// truth for those rounds, not a gap in this one.
551    #[serde(default)]
552    pub verdict: Option<ReviewVote>,
553}
554
555impl ReviewRound {
556    /// Did at least one reviewer seat fail to answer this round?
557    pub fn incomplete(&self) -> bool {
558        self.answered < self.expected
559    }
560
561    /// The honest state of this round's e2e leg.
562    ///
563    /// Never derive this from `e2e.is_empty()` alone anywhere else in the
564    /// codebase — `NotConfigured` and `Deferred` both leave it empty, and
565    /// only this method (backed by [`Self::e2e_deferred`]) tells them apart.
566    pub fn e2e_status(&self) -> E2eStatus {
567        if !self.e2e.is_empty() {
568            if self.e2e.iter().all(CommandOutcome::ok) {
569                E2eStatus::Passed
570            } else {
571                E2eStatus::Failed
572            }
573        } else if self.e2e_deferred {
574            E2eStatus::Deferred
575        } else {
576            E2eStatus::NotConfigured
577        }
578    }
579}
580
581/// The honest state of a round's e2e leg. See [`ReviewRound::e2e_status`].
582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583pub enum E2eStatus {
584    /// `verify.e2e` has no commands configured.
585    NotConfigured,
586    /// Skipped this round on purpose: blocking findings already required a
587    /// fix, so the round went straight to the fixer instead of spending a
588    /// full verify run on a head it already knew would need another pass.
589    Deferred,
590    /// Ran, and every command exited 0.
591    Passed,
592    /// Ran, and at least one command did not exit 0.
593    Failed,
594}
595
596/// What happened to the winning branch.
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct MergeOutcome {
599    /// Requested mode.
600    pub mode: MergeMode,
601    /// Did it land?
602    pub ok: bool,
603    /// Command output, or the command the operator should run.
604    #[serde(default)]
605    pub detail: String,
606}
607
608/// A seat currently mid-answer: a prompt was sent and no reply has landed yet.
609///
610/// This is not the whole story of "is it alive" — a daemon killed mid-wave
611/// leaves its last wave's entries here forever, since nothing ran to clear
612/// them. A reader must cross-check a live daemon's heartbeat
613/// (`daemon::is_working_on`) before trusting one of these as "still running"
614/// rather than "abandoned". [`RunState::clear_active`] is what keeps that
615/// leftover from surviving into the next attempt at this run: `execute` calls
616/// it before doing anything else, so a resumed run never carries a stale
617/// entry into its own report before the next wave repopulates it.
618///
619/// Deliberately carries no agent id: an implementer's agent is no secret, but
620/// a judge or reviewer seat is blind (`SeatState::key` is keyed by seat, never
621/// agent, for exactly this reason), and this struct has no way to tell which
622/// kind of seat it describes. The seat key alone — already in the map this
623/// lives under — is what every caller needs to say which seat is running.
624#[derive(Debug, Clone, Serialize, Deserialize)]
625pub struct ActiveSeat {
626    /// Node the seat is answering for, e.g. `implement`, `judge`, `review`.
627    pub node: String,
628    /// When this attempt was sent.
629    pub started_at: Timestamp,
630    /// The CLI's wall-clock budget for this attempt.
631    pub timeout_secs: u64,
632    /// 0 for the first ask, N for the Nth nudge or resume.
633    #[serde(default)]
634    pub attempt: usize,
635}
636
637impl ActiveSeat {
638    /// Seconds since this attempt was sent.
639    #[must_use]
640    pub fn elapsed_secs(&self, now: Timestamp) -> i64 {
641        (now.as_second() - self.started_at.as_second()).max(0)
642    }
643
644    /// Seconds left before this attempt's own timeout fires, floored at zero
645    /// rather than going negative once the CLI has overrun its budget.
646    #[must_use]
647    pub fn remaining_secs(&self, now: Timestamp) -> i64 {
648        (self.timeout_secs as i64 - self.elapsed_secs(now)).max(0)
649    }
650}
651
652/// A timestamped note about a node.
653#[derive(Debug, Clone, Serialize, Deserialize)]
654pub struct Event {
655    /// When.
656    pub at: Timestamp,
657    /// Node name.
658    pub node: String,
659    /// What happened.
660    pub message: String,
661}
662
663/// How far the winner's tree trailed the landing base, last time it was
664/// checked, and what came of trying to close that gap.
665///
666/// Set by `graph::Runner::sync_to_base`, which runs before the review loop and
667/// again before the gate: verifying against a tree that does not yet contain
668/// the base's tip answers "green on the commit this run branched from", not
669/// "green on what is about to land", and a merge on that answer can revert
670/// whatever landed elsewhere while the run was thinking.
671#[derive(Debug, Clone, Serialize, Deserialize)]
672pub struct BaseSync {
673    /// `<remote>/<base>` tip the tree was last checked against.
674    pub tip: String,
675    /// Commits `tip` was ahead of the tree at that check, before any rebase
676    /// this round tried to close the gap. Zero means the tree already
677    /// contained `tip`.
678    pub behind: usize,
679    /// Rebase attempts spent so far this run, bounded by
680    /// `graph::BASE_SYNC_ROUNDS`.
681    pub attempts: usize,
682    /// What git said, if the most recent rebase attempt conflicted or could
683    /// not be pushed. `Some` here is what makes a `Blocked` run read as
684    /// "stopped on the base, not on review or the gate" - the rebase is not
685    /// retried again while this is set; a person has to look.
686    #[serde(default)]
687    pub conflict: Option<String>,
688}
689
690/// What the land loop saw last time it looked at the pull request.
691///
692/// Strings for `state` and `checks` on purpose: they are `gh`'s vocabulary, and
693/// pinning them into an enum here would mean a new GitHub check conclusion
694/// turns a readable status into a deserialisation error on a run someone is
695/// trying to look at.
696#[derive(Debug, Clone, Serialize, Deserialize)]
697pub struct PrRecord {
698    /// Pull request url.
699    pub url: String,
700    /// Pull request number.
701    pub number: u64,
702    /// `open`, `merged` or `closed`.
703    pub state: String,
704    /// `pending`, `green`, `red` or `unknown`.
705    pub checks: String,
706    /// Land round, 1-based, or 0 before the first fix.
707    pub round: usize,
708    /// Land round budget.
709    pub rounds: usize,
710}
711
712/// The whole run.
713#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct RunState {
715    /// On-disk format version.
716    pub schema: u32,
717    /// Run id, e.g. `20260830-153012-a1b2`.
718    pub id: String,
719    /// Repository the run operates on.
720    pub repo: PathBuf,
721    /// Branch the run started from.
722    pub base_branch: String,
723    /// Commit the run started from.
724    pub base_commit: String,
725    /// The task, verbatim.
726    pub instruction: String,
727    /// When the run was created.
728    pub created_at: Timestamp,
729    /// Last state flush.
730    pub updated_at: Timestamp,
731    /// Current status.
732    pub status: RunStatus,
733    /// Seed for labels and session ids.
734    pub seed: u64,
735    /// Config snapshot, so a resumed run behaves like the original.
736    pub config: Config,
737    /// Did this run take a reference on `extensions.worktreeConfig` being on
738    /// (see [`crate::git::acquire_worktree_config`])? If so, cleanup releases
739    /// it - which only actually turns the setting back off once every other
740    /// run sharing this repository has released its own reference too.
741    #[serde(default)]
742    pub enabled_worktree_config: bool,
743    /// Candidates.
744    #[serde(default)]
745    pub candidates: Vec<Candidate>,
746    /// Initial blind rankings.
747    #[serde(default)]
748    pub judgements: Vec<Judgement>,
749    /// `judge` decided a solo candidate needs no panel and only logged it.
750    ///
751    /// `judgements` stays empty in that case — nothing to distinguish from
752    /// "not yet judged" — so this is the record that makes the skip
753    /// idempotent: without it, every reentry re-ran `judge`, re-logged the
754    /// same event, and rewrote `status` to `Judging` over whatever a later
755    /// node had already concluded.
756    #[serde(default)]
757    pub judge_skipped: bool,
758    /// Deliberation, if it happened.
759    #[serde(default)]
760    pub deliberation: Vec<DeliberationRound>,
761    /// Private final votes.
762    #[serde(default)]
763    pub votes: Vec<VoteRecord>,
764    /// The count.
765    #[serde(default)]
766    pub tally: Option<Tally>,
767    /// Review rounds.
768    #[serde(default)]
769    pub reviews: Vec<ReviewRound>,
770    /// Final gate.
771    #[serde(default)]
772    pub gate: Vec<CommandOutcome>,
773    /// Merge outcome.
774    #[serde(default)]
775    pub merge: Option<MergeOutcome>,
776    /// Vendor tokens seen in judged material.
777    #[serde(default)]
778    pub leaks: Vec<Leak>,
779    /// Seats lost to a CLI rate limit / quota, in the order they hit.
780    #[serde(default)]
781    pub quota: Vec<QuotaLoss>,
782    /// Parked at a node boundary, waiting to be resumed.
783    ///
784    /// A run that is neither finished nor being worked on is otherwise
785    /// indistinguishable from one whose daemon was killed, and the two want
786    /// opposite things from an operator: the first is expected to be resumed,
787    /// the second is a leftover. Cleared by the resume that carries it on.
788    #[serde(default)]
789    pub parked: bool,
790    /// Per-seat conversation state.
791    #[serde(default)]
792    pub seats: BTreeMap<String, SeatState>,
793    /// Seats currently mid-answer, keyed by seat.
794    ///
795    /// An entry exists from the moment a prompt is sent until a reply (of any
796    /// kind — success, failure, quota, drop) comes back, so its keys are
797    /// exactly "who hasn't answered yet" for whichever node populated it. See
798    /// [`ActiveSeat`] for why a reader still has to check a live daemon
799    /// before trusting one of these as "running" rather than "abandoned".
800    #[serde(default)]
801    pub active: BTreeMap<String, ActiveSeat>,
802    /// Last observation of the winner's pull request, when a land loop ran.
803    ///
804    /// Persisted rather than derived from the event log because the phone asks
805    /// two questions about a run that has opened a PR - how are its checks and
806    /// which round is it on - and parsing prose out of events to answer them
807    /// would break the first time an event message was reworded.
808    #[serde(default)]
809    pub pr: Option<PrRecord>,
810    /// The last look at how far the winner's tree trailed the landing base,
811    /// and the rebase(s) tried to close that gap. `None` until the tree has a
812    /// winner to check.
813    #[serde(default)]
814    pub base_sync: Option<BaseSync>,
815    /// Node log.
816    #[serde(default)]
817    pub events: Vec<Event>,
818}
819
820impl RunState {
821    /// A fresh run.
822    pub fn new(
823        repo: PathBuf,
824        base_branch: String,
825        base_commit: String,
826        instruction: String,
827        config: Config,
828    ) -> Self {
829        let now = Timestamp::now();
830        let seed = config.blind.seed.unwrap_or_else(crate::rng::entropy);
831        Self {
832            schema: SCHEMA,
833            id: new_id(),
834            repo,
835            base_branch,
836            base_commit,
837            instruction,
838            created_at: now,
839            updated_at: now,
840            status: RunStatus::Prep,
841            seed,
842            config,
843            enabled_worktree_config: false,
844            candidates: Vec::new(),
845            judgements: Vec::new(),
846            judge_skipped: false,
847            deliberation: Vec::new(),
848            votes: Vec::new(),
849            tally: None,
850            reviews: Vec::new(),
851            gate: Vec::new(),
852            merge: None,
853            leaks: Vec::new(),
854            quota: Vec::new(),
855            parked: false,
856            seats: BTreeMap::new(),
857            active: BTreeMap::new(),
858            pr: None,
859            base_sync: None,
860            events: Vec::new(),
861        }
862    }
863
864    /// Directory holding this run's state and artifacts.
865    pub fn dir(&self) -> PathBuf {
866        run_dir(&self.id)
867    }
868
869    /// Short form used in branch names and reports.
870    pub fn short(&self) -> &str {
871        short_of(&self.id)
872    }
873
874    /// Branch name for a label.
875    pub fn branch_for(&self, label: char) -> String {
876        format!("magi/{}/{}", self.short(), label)
877    }
878
879    /// Root of this run's worktrees.
880    pub fn worktree_root(&self) -> PathBuf {
881        self.config
882            .graph
883            .worktree_root
884            .clone()
885            .unwrap_or_else(default_worktree_root)
886            .join(self.short())
887    }
888
889    /// Note something in the run log and on the tracing stream.
890    pub fn event(&mut self, node: &str, message: impl Into<String>) {
891        let message = message.into();
892        tracing::info!(node, "{message}");
893        self.events.push(Event {
894            at: Timestamp::now(),
895            node: node.to_owned(),
896            message,
897        });
898    }
899
900    /// Record that `seat` was just sent a prompt for `node`, with the given
901    /// wall-clock budget. `attempt` is 0 for the first ask and N for the Nth
902    /// nudge or resume, purely for display — it does not change how the seat
903    /// is treated.
904    pub fn seat_started(
905        &mut self,
906        node: &str,
907        seat: &str,
908        timeout: std::time::Duration,
909        attempt: usize,
910    ) {
911        self.active.insert(
912            seat.to_owned(),
913            ActiveSeat {
914                node: node.to_owned(),
915                started_at: Timestamp::now(),
916                timeout_secs: timeout.as_secs(),
917                attempt,
918            },
919        );
920    }
921
922    /// Record that `seat` has answered, whatever the answer was.
923    pub fn seat_finished(&mut self, seat: &str) {
924        self.active.remove(seat);
925    }
926
927    /// Drop every seat this state still lists as answering, reporting whether
928    /// anything was dropped.
929    ///
930    /// Called first thing in `execute`, on every entry — fresh, resumed, or
931    /// recovering a stall — because an entry here only means something while
932    /// the process that wrote it is still asking that seat something. A
933    /// process killed mid-wave leaves its last batch of seats here with
934    /// nobody left to clear them, and the next process to touch this run must
935    /// not let that leftover read as "still going" before it has asked
936    /// anyone anything.
937    pub fn clear_active(&mut self) -> bool {
938        if self.active.is_empty() {
939            return false;
940        }
941        self.active.clear();
942        true
943    }
944
945    /// Flush to `run.json`, atomically.
946    pub fn save(&mut self) -> Result<()> {
947        self.updated_at = Timestamp::now();
948        let dir = self.dir();
949        std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
950        let body = serde_json::to_string_pretty(self).context("serialize run state")?;
951        let tmp = dir.join("run.json.tmp");
952        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
953        std::fs::rename(&tmp, dir.join("run.json")).with_context(|| "replace run.json")?;
954        Ok(())
955    }
956
957    /// Load a run by id or unambiguous id prefix.
958    pub fn load(id: &str) -> Result<Self> {
959        let resolved = resolve_id(id)?;
960        let path = run_dir(&resolved).join("run.json");
961        let body =
962            std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
963        let state: Self =
964            serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
965        migrate_schema(state)
966    }
967}
968
969fn migrate_schema(mut state: RunState) -> Result<RunState> {
970    // Schema 5 predates deferred e2e. Its empty e2e lists therefore mean
971    // "not configured", never "deferred"; serde's field defaults retain
972    // exactly that representation while this migration permits resumes.
973    if state.schema == 5 {
974        state.schema = SCHEMA;
975    }
976    if state.schema != SCHEMA {
977        bail!(
978            "run {} was written by a different magi (schema {}, this build \
979                 speaks {SCHEMA})",
980            state.id,
981            state.schema
982        );
983    }
984    Ok(state)
985}
986
987impl RunState {
988    /// The winning candidate, once the tally has run.
989    pub fn winner(&self) -> Option<&Candidate> {
990        let label = self.tally.as_ref()?.winner;
991        self.candidates.iter().find(|c| c.label == label)
992    }
993
994    /// Candidates eligible for judging.
995    pub fn viable(&self) -> Vec<&Candidate> {
996        self.candidates.iter().filter(|c| c.viable()).collect()
997    }
998
999    /// Findings still open when the review loop stopped trying: the last
1000    /// round's, exactly when that round was not clean. Empty on a run that
1001    /// never reviewed, or whose last round was clean.
1002    ///
1003    /// This is the last round's findings regardless of what the fixer claims
1004    /// to have addressed in that same round: a round that stopped the loop
1005    /// (round budget spent, or no tree progress for
1006    /// [`crate::graph::STAGNANT_LIMIT`] rounds) never had a *following* round
1007    /// to confirm the fix actually landed, and the self-reported adoption
1008    /// count is not trusted for that judgement either — see
1009    /// [`ReviewRound::progressed`].
1010    pub fn open_findings(&self) -> Vec<&Finding> {
1011        match self.reviews.last() {
1012            Some(r) if !r.clean => r
1013                .reviews
1014                .iter()
1015                .flat_map(|rec| rec.findings.iter())
1016                .collect(),
1017            _ => Vec::new(),
1018        }
1019    }
1020
1021    /// Did this run reach a mergeable status (`Ready` or `Merged`) with
1022    /// review findings still open?
1023    ///
1024    /// That combination is the point of the review hand-off: the review
1025    /// round budget (or an unproductive round, see [`ReviewRound::progressed`])
1026    /// was spent while gate and e2e stayed green, so the run was handed off
1027    /// rather than blocked — but the findings did not disappear, and whoever
1028    /// reads the result should be told they are still there.
1029    pub fn handed_off_with_open_findings(&self) -> bool {
1030        matches!(self.status, RunStatus::Ready | RunStatus::Merged)
1031            && self.reviews.last().is_some_and(|r| !r.clean)
1032    }
1033
1034    /// Local-time creation stamp for reports.
1035    pub fn created_local(&self) -> String {
1036        self.created_at
1037            .to_zoned(jiff::tz::TimeZone::system())
1038            .strftime("%Y-%m-%d %H:%M:%S")
1039            .to_string()
1040    }
1041
1042    /// Assert that this run is safe to delete.
1043    ///
1044    /// Refuses a run a live daemon is working on, and refuses any run whose
1045    /// candidate worktrees and branches have not been folded away with `magi
1046    /// fold`. The fold requirement is the real protection: it is what makes
1047    /// "delete" mean "remove a record" rather than "throw away a worktree
1048    /// somebody may still be editing".
1049    ///
1050    /// `in_flight` has to come from the caller, because a run's own status
1051    /// cannot answer the question. A daemon killed mid-run leaves its status at
1052    /// `implementing` forever, and a guard that trusted that would make every
1053    /// interrupted run permanently undeletable - the operator's only recourse
1054    /// being to edit `run.json` by hand, which is exactly the sort of thing
1055    /// this command exists to avoid. The queue already treats an orphaned
1056    /// `.lock` from a `SIGKILL`ed daemon the same way; this is that rule for
1057    /// runs.
1058    pub fn ensure_can_delete(&self, in_flight: bool) -> Result<()> {
1059        if in_flight {
1060            bail!(
1061                "run {} is being worked on by a live daemon right now",
1062                self.short()
1063            );
1064        }
1065        if self.candidates.iter().any(|c| !c.folded) {
1066            bail!(
1067                "run {} has unfolded candidates; fold first with `magi fold`",
1068                self.short()
1069            );
1070        }
1071        Ok(())
1072    }
1073}
1074
1075/// The short form of a run id: the trailing block after the last `-`.
1076///
1077/// A free function as well as [`RunState::short`], because callers that have
1078/// only an id - an error message, a daemon status, a route handler - were
1079/// otherwise reimplementing the split, and two spellings of "short id" is one
1080/// rename away from branch names that no longer match their run.
1081pub fn short_of(id: &str) -> &str {
1082    id.split('-').next_back().unwrap_or(id)
1083}
1084
1085/// Where magi keeps its runs.
1086///
1087/// `MAGI_HOME` overrides the default, and [`set_home`] overrides both — which
1088/// is what lets the integration tests drive a whole graph without writing into
1089/// the operator's real history.
1090///
1091/// In a unit test build (`cfg(test)`), falling through to the real
1092/// `<data_local>/magi` is not a fallback worth having: it is exactly how
1093/// three broken fixture runs ended up in the operator's actual history and
1094/// were counted as `unreadable` by the deck. A test that reaches this point
1095/// forgot to call [`set_home`] (or set `MAGI_HOME`) - that is a bug in the
1096/// test, not a case to serve, so it panics instead of writing anywhere.
1097pub fn home() -> PathBuf {
1098    resolve_home(HOME.get().cloned(), std::env::var_os("MAGI_HOME"))
1099}
1100
1101/// The decision `home` makes, taking its two overrides as plain values
1102/// instead of reading the `OnceLock` and the environment itself.
1103///
1104/// Pulled out so the `cfg(test)` panic is asserted directly against a
1105/// `None, None` input, rather than racing every other unit test in the
1106/// binary for who touches the process-global `HOME` first.
1107fn resolve_home(pinned: Option<PathBuf>, magi_home_env: Option<std::ffi::OsString>) -> PathBuf {
1108    if let Some(dir) = pinned {
1109        return dir;
1110    }
1111    if let Some(dir) = magi_home_env {
1112        return PathBuf::from(dir);
1113    }
1114    #[cfg(test)]
1115    {
1116        panic!(
1117            "run::home() was reached in a test without run::set_home() or \
1118             MAGI_HOME; this would write into the operator's real \
1119             <data_local>/magi. Call `run::set_home(temp_dir)` before any \
1120             code path that touches a RunState."
1121        );
1122    }
1123    #[cfg(not(test))]
1124    {
1125        dirs::data_local_dir()
1126            .unwrap_or_else(|| PathBuf::from("."))
1127            .join("magi")
1128    }
1129}
1130
1131/// Pin the run home for this process. The first call wins.
1132pub fn set_home(dir: PathBuf) {
1133    let _ = HOME.set(dir);
1134}
1135
1136static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
1137
1138/// `<home>/runs`.
1139pub fn runs_root() -> PathBuf {
1140    home().join("runs")
1141}
1142
1143/// The worktree root a run uses when the config sets none: `~/wt/magi`.
1144///
1145/// One definition of the default, so the folder the janitor folds and the
1146/// folder the health view sizes cannot drift apart: a run with no configured
1147/// [`crate::config::Graph::worktree_root`] lays its worktrees exactly here.
1148pub fn default_worktree_root() -> PathBuf {
1149    dirs::home_dir()
1150        .unwrap_or_else(|| PathBuf::from("."))
1151        .join("wt")
1152        .join("magi")
1153}
1154
1155/// Directory for one run id.
1156pub fn run_dir(id: &str) -> PathBuf {
1157    runs_root().join(id)
1158}
1159
1160/// Every run id on disk, newest first.
1161///
1162/// A directory is a run because of its **name**, not because it holds a
1163/// readable `run.json`. A run whose very first save lost the machine's last
1164/// free bytes leaves `<id>/run.json.tmp` and nothing else, and filtering on
1165/// `run.json` made that run invisible everywhere: not in `magi list`, not in
1166/// `runs_unreadable`, not on the phone, so nothing could report it and no
1167/// route could clear it. `88c0` sat like that for two days. Unreadable is
1168/// counted, never hidden - the readers already say why each one cannot be
1169/// read, and `fold_unreadable` is how a record like this leaves.
1170pub fn list_ids() -> Vec<String> {
1171    let mut ids: Vec<String> = std::fs::read_dir(runs_root())
1172        .into_iter()
1173        .flatten()
1174        .flatten()
1175        .filter(|e| e.path().is_dir())
1176        .map(|e| e.file_name().to_string_lossy().into_owned())
1177        .filter(|name| is_run_id(name))
1178        .collect();
1179    // Ids start with a sortable timestamp.
1180    ids.sort_unstable_by(|a, b| b.cmp(a));
1181    ids
1182}
1183
1184/// Does `name` have the shape [`new_id`] mints: `YYYYMMDD-HHMMSS-xxxx`?
1185///
1186/// The test for "this directory is a run", so a stray folder under
1187/// `<home>/runs` is not reported as a broken run.
1188///
1189/// The tag is checked for length and for being alphanumeric, not for being
1190/// hex: real ids are hex, but fixtures across this crate name runs
1191/// `...-dead` / `...-gone` / `...-once`, and a predicate that disowned those
1192/// would be asserting the fixtures' spelling rather than the shape.
1193pub fn is_run_id(name: &str) -> bool {
1194    let mut parts = name.split('-');
1195    let (Some(day), Some(time), Some(tag), None) =
1196        (parts.next(), parts.next(), parts.next(), parts.next())
1197    else {
1198        return false;
1199    };
1200    day.len() == 8
1201        && day.bytes().all(|b| b.is_ascii_digit())
1202        && time.len() == 6
1203        && time.bytes().all(|b| b.is_ascii_digit())
1204        && tag.len() == 4
1205        && tag.bytes().all(|b| b.is_ascii_alphanumeric())
1206}
1207
1208/// Expand an id prefix to exactly one run id.
1209pub fn resolve_id(prefix: &str) -> Result<String> {
1210    // A whole id names its directory, readable state or not: the run whose
1211    // `run.json` never landed still has to be reachable by `magi show` and
1212    // by the fold route, which is the only way its record ever leaves.
1213    if is_run_id(prefix) && run_dir(prefix).is_dir() {
1214        return Ok(prefix.to_owned());
1215    }
1216    let hits: Vec<String> = list_ids()
1217        .into_iter()
1218        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
1219        .collect();
1220    match hits.len() {
1221        1 => Ok(hits.into_iter().next().expect("exactly one hit")),
1222        0 => bail!("no run matches `{prefix}`"),
1223        _ => bail!(
1224            "`{prefix}` matches {} runs: {}",
1225            hits.len(),
1226            hits.join(", ")
1227        ),
1228    }
1229}
1230
1231/// The most recent run, if any.
1232pub fn latest_id() -> Option<String> {
1233    list_ids().into_iter().next()
1234}
1235
1236/// `YYYYMMDD-HHMMSS-xxxx`, sortable and short enough for a branch name.
1237///
1238/// The four hex digits are fresh entropy, **not** `blind.seed`. They were the
1239/// seed, and a pinned seed then made the whole id a function of the second it
1240/// started in: two runs a second apart were distinguishable, two in the same
1241/// second were not. Everything keyed on the id collided with them - the run
1242/// directory, `artifacts/`, and the candidate worktrees under
1243/// `wt/magi/<short>/`.
1244///
1245/// `tests/common` pins the seed on purpose, so its integration tests all share
1246/// one suffix. On Windows the suite is slow enough that the seconds differ and
1247/// nothing showed; on Linux `graph_dropped_stream`'s three tests run inside
1248/// 16s, so two of them shared a run directory and the second read an artifact
1249/// the first had written (`impl-B-resume.out`) - a failure that looked like the
1250/// resume logic misbehaving and was really two runs in one directory.
1251///
1252/// A seed exists to make the *blind* decisions reproducible: label assignment
1253/// and per-judge presentation order. It was never meant to name the run, and
1254/// `RunState::seed` still carries it for what it is for.
1255fn new_id() -> String {
1256    let stamp = Zoned::now().strftime("%Y%m%d-%H%M%S");
1257    let entropy = crate::rng::entropy();
1258    format!("{stamp}-{:04x}", (entropy ^ (entropy >> 32)) & 0xffff)
1259}
1260
1261/// Keep the last `max` bytes of `text`, on a line boundary.
1262pub fn tail(text: &str, max: usize) -> String {
1263    if text.len() <= max {
1264        return text.to_owned();
1265    }
1266    let mut cut = text.len() - max;
1267    while cut < text.len() && !text.is_char_boundary(cut) {
1268        cut += 1;
1269    }
1270    let slice = &text[cut..];
1271    let start = slice.find('\n').map_or(0, |i| i + 1);
1272    format!(
1273        "[... {} earlier bytes omitted ...]\n{}",
1274        cut,
1275        &slice[start..]
1276    )
1277}
1278
1279/// Path of a run artifact.
1280pub fn artifact_path(run: &RunState, name: &str) -> PathBuf {
1281    run.dir().join("artifacts").join(name)
1282}
1283
1284/// Write an artifact, creating the directory if needed.
1285pub fn write_artifact(run: &RunState, name: &str, body: &str) -> Result<PathBuf> {
1286    let path = artifact_path(run, name);
1287    if let Some(parent) = path.parent() {
1288        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
1289    }
1290    std::fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
1291    Ok(path)
1292}
1293
1294/// Read an artifact back, e.g. a stored patch on resume.
1295pub fn read_artifact(run: &RunState, name: &str) -> Option<String> {
1296    std::fs::read_to_string(artifact_path(run, name)).ok()
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301    use super::*;
1302
1303    fn state() -> RunState {
1304        RunState::new(
1305            PathBuf::from("/repo"),
1306            "main".to_owned(),
1307            "abc1234def".to_owned(),
1308            "add retries".to_owned(),
1309            Config::default(),
1310        )
1311    }
1312
1313    #[test]
1314    fn resolve_home_prefers_the_pin_then_the_env_var() {
1315        let pinned = PathBuf::from("/pinned");
1316        assert_eq!(
1317            resolve_home(Some(pinned.clone()), Some("/env".into())),
1318            pinned,
1319            "a pin wins even over MAGI_HOME"
1320        );
1321        assert_eq!(
1322            resolve_home(None, Some("/env".into())),
1323            PathBuf::from("/env")
1324        );
1325    }
1326
1327    #[test]
1328    #[should_panic(expected = "run::set_home()")]
1329    fn resolve_home_refuses_to_fall_back_to_the_operators_real_home() {
1330        // Neither override present is exactly the state a test reaches by
1331        // forgetting `set_home`/`MAGI_HOME` - the accident that put three
1332        // broken fixture runs into the operator's real history. Asserted
1333        // against the pure decision directly, not `home()` itself, because
1334        // `HOME` is a process-wide `OnceLock` another test may have already
1335        // set - this must not depend on test execution order.
1336        resolve_home(None, None);
1337    }
1338
1339    #[test]
1340    fn a_run_is_named_by_shape_so_a_state_less_directory_is_still_a_run() {
1341        // The shape `new_id` mints. A directory answering to it is a run even
1342        // with no readable `run.json`: that is how a save that ran out of
1343        // disk stays visible instead of vanishing from every listing.
1344        assert!(is_run_id(&new_id()));
1345        assert!(is_run_id("20260904-014540-88c0"));
1346        // Not runs: a stray folder, a truncated id, a non-hex tag, and an id
1347        // with an extra segment (a worktree label, say).
1348        assert!(!is_run_id("scratch"));
1349        assert!(!is_run_id("20260904-014540"));
1350        assert!(!is_run_id("20260904-014540-88c0f"));
1351        assert!(!is_run_id("2026090x-014540-88c0"));
1352        assert!(!is_run_id("20260904-014540-88c0-A"));
1353    }
1354
1355    #[test]
1356    fn ids_are_sortable_and_short_suffixed() {
1357        let s = state();
1358        let parts: Vec<&str> = s.id.split('-').collect();
1359        assert_eq!(parts.len(), 3);
1360        assert_eq!(parts[0].len(), 8);
1361        assert_eq!(parts[1].len(), 6);
1362        assert_eq!(parts[2].len(), 4);
1363        assert_eq!(s.short(), parts[2]);
1364    }
1365
1366    #[test]
1367    fn branch_names_carry_the_label_not_the_author() {
1368        let s = state();
1369        let b = s.branch_for('B');
1370        assert_eq!(b, format!("magi/{}/B", s.short()));
1371        assert!(!b.contains("claude"));
1372    }
1373
1374    /// A pinned seed reproduces the blind decisions. It must **not** reproduce
1375    /// the run's identity.
1376    ///
1377    /// `assert_eq!(a.short(), b.short())` used to stand where the last
1378    /// assertion is now, and it was pinning the defect: with the id's suffix
1379    /// derived from the seed, two runs started in the same second were the
1380    /// same run as far as the filesystem was concerned - one directory, one
1381    /// `artifacts/`, one set of candidate worktrees. `tests/common` pins a
1382    /// seed for every integration test, so on Linux, where the suite is fast,
1383    /// two tests in `graph_dropped_stream` shared a directory and one read the
1384    /// other's artifact.
1385    #[test]
1386    fn a_pinned_seed_is_reproducible_but_never_the_run_id() {
1387        let mut cfg = Config::default();
1388        cfg.blind.seed = Some(1234);
1389        let a = RunState::new(
1390            PathBuf::from("/r"),
1391            "main".to_owned(),
1392            "c".to_owned(),
1393            "t".to_owned(),
1394            cfg.clone(),
1395        );
1396        let b = RunState::new(
1397            PathBuf::from("/r"),
1398            "main".to_owned(),
1399            "c".to_owned(),
1400            "t".to_owned(),
1401            cfg,
1402        );
1403        // What the seed is for: the same shuffles, run after run.
1404        assert_eq!(a.seed, 1234);
1405        assert_eq!(a.seed, b.seed);
1406        // What it is not for. Two runs are two runs, in the same second or
1407        // not, and everything keyed on the id depends on that.
1408        assert_ne!(
1409            a.id, b.id,
1410            "two runs sharing an id share a directory, artifacts and worktrees"
1411        );
1412    }
1413
1414    #[test]
1415    fn status_terminality() {
1416        assert!(RunStatus::Merged.done());
1417        assert!(RunStatus::Blocked.done());
1418        assert!(!RunStatus::Reviewing.done());
1419    }
1420
1421    #[test]
1422    fn candidate_viability_excludes_empty_and_failed() {
1423        let mut c = Candidate {
1424            index: 0,
1425            label: 'A',
1426            agent: "a".to_owned(),
1427            branch: "b".to_owned(),
1428            worktree: PathBuf::from("/w"),
1429            summary: String::new(),
1430            stat: String::new(),
1431            files: 1,
1432            commits: 1,
1433            empty: false,
1434            failed: None,
1435            duration_ms: 0,
1436            folded: false,
1437        };
1438        assert!(c.viable());
1439        c.empty = true;
1440        assert!(!c.viable());
1441        c.empty = false;
1442        c.failed = Some("timeout".to_owned());
1443        assert!(!c.viable());
1444    }
1445
1446    #[test]
1447    fn build_failure_is_distinguished_from_a_failing_test() {
1448        let link_race = CommandOutcome {
1449            command: "cargo test".to_owned(),
1450            code: Some(1),
1451            output_tail: "LINK : fatal error LNK1104: cannot open file \
1452                          'graph_dirty_tree-71d4dc8e.exe'\n\
1453                          error: could not compile `magi-cli` (test \"graph_dirty_tree\")"
1454                .to_owned(),
1455            duration_ms: 500,
1456        };
1457        assert!(!link_race.ok());
1458        assert!(link_race.build_failed());
1459
1460        let failing_test = CommandOutcome {
1461            command: "cargo test".to_owned(),
1462            code: Some(101),
1463            output_tail: "thread 'it_works' panicked at 'assertion failed'".to_owned(),
1464            duration_ms: 500,
1465        };
1466        assert!(!failing_test.ok());
1467        assert!(
1468            !failing_test.build_failed(),
1469            "a real test failure must not be classed as a build failure"
1470        );
1471
1472        let passing = CommandOutcome {
1473            command: "cargo test".to_owned(),
1474            code: Some(0),
1475            output_tail: String::new(),
1476            duration_ms: 500,
1477        };
1478        assert!(passing.ok());
1479        assert!(!passing.build_failed());
1480    }
1481
1482    #[test]
1483    fn tail_keeps_the_end_on_a_line_boundary() {
1484        let text = (0..100).map(|i| format!("line {i}\n")).collect::<String>();
1485        let t = tail(&text, 40);
1486        assert!(t.starts_with("[..."));
1487        assert!(t.ends_with("line 99\n"));
1488        assert!(t.len() < 120);
1489        assert_eq!(tail("short", 40), "short");
1490    }
1491
1492    #[test]
1493    fn tail_survives_multibyte_cuts() {
1494        let text = "あ".repeat(50);
1495        let t = tail(&text, 10);
1496        assert!(t.contains("earlier bytes omitted"));
1497        assert!(t.ends_with('あ'));
1498    }
1499
1500    fn finding(id: &str, severity: crate::verdict::Severity) -> crate::verdict::Finding {
1501        crate::verdict::Finding {
1502            id: id.to_owned(),
1503            severity,
1504            file: None,
1505            line: None,
1506            title: "x".to_owned(),
1507            detail: String::new(),
1508        }
1509    }
1510
1511    fn round(clean: bool, findings: Vec<crate::verdict::Finding>) -> ReviewRound {
1512        ReviewRound {
1513            round: 1,
1514            head: "h".to_owned(),
1515            verified_head: None,
1516            reviews: vec![ReviewRecord {
1517                reviewer: 1,
1518                agent: "a".to_owned(),
1519                summary: String::new(),
1520                findings,
1521                vote: None,
1522                failed: None,
1523                duration_ms: 0,
1524            }],
1525            e2e: Vec::new(),
1526            verify_retried: false,
1527            e2e_deferred: false,
1528            e2e_defer_reason: None,
1529            fix: None,
1530            blocking: 0,
1531            answered: 1,
1532            expected: 1,
1533            clean,
1534            progressed: false,
1535            vote_split: false,
1536            reconsideration: Vec::new(),
1537            verdict: None,
1538        }
1539    }
1540
1541    #[test]
1542    fn e2e_status_tells_deferred_apart_from_not_configured() {
1543        let mut r = round(false, Vec::new());
1544        assert_eq!(r.e2e_status(), E2eStatus::NotConfigured);
1545
1546        r.e2e_deferred = true;
1547        assert_eq!(
1548            r.e2e_status(),
1549            E2eStatus::Deferred,
1550            "an empty e2e must not read as unconfigured once it was deferred on purpose"
1551        );
1552
1553        r.e2e = vec![CommandOutcome {
1554            command: "test".to_owned(),
1555            code: Some(0),
1556            output_tail: String::new(),
1557            duration_ms: 0,
1558        }];
1559        assert_eq!(
1560            r.e2e_status(),
1561            E2eStatus::Passed,
1562            "a round with real outcomes is never read as deferred, even if the flag is still set"
1563        );
1564    }
1565
1566    #[test]
1567    fn e2e_status_reports_a_real_failure_as_failed_not_deferred() {
1568        let mut r = round(false, Vec::new());
1569        r.e2e = vec![CommandOutcome {
1570            command: "test".to_owned(),
1571            code: Some(1),
1572            output_tail: "boom".to_owned(),
1573            duration_ms: 0,
1574        }];
1575        assert_eq!(r.e2e_status(), E2eStatus::Failed);
1576    }
1577
1578    #[test]
1579    fn open_findings_is_empty_when_the_last_round_was_clean() {
1580        let mut s = state();
1581        s.reviews = vec![round(
1582            true,
1583            vec![finding("R1-1-1", crate::verdict::Severity::Minor)],
1584        )];
1585        assert!(s.open_findings().is_empty());
1586    }
1587
1588    #[test]
1589    fn open_findings_reads_the_last_non_clean_round() {
1590        let mut s = state();
1591        s.reviews = vec![round(
1592            false,
1593            vec![finding("R1-1-1", crate::verdict::Severity::Major)],
1594        )];
1595        let open = s.open_findings();
1596        assert_eq!(open.len(), 1);
1597        assert_eq!(open[0].id, "R1-1-1");
1598    }
1599
1600    #[test]
1601    fn handed_off_with_open_findings_needs_a_mergeable_status_and_an_open_round() {
1602        let mut s = state();
1603        s.reviews = vec![round(
1604            false,
1605            vec![finding("R1-1-1", crate::verdict::Severity::Major)],
1606        )];
1607
1608        s.status = RunStatus::Blocked;
1609        assert!(
1610            !s.handed_off_with_open_findings(),
1611            "a blocked run is not a hand-off"
1612        );
1613
1614        s.status = RunStatus::Ready;
1615        assert!(s.handed_off_with_open_findings());
1616
1617        s.reviews = vec![round(true, Vec::new())];
1618        assert!(
1619            !s.handed_off_with_open_findings(),
1620            "a clean last round has nothing to hand off"
1621        );
1622    }
1623
1624    #[test]
1625    fn state_round_trips_through_json() {
1626        let s = state();
1627        let body = serde_json::to_string(&s).unwrap();
1628        let back: RunState = serde_json::from_str(&body).unwrap();
1629        assert_eq!(back.id, s.id);
1630        assert_eq!(back.instruction, "add retries");
1631        assert_eq!(back.status, RunStatus::Prep);
1632    }
1633
1634    #[test]
1635    fn a_round_recorded_before_e2e_deferral_existed_still_loads() {
1636        // Exactly the shape a pre-existing `run.json` has for a round: no
1637        // `e2e_deferred`, no `e2e_defer_reason`. Every round used to run e2e
1638        // unconditionally, so the honest reading of an old record's silence
1639        // on this is "it was not deferred" — `false`/`None`, not a load
1640        // failure and not a schema bump (see the `SCHEMA` doc comment: a
1641        // purely additive field whose absence has one unambiguous meaning
1642        // does not need one).
1643        let body = r#"{
1644            "round": 1,
1645            "head": "deadbeef",
1646            "reviews": [],
1647            "e2e": [],
1648            "verify_retried": false,
1649            "fix": null,
1650            "blocking": 0,
1651            "answered": 1,
1652            "expected": 1,
1653            "clean": true
1654        }"#;
1655        let r: ReviewRound = serde_json::from_str(body).expect("an old-shaped round must load");
1656        assert!(!r.e2e_deferred);
1657        assert!(r.e2e_defer_reason.is_none());
1658        assert_eq!(r.e2e_status(), E2eStatus::NotConfigured);
1659    }
1660
1661    #[test]
1662    fn schema_five_state_migrates_old_empty_e2e_and_legacy_verify_budget() {
1663        let mut value = serde_json::to_value(state()).expect("serialize state");
1664        let object = value.as_object_mut().expect("state object");
1665        object.insert("schema".to_owned(), serde_json::json!(5));
1666        let graph = object["config"]["graph"]
1667            .as_object_mut()
1668            .expect("graph object");
1669        graph.insert("timeout_review".to_owned(), serde_json::json!(3600));
1670        graph.remove("timeout_verify");
1671        let review = object["reviews"].as_array_mut().expect("reviews");
1672        review.push(serde_json::json!({
1673            "round": 1, "head": "old", "reviews": [], "e2e": [],
1674            "verify_retried": false, "blocking": 0, "answered": 1,
1675            "expected": 1, "clean": true
1676        }));
1677        let old: RunState = serde_json::from_value(value).expect("schema-5 shape parses");
1678        let migrated = migrate_schema(old).expect("schema 5 migrates");
1679        assert_eq!(migrated.schema, SCHEMA);
1680        assert_eq!(migrated.config.graph.verify_timeout(), 3600);
1681        assert_eq!(migrated.reviews[0].e2e_status(), E2eStatus::NotConfigured);
1682    }
1683
1684    #[test]
1685    fn schema_six_serialization_is_rejected_by_a_schema_five_reader() {
1686        let body = serde_json::to_value(state()).expect("serialize state");
1687        assert_eq!(body["schema"], serde_json::json!(SCHEMA));
1688        assert_ne!(body["schema"], serde_json::json!(5));
1689    }
1690
1691    #[test]
1692    fn seat_started_and_finished_track_who_has_not_answered_yet() {
1693        let mut s = state();
1694        s.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
1695        s.seat_started("judge", "judge-2", std::time::Duration::from_secs(60), 0);
1696        assert_eq!(s.active.len(), 2, "both seats are still out");
1697
1698        s.seat_finished("judge-1");
1699        assert_eq!(
1700            s.active.keys().collect::<Vec<_>>(),
1701            vec!["judge-2"],
1702            "only the seat that answered drops out; judge-2 is still waited on"
1703        );
1704    }
1705
1706    #[test]
1707    fn a_retry_is_recorded_as_a_later_attempt_on_the_same_seat() {
1708        let mut s = state();
1709        s.seat_started("review", "review-2", std::time::Duration::from_secs(30), 0);
1710        s.seat_finished("review-2");
1711        // A nudge re-asks the same seat; attempt says this is not the first
1712        // time, which is the only trace a nudge otherwise leaves behind.
1713        s.seat_started("review", "review-2", std::time::Duration::from_secs(30), 1);
1714        assert_eq!(s.active["review-2"].attempt, 1);
1715    }
1716
1717    #[test]
1718    fn active_seat_reports_elapsed_and_remaining_time() {
1719        let now = Timestamp::now();
1720        let started = now - jiff::SignedDuration::from_secs(30);
1721        let seat = ActiveSeat {
1722            node: "judge".to_owned(),
1723            started_at: started,
1724            timeout_secs: 100,
1725            attempt: 0,
1726        };
1727        assert_eq!(seat.elapsed_secs(now), 30);
1728        assert_eq!(seat.remaining_secs(now), 70);
1729    }
1730
1731    #[test]
1732    fn remaining_time_never_goes_negative_past_the_timeout() {
1733        // `agy`'s own print-timeout occasionally overruns by a hair before the
1734        // kill lands; a naive subtraction would print a negative "time left".
1735        let now = Timestamp::now();
1736        let started = now - jiff::SignedDuration::from_secs(200);
1737        let seat = ActiveSeat {
1738            node: "implement".to_owned(),
1739            started_at: started,
1740            timeout_secs: 100,
1741            attempt: 1,
1742        };
1743        assert_eq!(seat.remaining_secs(now), 0);
1744    }
1745
1746    #[test]
1747    fn clear_active_drops_stale_seats_and_reports_whether_it_did() {
1748        let mut s = state();
1749        assert!(!s.clear_active(), "nothing to clear on a fresh run");
1750        s.seat_started(
1751            "implement",
1752            "impl-B",
1753            std::time::Duration::from_secs(3600),
1754            0,
1755        );
1756        assert!(s.clear_active(), "a leftover entry is reported as cleared");
1757        assert!(s.active.is_empty());
1758    }
1759
1760    #[test]
1761    fn active_seat_carries_nothing_that_could_be_read_as_output_bytes() {
1762        // `agy` prints exactly one JSON object, at the very end (see
1763        // `agent::dropped_stream`'s doc comment) — a seat can sit at zero
1764        // captured bytes for its whole timeout while working normally. So
1765        // `ActiveSeat` records only the wall-clock facts (when it started,
1766        // its budget, which attempt), never a byte count, which is what
1767        // keeps a reader from being able to build "0 bytes => dead" out of
1768        // it even by accident.
1769        let seat = ActiveSeat {
1770            node: "implement".to_owned(),
1771            started_at: Timestamp::now(),
1772            timeout_secs: 60,
1773            attempt: 0,
1774        };
1775        let value = serde_json::to_value(&seat).unwrap();
1776        let keys: std::collections::BTreeSet<String> =
1777            value.as_object().unwrap().keys().cloned().collect();
1778        assert_eq!(
1779            keys,
1780            std::collections::BTreeSet::from([
1781                "node".to_owned(),
1782                "started_at".to_owned(),
1783                "timeout_secs".to_owned(),
1784                "attempt".to_owned(),
1785            ]),
1786            "a byte count here would be a lever to declare a silent-but-healthy seat dead"
1787        );
1788    }
1789
1790    #[test]
1791    fn an_old_run_json_without_active_seats_still_loads() {
1792        // Schema did not bump for this field: an already-written run.json
1793        // simply lacks the key, and `#[serde(default)]` must fill it in
1794        // rather than fail the whole read.
1795        let s = state();
1796        let mut value = serde_json::to_value(&s).unwrap();
1797        value.as_object_mut().unwrap().remove("active");
1798        let back: RunState = serde_json::from_value(value).unwrap();
1799        assert!(back.active.is_empty());
1800        assert_eq!(back.schema, SCHEMA);
1801    }
1802
1803    #[test]
1804    fn ensure_can_delete_guards_live_and_unfolded_runs() {
1805        let mut s = state();
1806        // 1. A daemon is working on it right now.
1807        s.status = RunStatus::Prep;
1808        let err = s.ensure_can_delete(true).unwrap_err().to_string();
1809        assert!(err.contains("live daemon"), "{err}");
1810
1811        // 2. The same unfinished run with no daemon behind it is a leftover
1812        // from a killed process, and deletable. Without this an interrupted
1813        // run could never be removed: its status stays `prep` forever.
1814        assert!(s.ensure_can_delete(false).is_ok());
1815
1816        // 3. Unfolded candidates are refused either way — that is the guard
1817        // that stops a delete from discarding a worktree.
1818        s.status = RunStatus::Merged;
1819        s.candidates.push(Candidate {
1820            index: 0,
1821            label: 'A',
1822            agent: "a".to_owned(),
1823            branch: "b".to_owned(),
1824            worktree: PathBuf::from("/w"),
1825            summary: String::new(),
1826            stat: String::new(),
1827            files: 1,
1828            commits: 1,
1829            empty: false,
1830            failed: None,
1831            duration_ms: 0,
1832            folded: false,
1833        });
1834        let err = s.ensure_can_delete(false).unwrap_err().to_string();
1835        assert!(
1836            err.contains("magi fold"),
1837            "error must suggest `magi fold`: {err}"
1838        );
1839
1840        // 4. Folded and nobody working on it.
1841        s.candidates[0].folded = true;
1842        assert!(s.ensure_can_delete(false).is_ok());
1843    }
1844}