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