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