Skip to main content

magi/
run.rs

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