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/// What the land loop saw last time it looked at the pull request.
504///
505/// Strings for `state` and `checks` on purpose: they are `gh`'s vocabulary, and
506/// pinning them into an enum here would mean a new GitHub check conclusion
507/// turns a readable status into a deserialisation error on a run someone is
508/// trying to look at.
509#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct PrRecord {
511 /// Pull request url.
512 pub url: String,
513 /// Pull request number.
514 pub number: u64,
515 /// `open`, `merged` or `closed`.
516 pub state: String,
517 /// `pending`, `green`, `red` or `unknown`.
518 pub checks: String,
519 /// Land round, 1-based, or 0 before the first fix.
520 pub round: usize,
521 /// Land round budget.
522 pub rounds: usize,
523}
524
525/// The whole run.
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct RunState {
528 /// On-disk format version.
529 pub schema: u32,
530 /// Run id, e.g. `20260830-153012-a1b2`.
531 pub id: String,
532 /// Repository the run operates on.
533 pub repo: PathBuf,
534 /// Branch the run started from.
535 pub base_branch: String,
536 /// Commit the run started from.
537 pub base_commit: String,
538 /// The task, verbatim.
539 pub instruction: String,
540 /// When the run was created.
541 pub created_at: Timestamp,
542 /// Last state flush.
543 pub updated_at: Timestamp,
544 /// Current status.
545 pub status: RunStatus,
546 /// Seed for labels and session ids.
547 pub seed: u64,
548 /// Config snapshot, so a resumed run behaves like the original.
549 pub config: Config,
550 /// Did magi enable `extensions.worktreeConfig`? If so, cleanup turns it off.
551 #[serde(default)]
552 pub enabled_worktree_config: bool,
553 /// Candidates.
554 #[serde(default)]
555 pub candidates: Vec<Candidate>,
556 /// Initial blind rankings.
557 #[serde(default)]
558 pub judgements: Vec<Judgement>,
559 /// `judge` decided a solo candidate needs no panel and only logged it.
560 ///
561 /// `judgements` stays empty in that case — nothing to distinguish from
562 /// "not yet judged" — so this is the record that makes the skip
563 /// idempotent: without it, every reentry re-ran `judge`, re-logged the
564 /// same event, and rewrote `status` to `Judging` over whatever a later
565 /// node had already concluded.
566 #[serde(default)]
567 pub judge_skipped: bool,
568 /// Deliberation, if it happened.
569 #[serde(default)]
570 pub deliberation: Vec<DeliberationRound>,
571 /// Private final votes.
572 #[serde(default)]
573 pub votes: Vec<VoteRecord>,
574 /// The count.
575 #[serde(default)]
576 pub tally: Option<Tally>,
577 /// Review rounds.
578 #[serde(default)]
579 pub reviews: Vec<ReviewRound>,
580 /// Final gate.
581 #[serde(default)]
582 pub gate: Vec<CommandOutcome>,
583 /// Merge outcome.
584 #[serde(default)]
585 pub merge: Option<MergeOutcome>,
586 /// Vendor tokens seen in judged material.
587 #[serde(default)]
588 pub leaks: Vec<Leak>,
589 /// Seats lost to a CLI rate limit / quota, in the order they hit.
590 #[serde(default)]
591 pub quota: Vec<QuotaLoss>,
592 /// Parked at a node boundary, waiting to be resumed.
593 ///
594 /// A run that is neither finished nor being worked on is otherwise
595 /// indistinguishable from one whose daemon was killed, and the two want
596 /// opposite things from an operator: the first is expected to be resumed,
597 /// the second is a leftover. Cleared by the resume that carries it on.
598 #[serde(default)]
599 pub parked: bool,
600 /// Per-seat conversation state.
601 #[serde(default)]
602 pub seats: BTreeMap<String, SeatState>,
603 /// Seats currently mid-answer, keyed by seat.
604 ///
605 /// An entry exists from the moment a prompt is sent until a reply (of any
606 /// kind — success, failure, quota, drop) comes back, so its keys are
607 /// exactly "who hasn't answered yet" for whichever node populated it. See
608 /// [`ActiveSeat`] for why a reader still has to check a live daemon
609 /// before trusting one of these as "running" rather than "abandoned".
610 #[serde(default)]
611 pub active: BTreeMap<String, ActiveSeat>,
612 /// Last observation of the winner's pull request, when a land loop ran.
613 ///
614 /// Persisted rather than derived from the event log because the phone asks
615 /// two questions about a run that has opened a PR - how are its checks and
616 /// which round is it on - and parsing prose out of events to answer them
617 /// would break the first time an event message was reworded.
618 #[serde(default)]
619 pub pr: Option<PrRecord>,
620 /// Node log.
621 #[serde(default)]
622 pub events: Vec<Event>,
623}
624
625impl RunState {
626 /// A fresh run.
627 pub fn new(
628 repo: PathBuf,
629 base_branch: String,
630 base_commit: String,
631 instruction: String,
632 config: Config,
633 ) -> Self {
634 let now = Timestamp::now();
635 let seed = config.blind.seed.unwrap_or_else(crate::rng::entropy);
636 Self {
637 schema: SCHEMA,
638 id: new_id(),
639 repo,
640 base_branch,
641 base_commit,
642 instruction,
643 created_at: now,
644 updated_at: now,
645 status: RunStatus::Prep,
646 seed,
647 config,
648 enabled_worktree_config: false,
649 candidates: Vec::new(),
650 judgements: Vec::new(),
651 judge_skipped: false,
652 deliberation: Vec::new(),
653 votes: Vec::new(),
654 tally: None,
655 reviews: Vec::new(),
656 gate: Vec::new(),
657 merge: None,
658 leaks: Vec::new(),
659 quota: Vec::new(),
660 parked: false,
661 seats: BTreeMap::new(),
662 active: BTreeMap::new(),
663 pr: None,
664 events: Vec::new(),
665 }
666 }
667
668 /// Directory holding this run's state and artifacts.
669 pub fn dir(&self) -> PathBuf {
670 run_dir(&self.id)
671 }
672
673 /// Short form used in branch names and reports.
674 pub fn short(&self) -> &str {
675 short_of(&self.id)
676 }
677
678 /// Branch name for a label.
679 pub fn branch_for(&self, label: char) -> String {
680 format!("magi/{}/{}", self.short(), label)
681 }
682
683 /// Root of this run's worktrees.
684 pub fn worktree_root(&self) -> PathBuf {
685 self.config
686 .graph
687 .worktree_root
688 .clone()
689 .unwrap_or_else(default_worktree_root)
690 .join(self.short())
691 }
692
693 /// Note something in the run log and on the tracing stream.
694 pub fn event(&mut self, node: &str, message: impl Into<String>) {
695 let message = message.into();
696 tracing::info!(node, "{message}");
697 self.events.push(Event {
698 at: Timestamp::now(),
699 node: node.to_owned(),
700 message,
701 });
702 }
703
704 /// Record that `seat` was just sent a prompt for `node`, with the given
705 /// wall-clock budget. `attempt` is 0 for the first ask and N for the Nth
706 /// nudge or resume, purely for display — it does not change how the seat
707 /// is treated.
708 pub fn seat_started(
709 &mut self,
710 node: &str,
711 seat: &str,
712 timeout: std::time::Duration,
713 attempt: usize,
714 ) {
715 self.active.insert(
716 seat.to_owned(),
717 ActiveSeat {
718 node: node.to_owned(),
719 started_at: Timestamp::now(),
720 timeout_secs: timeout.as_secs(),
721 attempt,
722 },
723 );
724 }
725
726 /// Record that `seat` has answered, whatever the answer was.
727 pub fn seat_finished(&mut self, seat: &str) {
728 self.active.remove(seat);
729 }
730
731 /// Drop every seat this state still lists as answering, reporting whether
732 /// anything was dropped.
733 ///
734 /// Called first thing in `execute`, on every entry — fresh, resumed, or
735 /// recovering a stall — because an entry here only means something while
736 /// the process that wrote it is still asking that seat something. A
737 /// process killed mid-wave leaves its last batch of seats here with
738 /// nobody left to clear them, and the next process to touch this run must
739 /// not let that leftover read as "still going" before it has asked
740 /// anyone anything.
741 pub fn clear_active(&mut self) -> bool {
742 if self.active.is_empty() {
743 return false;
744 }
745 self.active.clear();
746 true
747 }
748
749 /// Flush to `run.json`, atomically.
750 pub fn save(&mut self) -> Result<()> {
751 self.updated_at = Timestamp::now();
752 let dir = self.dir();
753 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
754 let body = serde_json::to_string_pretty(self).context("serialize run state")?;
755 let tmp = dir.join("run.json.tmp");
756 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
757 std::fs::rename(&tmp, dir.join("run.json")).with_context(|| "replace run.json")?;
758 Ok(())
759 }
760
761 /// Load a run by id or unambiguous id prefix.
762 pub fn load(id: &str) -> Result<Self> {
763 let resolved = resolve_id(id)?;
764 let path = run_dir(&resolved).join("run.json");
765 let body =
766 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
767 let state: Self =
768 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
769 if state.schema != SCHEMA {
770 bail!(
771 "run {} was written by a different magi (schema {}, this build \
772 speaks {SCHEMA})",
773 state.id,
774 state.schema
775 );
776 }
777 Ok(state)
778 }
779
780 /// The winning candidate, once the tally has run.
781 pub fn winner(&self) -> Option<&Candidate> {
782 let label = self.tally.as_ref()?.winner;
783 self.candidates.iter().find(|c| c.label == label)
784 }
785
786 /// Candidates eligible for judging.
787 pub fn viable(&self) -> Vec<&Candidate> {
788 self.candidates.iter().filter(|c| c.viable()).collect()
789 }
790
791 /// Local-time creation stamp for reports.
792 pub fn created_local(&self) -> String {
793 self.created_at
794 .to_zoned(jiff::tz::TimeZone::system())
795 .strftime("%Y-%m-%d %H:%M:%S")
796 .to_string()
797 }
798
799 /// Assert that this run is safe to delete.
800 ///
801 /// Refuses a run a live daemon is working on, and refuses any run whose
802 /// candidate worktrees and branches have not been folded away with `magi
803 /// fold`. The fold requirement is the real protection: it is what makes
804 /// "delete" mean "remove a record" rather than "throw away a worktree
805 /// somebody may still be editing".
806 ///
807 /// `in_flight` has to come from the caller, because a run's own status
808 /// cannot answer the question. A daemon killed mid-run leaves its status at
809 /// `implementing` forever, and a guard that trusted that would make every
810 /// interrupted run permanently undeletable - the operator's only recourse
811 /// being to edit `run.json` by hand, which is exactly the sort of thing
812 /// this command exists to avoid. The queue already treats an orphaned
813 /// `.lock` from a `SIGKILL`ed daemon the same way; this is that rule for
814 /// runs.
815 pub fn ensure_can_delete(&self, in_flight: bool) -> Result<()> {
816 if in_flight {
817 bail!(
818 "run {} is being worked on by a live daemon right now",
819 self.short()
820 );
821 }
822 if self.candidates.iter().any(|c| !c.folded) {
823 bail!(
824 "run {} has unfolded candidates; fold first with `magi fold`",
825 self.short()
826 );
827 }
828 Ok(())
829 }
830}
831
832/// The short form of a run id: the trailing block after the last `-`.
833///
834/// A free function as well as [`RunState::short`], because callers that have
835/// only an id - an error message, a daemon status, a route handler - were
836/// otherwise reimplementing the split, and two spellings of "short id" is one
837/// rename away from branch names that no longer match their run.
838pub fn short_of(id: &str) -> &str {
839 id.split('-').next_back().unwrap_or(id)
840}
841
842/// Where magi keeps its runs.
843///
844/// `MAGI_HOME` overrides the default, and [`set_home`] overrides both — which
845/// is what lets the integration tests drive a whole graph without writing into
846/// the operator's real history.
847///
848/// In a unit test build (`cfg(test)`), falling through to the real
849/// `<data_local>/magi` is not a fallback worth having: it is exactly how
850/// three broken fixture runs ended up in the operator's actual history and
851/// were counted as `unreadable` by the deck. A test that reaches this point
852/// forgot to call [`set_home`] (or set `MAGI_HOME`) - that is a bug in the
853/// test, not a case to serve, so it panics instead of writing anywhere.
854pub fn home() -> PathBuf {
855 resolve_home(HOME.get().cloned(), std::env::var_os("MAGI_HOME"))
856}
857
858/// The decision `home` makes, taking its two overrides as plain values
859/// instead of reading the `OnceLock` and the environment itself.
860///
861/// Pulled out so the `cfg(test)` panic is asserted directly against a
862/// `None, None` input, rather than racing every other unit test in the
863/// binary for who touches the process-global `HOME` first.
864fn resolve_home(pinned: Option<PathBuf>, magi_home_env: Option<std::ffi::OsString>) -> PathBuf {
865 if let Some(dir) = pinned {
866 return dir;
867 }
868 if let Some(dir) = magi_home_env {
869 return PathBuf::from(dir);
870 }
871 #[cfg(test)]
872 {
873 panic!(
874 "run::home() was reached in a test without run::set_home() or \
875 MAGI_HOME; this would write into the operator's real \
876 <data_local>/magi. Call `run::set_home(temp_dir)` before any \
877 code path that touches a RunState."
878 );
879 }
880 #[cfg(not(test))]
881 {
882 dirs::data_local_dir()
883 .unwrap_or_else(|| PathBuf::from("."))
884 .join("magi")
885 }
886}
887
888/// Pin the run home for this process. The first call wins.
889pub fn set_home(dir: PathBuf) {
890 let _ = HOME.set(dir);
891}
892
893static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
894
895/// `<home>/runs`.
896pub fn runs_root() -> PathBuf {
897 home().join("runs")
898}
899
900/// The worktree root a run uses when the config sets none: `~/wt/magi`.
901///
902/// One definition of the default, so the folder the janitor folds and the
903/// folder the health view sizes cannot drift apart: a run with no configured
904/// [`crate::config::Graph::worktree_root`] lays its worktrees exactly here.
905pub fn default_worktree_root() -> PathBuf {
906 dirs::home_dir()
907 .unwrap_or_else(|| PathBuf::from("."))
908 .join("wt")
909 .join("magi")
910}
911
912/// Directory for one run id.
913pub fn run_dir(id: &str) -> PathBuf {
914 runs_root().join(id)
915}
916
917/// Every run id on disk, newest first.
918///
919/// A directory is a run because of its **name**, not because it holds a
920/// readable `run.json`. A run whose very first save lost the machine's last
921/// free bytes leaves `<id>/run.json.tmp` and nothing else, and filtering on
922/// `run.json` made that run invisible everywhere: not in `magi list`, not in
923/// `runs_unreadable`, not on the phone, so nothing could report it and no
924/// route could clear it. `88c0` sat like that for two days. Unreadable is
925/// counted, never hidden - the readers already say why each one cannot be
926/// read, and `fold_unreadable` is how a record like this leaves.
927pub fn list_ids() -> Vec<String> {
928 let mut ids: Vec<String> = std::fs::read_dir(runs_root())
929 .into_iter()
930 .flatten()
931 .flatten()
932 .filter(|e| e.path().is_dir())
933 .map(|e| e.file_name().to_string_lossy().into_owned())
934 .filter(|name| is_run_id(name))
935 .collect();
936 // Ids start with a sortable timestamp.
937 ids.sort_unstable_by(|a, b| b.cmp(a));
938 ids
939}
940
941/// Does `name` have the shape [`new_id`] mints: `YYYYMMDD-HHMMSS-xxxx`?
942///
943/// The test for "this directory is a run", so a stray folder under
944/// `<home>/runs` is not reported as a broken run.
945///
946/// The tag is checked for length and for being alphanumeric, not for being
947/// hex: real ids are hex, but fixtures across this crate name runs
948/// `...-dead` / `...-gone` / `...-once`, and a predicate that disowned those
949/// would be asserting the fixtures' spelling rather than the shape.
950pub fn is_run_id(name: &str) -> bool {
951 let mut parts = name.split('-');
952 let (Some(day), Some(time), Some(tag), None) =
953 (parts.next(), parts.next(), parts.next(), parts.next())
954 else {
955 return false;
956 };
957 day.len() == 8
958 && day.bytes().all(|b| b.is_ascii_digit())
959 && time.len() == 6
960 && time.bytes().all(|b| b.is_ascii_digit())
961 && tag.len() == 4
962 && tag.bytes().all(|b| b.is_ascii_alphanumeric())
963}
964
965/// Expand an id prefix to exactly one run id.
966pub fn resolve_id(prefix: &str) -> Result<String> {
967 // A whole id names its directory, readable state or not: the run whose
968 // `run.json` never landed still has to be reachable by `magi show` and
969 // by the fold route, which is the only way its record ever leaves.
970 if is_run_id(prefix) && run_dir(prefix).is_dir() {
971 return Ok(prefix.to_owned());
972 }
973 let hits: Vec<String> = list_ids()
974 .into_iter()
975 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
976 .collect();
977 match hits.len() {
978 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
979 0 => bail!("no run matches `{prefix}`"),
980 _ => bail!(
981 "`{prefix}` matches {} runs: {}",
982 hits.len(),
983 hits.join(", ")
984 ),
985 }
986}
987
988/// The most recent run, if any.
989pub fn latest_id() -> Option<String> {
990 list_ids().into_iter().next()
991}
992
993/// `YYYYMMDD-HHMMSS-xxxx`, sortable and short enough for a branch name.
994///
995/// The four hex digits are fresh entropy, **not** `blind.seed`. They were the
996/// seed, and a pinned seed then made the whole id a function of the second it
997/// started in: two runs a second apart were distinguishable, two in the same
998/// second were not. Everything keyed on the id collided with them - the run
999/// directory, `artifacts/`, and the candidate worktrees under
1000/// `wt/magi/<short>/`.
1001///
1002/// `tests/common` pins the seed on purpose, so its integration tests all share
1003/// one suffix. On Windows the suite is slow enough that the seconds differ and
1004/// nothing showed; on Linux `graph_dropped_stream`'s three tests run inside
1005/// 16s, so two of them shared a run directory and the second read an artifact
1006/// the first had written (`impl-B-resume.out`) - a failure that looked like the
1007/// resume logic misbehaving and was really two runs in one directory.
1008///
1009/// A seed exists to make the *blind* decisions reproducible: label assignment
1010/// and per-judge presentation order. It was never meant to name the run, and
1011/// `RunState::seed` still carries it for what it is for.
1012fn new_id() -> String {
1013 let stamp = Zoned::now().strftime("%Y%m%d-%H%M%S");
1014 let entropy = crate::rng::entropy();
1015 format!("{stamp}-{:04x}", (entropy ^ (entropy >> 32)) & 0xffff)
1016}
1017
1018/// Keep the last `max` bytes of `text`, on a line boundary.
1019pub fn tail(text: &str, max: usize) -> String {
1020 if text.len() <= max {
1021 return text.to_owned();
1022 }
1023 let mut cut = text.len() - max;
1024 while cut < text.len() && !text.is_char_boundary(cut) {
1025 cut += 1;
1026 }
1027 let slice = &text[cut..];
1028 let start = slice.find('\n').map_or(0, |i| i + 1);
1029 format!(
1030 "[... {} earlier bytes omitted ...]\n{}",
1031 cut,
1032 &slice[start..]
1033 )
1034}
1035
1036/// Path of a run artifact.
1037pub fn artifact_path(run: &RunState, name: &str) -> PathBuf {
1038 run.dir().join("artifacts").join(name)
1039}
1040
1041/// Write an artifact, creating the directory if needed.
1042pub fn write_artifact(run: &RunState, name: &str, body: &str) -> Result<PathBuf> {
1043 let path = artifact_path(run, name);
1044 if let Some(parent) = path.parent() {
1045 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
1046 }
1047 std::fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
1048 Ok(path)
1049}
1050
1051/// Read an artifact back, e.g. a stored patch on resume.
1052pub fn read_artifact(run: &RunState, name: &str) -> Option<String> {
1053 std::fs::read_to_string(artifact_path(run, name)).ok()
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059
1060 fn state() -> RunState {
1061 RunState::new(
1062 PathBuf::from("/repo"),
1063 "main".to_owned(),
1064 "abc1234def".to_owned(),
1065 "add retries".to_owned(),
1066 Config::default(),
1067 )
1068 }
1069
1070 #[test]
1071 fn resolve_home_prefers_the_pin_then_the_env_var() {
1072 let pinned = PathBuf::from("/pinned");
1073 assert_eq!(
1074 resolve_home(Some(pinned.clone()), Some("/env".into())),
1075 pinned,
1076 "a pin wins even over MAGI_HOME"
1077 );
1078 assert_eq!(
1079 resolve_home(None, Some("/env".into())),
1080 PathBuf::from("/env")
1081 );
1082 }
1083
1084 #[test]
1085 #[should_panic(expected = "run::set_home()")]
1086 fn resolve_home_refuses_to_fall_back_to_the_operators_real_home() {
1087 // Neither override present is exactly the state a test reaches by
1088 // forgetting `set_home`/`MAGI_HOME` - the accident that put three
1089 // broken fixture runs into the operator's real history. Asserted
1090 // against the pure decision directly, not `home()` itself, because
1091 // `HOME` is a process-wide `OnceLock` another test may have already
1092 // set - this must not depend on test execution order.
1093 resolve_home(None, None);
1094 }
1095
1096 #[test]
1097 fn a_run_is_named_by_shape_so_a_state_less_directory_is_still_a_run() {
1098 // The shape `new_id` mints. A directory answering to it is a run even
1099 // with no readable `run.json`: that is how a save that ran out of
1100 // disk stays visible instead of vanishing from every listing.
1101 assert!(is_run_id(&new_id()));
1102 assert!(is_run_id("20260904-014540-88c0"));
1103 // Not runs: a stray folder, a truncated id, a non-hex tag, and an id
1104 // with an extra segment (a worktree label, say).
1105 assert!(!is_run_id("scratch"));
1106 assert!(!is_run_id("20260904-014540"));
1107 assert!(!is_run_id("20260904-014540-88c0f"));
1108 assert!(!is_run_id("2026090x-014540-88c0"));
1109 assert!(!is_run_id("20260904-014540-88c0-A"));
1110 }
1111
1112 #[test]
1113 fn ids_are_sortable_and_short_suffixed() {
1114 let s = state();
1115 let parts: Vec<&str> = s.id.split('-').collect();
1116 assert_eq!(parts.len(), 3);
1117 assert_eq!(parts[0].len(), 8);
1118 assert_eq!(parts[1].len(), 6);
1119 assert_eq!(parts[2].len(), 4);
1120 assert_eq!(s.short(), parts[2]);
1121 }
1122
1123 #[test]
1124 fn branch_names_carry_the_label_not_the_author() {
1125 let s = state();
1126 let b = s.branch_for('B');
1127 assert_eq!(b, format!("magi/{}/B", s.short()));
1128 assert!(!b.contains("claude"));
1129 }
1130
1131 /// A pinned seed reproduces the blind decisions. It must **not** reproduce
1132 /// the run's identity.
1133 ///
1134 /// `assert_eq!(a.short(), b.short())` used to stand where the last
1135 /// assertion is now, and it was pinning the defect: with the id's suffix
1136 /// derived from the seed, two runs started in the same second were the
1137 /// same run as far as the filesystem was concerned - one directory, one
1138 /// `artifacts/`, one set of candidate worktrees. `tests/common` pins a
1139 /// seed for every integration test, so on Linux, where the suite is fast,
1140 /// two tests in `graph_dropped_stream` shared a directory and one read the
1141 /// other's artifact.
1142 #[test]
1143 fn a_pinned_seed_is_reproducible_but_never_the_run_id() {
1144 let mut cfg = Config::default();
1145 cfg.blind.seed = Some(1234);
1146 let a = RunState::new(
1147 PathBuf::from("/r"),
1148 "main".to_owned(),
1149 "c".to_owned(),
1150 "t".to_owned(),
1151 cfg.clone(),
1152 );
1153 let b = RunState::new(
1154 PathBuf::from("/r"),
1155 "main".to_owned(),
1156 "c".to_owned(),
1157 "t".to_owned(),
1158 cfg,
1159 );
1160 // What the seed is for: the same shuffles, run after run.
1161 assert_eq!(a.seed, 1234);
1162 assert_eq!(a.seed, b.seed);
1163 // What it is not for. Two runs are two runs, in the same second or
1164 // not, and everything keyed on the id depends on that.
1165 assert_ne!(
1166 a.id, b.id,
1167 "two runs sharing an id share a directory, artifacts and worktrees"
1168 );
1169 }
1170
1171 #[test]
1172 fn status_terminality() {
1173 assert!(RunStatus::Merged.done());
1174 assert!(RunStatus::Blocked.done());
1175 assert!(!RunStatus::Reviewing.done());
1176 }
1177
1178 #[test]
1179 fn candidate_viability_excludes_empty_and_failed() {
1180 let mut c = Candidate {
1181 index: 0,
1182 label: 'A',
1183 agent: "a".to_owned(),
1184 branch: "b".to_owned(),
1185 worktree: PathBuf::from("/w"),
1186 summary: String::new(),
1187 stat: String::new(),
1188 files: 1,
1189 commits: 1,
1190 empty: false,
1191 failed: None,
1192 duration_ms: 0,
1193 folded: false,
1194 };
1195 assert!(c.viable());
1196 c.empty = true;
1197 assert!(!c.viable());
1198 c.empty = false;
1199 c.failed = Some("timeout".to_owned());
1200 assert!(!c.viable());
1201 }
1202
1203 #[test]
1204 fn build_failure_is_distinguished_from_a_failing_test() {
1205 let link_race = CommandOutcome {
1206 command: "cargo test".to_owned(),
1207 code: Some(1),
1208 output_tail: "LINK : fatal error LNK1104: cannot open file \
1209 'graph_dirty_tree-71d4dc8e.exe'\n\
1210 error: could not compile `magi-cli` (test \"graph_dirty_tree\")"
1211 .to_owned(),
1212 duration_ms: 500,
1213 };
1214 assert!(!link_race.ok());
1215 assert!(link_race.build_failed());
1216
1217 let failing_test = CommandOutcome {
1218 command: "cargo test".to_owned(),
1219 code: Some(101),
1220 output_tail: "thread 'it_works' panicked at 'assertion failed'".to_owned(),
1221 duration_ms: 500,
1222 };
1223 assert!(!failing_test.ok());
1224 assert!(
1225 !failing_test.build_failed(),
1226 "a real test failure must not be classed as a build failure"
1227 );
1228
1229 let passing = CommandOutcome {
1230 command: "cargo test".to_owned(),
1231 code: Some(0),
1232 output_tail: String::new(),
1233 duration_ms: 500,
1234 };
1235 assert!(passing.ok());
1236 assert!(!passing.build_failed());
1237 }
1238
1239 #[test]
1240 fn tail_keeps_the_end_on_a_line_boundary() {
1241 let text = (0..100).map(|i| format!("line {i}\n")).collect::<String>();
1242 let t = tail(&text, 40);
1243 assert!(t.starts_with("[..."));
1244 assert!(t.ends_with("line 99\n"));
1245 assert!(t.len() < 120);
1246 assert_eq!(tail("short", 40), "short");
1247 }
1248
1249 #[test]
1250 fn tail_survives_multibyte_cuts() {
1251 let text = "あ".repeat(50);
1252 let t = tail(&text, 10);
1253 assert!(t.contains("earlier bytes omitted"));
1254 assert!(t.ends_with('あ'));
1255 }
1256
1257 #[test]
1258 fn state_round_trips_through_json() {
1259 let s = state();
1260 let body = serde_json::to_string(&s).unwrap();
1261 let back: RunState = serde_json::from_str(&body).unwrap();
1262 assert_eq!(back.id, s.id);
1263 assert_eq!(back.instruction, "add retries");
1264 assert_eq!(back.status, RunStatus::Prep);
1265 }
1266
1267 #[test]
1268 fn seat_started_and_finished_track_who_has_not_answered_yet() {
1269 let mut s = state();
1270 s.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
1271 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(60), 0);
1272 assert_eq!(s.active.len(), 2, "both seats are still out");
1273
1274 s.seat_finished("judge-1");
1275 assert_eq!(
1276 s.active.keys().collect::<Vec<_>>(),
1277 vec!["judge-2"],
1278 "only the seat that answered drops out; judge-2 is still waited on"
1279 );
1280 }
1281
1282 #[test]
1283 fn a_retry_is_recorded_as_a_later_attempt_on_the_same_seat() {
1284 let mut s = state();
1285 s.seat_started("review", "review-2", std::time::Duration::from_secs(30), 0);
1286 s.seat_finished("review-2");
1287 // A nudge re-asks the same seat; attempt says this is not the first
1288 // time, which is the only trace a nudge otherwise leaves behind.
1289 s.seat_started("review", "review-2", std::time::Duration::from_secs(30), 1);
1290 assert_eq!(s.active["review-2"].attempt, 1);
1291 }
1292
1293 #[test]
1294 fn active_seat_reports_elapsed_and_remaining_time() {
1295 let now = Timestamp::now();
1296 let started = now - jiff::SignedDuration::from_secs(30);
1297 let seat = ActiveSeat {
1298 node: "judge".to_owned(),
1299 started_at: started,
1300 timeout_secs: 100,
1301 attempt: 0,
1302 };
1303 assert_eq!(seat.elapsed_secs(now), 30);
1304 assert_eq!(seat.remaining_secs(now), 70);
1305 }
1306
1307 #[test]
1308 fn remaining_time_never_goes_negative_past_the_timeout() {
1309 // `agy`'s own print-timeout occasionally overruns by a hair before the
1310 // kill lands; a naive subtraction would print a negative "time left".
1311 let now = Timestamp::now();
1312 let started = now - jiff::SignedDuration::from_secs(200);
1313 let seat = ActiveSeat {
1314 node: "implement".to_owned(),
1315 started_at: started,
1316 timeout_secs: 100,
1317 attempt: 1,
1318 };
1319 assert_eq!(seat.remaining_secs(now), 0);
1320 }
1321
1322 #[test]
1323 fn clear_active_drops_stale_seats_and_reports_whether_it_did() {
1324 let mut s = state();
1325 assert!(!s.clear_active(), "nothing to clear on a fresh run");
1326 s.seat_started(
1327 "implement",
1328 "impl-B",
1329 std::time::Duration::from_secs(3600),
1330 0,
1331 );
1332 assert!(s.clear_active(), "a leftover entry is reported as cleared");
1333 assert!(s.active.is_empty());
1334 }
1335
1336 #[test]
1337 fn active_seat_carries_nothing_that_could_be_read_as_output_bytes() {
1338 // `agy` prints exactly one JSON object, at the very end (see
1339 // `agent::dropped_stream`'s doc comment) — a seat can sit at zero
1340 // captured bytes for its whole timeout while working normally. So
1341 // `ActiveSeat` records only the wall-clock facts (when it started,
1342 // its budget, which attempt), never a byte count, which is what
1343 // keeps a reader from being able to build "0 bytes => dead" out of
1344 // it even by accident.
1345 let seat = ActiveSeat {
1346 node: "implement".to_owned(),
1347 started_at: Timestamp::now(),
1348 timeout_secs: 60,
1349 attempt: 0,
1350 };
1351 let value = serde_json::to_value(&seat).unwrap();
1352 let keys: std::collections::BTreeSet<String> =
1353 value.as_object().unwrap().keys().cloned().collect();
1354 assert_eq!(
1355 keys,
1356 std::collections::BTreeSet::from([
1357 "node".to_owned(),
1358 "started_at".to_owned(),
1359 "timeout_secs".to_owned(),
1360 "attempt".to_owned(),
1361 ]),
1362 "a byte count here would be a lever to declare a silent-but-healthy seat dead"
1363 );
1364 }
1365
1366 #[test]
1367 fn an_old_run_json_without_active_seats_still_loads() {
1368 // Schema did not bump for this field: an already-written run.json
1369 // simply lacks the key, and `#[serde(default)]` must fill it in
1370 // rather than fail the whole read.
1371 let s = state();
1372 let mut value = serde_json::to_value(&s).unwrap();
1373 value.as_object_mut().unwrap().remove("active");
1374 let back: RunState = serde_json::from_value(value).unwrap();
1375 assert!(back.active.is_empty());
1376 assert_eq!(back.schema, SCHEMA);
1377 }
1378
1379 #[test]
1380 fn ensure_can_delete_guards_live_and_unfolded_runs() {
1381 let mut s = state();
1382 // 1. A daemon is working on it right now.
1383 s.status = RunStatus::Prep;
1384 let err = s.ensure_can_delete(true).unwrap_err().to_string();
1385 assert!(err.contains("live daemon"), "{err}");
1386
1387 // 2. The same unfinished run with no daemon behind it is a leftover
1388 // from a killed process, and deletable. Without this an interrupted
1389 // run could never be removed: its status stays `prep` forever.
1390 assert!(s.ensure_can_delete(false).is_ok());
1391
1392 // 3. Unfolded candidates are refused either way — that is the guard
1393 // that stops a delete from discarding a worktree.
1394 s.status = RunStatus::Merged;
1395 s.candidates.push(Candidate {
1396 index: 0,
1397 label: 'A',
1398 agent: "a".to_owned(),
1399 branch: "b".to_owned(),
1400 worktree: PathBuf::from("/w"),
1401 summary: String::new(),
1402 stat: String::new(),
1403 files: 1,
1404 commits: 1,
1405 empty: false,
1406 failed: None,
1407 duration_ms: 0,
1408 folded: false,
1409 });
1410 let err = s.ensure_can_delete(false).unwrap_err().to_string();
1411 assert!(
1412 err.contains("magi fold"),
1413 "error must suggest `magi fold`: {err}"
1414 );
1415
1416 // 4. Folded and nobody working on it.
1417 s.candidates[0].folded = true;
1418 assert!(s.ensure_can_delete(false).is_ok());
1419 }
1420}