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