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.
30pub const SCHEMA: u32 = 2;
31
32/// Where a run got to.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum RunStatus {
36 /// Worktrees being prepared.
37 Prep,
38 /// Candidates being implemented.
39 Implementing,
40 /// Judges ranking blind.
41 Judging,
42 /// Judges deliberating after a split.
43 Deliberating,
44 /// Final votes being collected privately.
45 Voting,
46 /// Winner in the review + verification loop.
47 Reviewing,
48 /// Gate commands running.
49 Gating,
50 /// Winner merged.
51 Merged,
52 /// Winner passed the gate; merge was not requested.
53 Ready,
54 /// The judgement did not gather enough judges (e.g. rate limiting took out
55 /// seats), so the verdict is not trustworthy. The run stopped and kept its
56 /// work so it can be resumed or folded — it must never be confused with a
57 /// healthy `Ready`.
58 Stalled,
59 /// Review rounds exhausted with findings still open, or the gate failed.
60 Blocked,
61 /// The graph could not complete.
62 Failed,
63}
64
65impl RunStatus {
66 /// Is this a terminal state?
67 pub fn done(self) -> bool {
68 matches!(
69 self,
70 Self::Merged | Self::Ready | Self::Stalled | Self::Blocked | Self::Failed
71 )
72 }
73
74 /// The name this status is written and shown under, matching the
75 /// `snake_case` serde spelling so a log line, an error message and the
76 /// JSON a phone reads all say the same word.
77 pub fn as_str(self) -> &'static str {
78 match self {
79 Self::Prep => "prep",
80 Self::Implementing => "implementing",
81 Self::Judging => "judging",
82 Self::Deliberating => "deliberating",
83 Self::Voting => "voting",
84 Self::Reviewing => "reviewing",
85 Self::Gating => "gating",
86 Self::Merged => "merged",
87 Self::Ready => "ready",
88 Self::Stalled => "stalled",
89 Self::Blocked => "blocked",
90 Self::Failed => "failed",
91 }
92 }
93
94 /// Can this run be carried on from where it stopped?
95 ///
96 /// Everything except a finished run and a failed one. `execute` skips
97 /// nodes already recorded, so re-entering is cheap wherever the run
98 /// stopped, and the alternative is always a fresh competition against
99 /// work that already exists.
100 ///
101 /// - `Stalled` re-asks only the seats whose absence collapsed the panel,
102 /// keeping the candidates that were already paid for.
103 /// - `Blocked` re-enters the review loop against a branch that is built.
104 /// - **A non-terminal status** means the run was interrupted: a parked
105 /// run waiting for its upgrade, or one whose daemon was killed. This
106 /// used to be excluded, which left run 4043 stuck at `reviewing` with
107 /// the deck telling the operator it could not be resumed - the one
108 /// state where resuming is the only sensible answer.
109 ///
110 /// `Failed` does not qualify: the graph could not complete and there is
111 /// no established point to continue from. Nor does a finished run, whose
112 /// answer is a new competition.
113 ///
114 /// Whether anything is *already* driving the run is a separate question,
115 /// answered by `daemon::is_working_on` at the callers that need it.
116 pub fn resumable(self) -> bool {
117 !matches!(self, Self::Merged | Self::Ready | Self::Failed)
118 }
119}
120
121/// One candidate implementation.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct Candidate {
124 /// Position in the implementer list.
125 pub index: usize,
126 /// Blind label as presented to judges.
127 pub label: char,
128 /// Which agent wrote it. Recorded for the stats tables, never shown to a
129 /// judge.
130 pub agent: String,
131 /// Branch, named after the label so judges can inspect it without learning
132 /// the author.
133 pub branch: String,
134 /// Worktree path.
135 pub worktree: PathBuf,
136 /// Sanitized author summary.
137 #[serde(default)]
138 pub summary: String,
139 /// `git diff --stat`.
140 #[serde(default)]
141 pub stat: String,
142 /// Files touched.
143 #[serde(default)]
144 pub files: usize,
145 /// Commits ahead of base.
146 #[serde(default)]
147 pub commits: usize,
148 /// True when the agent produced no change at all.
149 #[serde(default)]
150 pub empty: bool,
151 /// Why this candidate is not in the running.
152 #[serde(default)]
153 pub failed: Option<String>,
154 /// Wall-clock time for the implementation.
155 #[serde(default)]
156 pub duration_ms: u64,
157 /// Whether the worktree has been folded away.
158 #[serde(default)]
159 pub folded: bool,
160}
161
162impl Candidate {
163 /// Can this candidate be judged?
164 pub fn viable(&self) -> bool {
165 self.failed.is_none() && !self.empty
166 }
167}
168
169/// One judge's independent ranking.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct Judgement {
172 /// Judge seat number, 1-based.
173 pub judge: usize,
174 /// Seat key.
175 pub seat: String,
176 /// Agent occupying the seat.
177 pub agent: String,
178 /// Best-first labels.
179 #[serde(default)]
180 pub ranking: Vec<char>,
181 /// Per-label justification.
182 #[serde(default)]
183 pub reasons: BTreeMap<String, String>,
184 /// Self-reported confidence.
185 #[serde(default)]
186 pub confidence: Option<u8>,
187 /// Order the candidates were presented in, as candidate indices.
188 #[serde(default)]
189 pub order: Vec<usize>,
190 /// Why this judge has no ranking.
191 #[serde(default)]
192 pub failed: Option<String>,
193 /// Wall-clock time.
194 #[serde(default)]
195 pub duration_ms: u64,
196}
197
198/// One judge's turn in a deliberation round.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct DeliberationTurn {
201 /// Judge seat number, 1-based.
202 pub judge: usize,
203 /// Agent occupying the seat.
204 pub agent: String,
205 /// The argument, as written.
206 pub body: String,
207 /// Where the judge stood at the end of the turn.
208 #[serde(default)]
209 pub tentative: Option<char>,
210}
211
212/// A deliberation round.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct DeliberationRound {
215 /// 1-based round number.
216 pub round: usize,
217 /// Turns, in the order they were taken.
218 pub turns: Vec<DeliberationTurn>,
219}
220
221/// A final vote, collected privately.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct VoteRecord {
224 /// Judge seat number, 1-based.
225 pub judge: usize,
226 /// Agent occupying the seat.
227 pub agent: String,
228 /// The vote.
229 #[serde(default)]
230 pub vote: Option<char>,
231 /// Why.
232 #[serde(default)]
233 pub reason: String,
234 /// Did this judge move from its initial first choice?
235 #[serde(default)]
236 pub changed: bool,
237}
238
239/// A seat that was taken out by a CLI rate limit / quota, recorded so a run
240/// whose panel collapsed does not masquerade as a healthy one.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct QuotaLoss {
243 /// Seat key, e.g. `judge-1` or `review-2`.
244 pub seat: String,
245 /// Node that was running, e.g. `judge`, `vote`, `review`.
246 pub node: String,
247 /// When the CLI reported the limit.
248 pub at: Timestamp,
249 /// Reset hint if the CLI printed one, free text.
250 #[serde(default)]
251 pub reset: Option<String>,
252}
253
254/// The mechanical count.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Tally {
257 /// First-choice votes per label.
258 pub first_choice: BTreeMap<char, usize>,
259 /// Borda points from the initial rankings, used only to break a tie.
260 pub borda: BTreeMap<char, usize>,
261 /// The winning label.
262 pub winner: char,
263 /// How many judges produced a usable ranking. A panel of one is not a
264 /// consensus and must not be reported as a split.
265 #[serde(default)]
266 pub rankings: usize,
267 /// Did every judge's *initial* first choice agree?
268 pub unanimous_initial: bool,
269 /// Was deliberation run?
270 pub deliberated: bool,
271 /// Judges who moved between their initial ranking and their final vote.
272 pub changed_votes: usize,
273 /// Did the final votes agree?
274 pub unanimous_final: bool,
275 /// How the tie was broken, when it had to be.
276 #[serde(default)]
277 pub tie_break: Option<String>,
278 /// Configured judge count — the size of the full panel.
279 #[serde(default)]
280 pub judges: usize,
281 /// Judges who actually contributed to the decision (not taken out by a
282 /// rate limit and producing a usable rank or vote).
283 #[serde(default)]
284 pub present: usize,
285 /// How many judges are required for a trustworthy verdict. Chosen as a
286 /// strict majority (`judges / 2 + 1`): a verdict backed by a minority must
287 /// never be presented as a healthy one, while a bare majority is still
288 /// real signal. A one-candidate run needs no quorum.
289 #[serde(default)]
290 pub quorum: usize,
291 /// `present >= quorum`, or no quorum was required.
292 #[serde(default)]
293 pub met_quorum: bool,
294}
295
296/// One reviewer's report in a round.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct ReviewRecord {
299 /// Reviewer seat number, 1-based.
300 pub reviewer: usize,
301 /// Agent occupying the seat.
302 pub agent: String,
303 /// Reviewer prose.
304 #[serde(default)]
305 pub summary: String,
306 /// Findings, with magi-assigned ids.
307 #[serde(default)]
308 pub findings: Vec<Finding>,
309 /// Why this reviewer produced nothing.
310 #[serde(default)]
311 pub failed: Option<String>,
312 /// Wall-clock time.
313 #[serde(default)]
314 pub duration_ms: u64,
315}
316
317/// The fixer's response to a round.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct FixRecord {
320 /// Agent that applied the fixes.
321 pub agent: String,
322 /// Finding ids acted on.
323 #[serde(default)]
324 pub addressed: Vec<String>,
325 /// Findings declined, with reasons.
326 #[serde(default)]
327 pub rejected: Vec<Rejection>,
328 /// What changed.
329 #[serde(default)]
330 pub notes: String,
331 /// Did the fix produce a commit?
332 #[serde(default)]
333 pub committed: bool,
334 /// Why the fix step produced nothing.
335 #[serde(default)]
336 pub failed: Option<String>,
337 /// Wall-clock time.
338 #[serde(default)]
339 pub duration_ms: u64,
340}
341
342/// Outcome of one shell command.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct CommandOutcome {
345 /// The command, as configured.
346 pub command: String,
347 /// Exit code, `None` on timeout or signal.
348 pub code: Option<i32>,
349 /// Tail of the combined output, for the report and the fix prompt.
350 #[serde(default)]
351 pub output_tail: String,
352 /// Wall-clock time.
353 #[serde(default)]
354 pub duration_ms: u64,
355}
356
357impl CommandOutcome {
358 /// Did it pass?
359 pub fn ok(&self) -> bool {
360 self.code == Some(0)
361 }
362}
363
364/// One review + verify + fix round.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct ReviewRound {
367 /// 1-based round number.
368 pub round: usize,
369 /// Commit the round reviewed.
370 pub head: String,
371 /// Reviewer reports.
372 pub reviews: Vec<ReviewRecord>,
373 /// E2E command outcomes for this round.
374 #[serde(default)]
375 pub e2e: Vec<CommandOutcome>,
376 /// Fixer response, absent when the round was already clean.
377 #[serde(default)]
378 pub fix: Option<FixRecord>,
379 /// Findings that hold the merge.
380 #[serde(default)]
381 pub blocking: usize,
382 /// Round ended with no blocking findings and green verification.
383 #[serde(default)]
384 pub clean: bool,
385}
386
387/// What happened to the winning branch.
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct MergeOutcome {
390 /// Requested mode.
391 pub mode: MergeMode,
392 /// Did it land?
393 pub ok: bool,
394 /// Command output, or the command the operator should run.
395 #[serde(default)]
396 pub detail: String,
397}
398
399/// A timestamped note about a node.
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct Event {
402 /// When.
403 pub at: Timestamp,
404 /// Node name.
405 pub node: String,
406 /// What happened.
407 pub message: String,
408}
409
410/// What the land loop saw last time it looked at the pull request.
411///
412/// Strings for `state` and `checks` on purpose: they are `gh`'s vocabulary, and
413/// pinning them into an enum here would mean a new GitHub check conclusion
414/// turns a readable status into a deserialisation error on a run someone is
415/// trying to look at.
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct PrRecord {
418 /// Pull request url.
419 pub url: String,
420 /// Pull request number.
421 pub number: u64,
422 /// `open`, `merged` or `closed`.
423 pub state: String,
424 /// `pending`, `green`, `red` or `unknown`.
425 pub checks: String,
426 /// Land round, 1-based, or 0 before the first fix.
427 pub round: usize,
428 /// Land round budget.
429 pub rounds: usize,
430}
431
432/// The whole run.
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct RunState {
435 /// On-disk format version.
436 pub schema: u32,
437 /// Run id, e.g. `20260830-153012-a1b2`.
438 pub id: String,
439 /// Repository the run operates on.
440 pub repo: PathBuf,
441 /// Branch the run started from.
442 pub base_branch: String,
443 /// Commit the run started from.
444 pub base_commit: String,
445 /// The task, verbatim.
446 pub instruction: String,
447 /// When the run was created.
448 pub created_at: Timestamp,
449 /// Last state flush.
450 pub updated_at: Timestamp,
451 /// Current status.
452 pub status: RunStatus,
453 /// Seed for labels and session ids.
454 pub seed: u64,
455 /// Config snapshot, so a resumed run behaves like the original.
456 pub config: Config,
457 /// Did magi enable `extensions.worktreeConfig`? If so, cleanup turns it off.
458 #[serde(default)]
459 pub enabled_worktree_config: bool,
460 /// Candidates.
461 #[serde(default)]
462 pub candidates: Vec<Candidate>,
463 /// Initial blind rankings.
464 #[serde(default)]
465 pub judgements: Vec<Judgement>,
466 /// Deliberation, if it happened.
467 #[serde(default)]
468 pub deliberation: Vec<DeliberationRound>,
469 /// Private final votes.
470 #[serde(default)]
471 pub votes: Vec<VoteRecord>,
472 /// The count.
473 #[serde(default)]
474 pub tally: Option<Tally>,
475 /// Review rounds.
476 #[serde(default)]
477 pub reviews: Vec<ReviewRound>,
478 /// Final gate.
479 #[serde(default)]
480 pub gate: Vec<CommandOutcome>,
481 /// Merge outcome.
482 #[serde(default)]
483 pub merge: Option<MergeOutcome>,
484 /// Vendor tokens seen in judged material.
485 #[serde(default)]
486 pub leaks: Vec<Leak>,
487 /// Seats lost to a CLI rate limit / quota, in the order they hit.
488 #[serde(default)]
489 pub quota: Vec<QuotaLoss>,
490 /// Parked at a node boundary, waiting to be resumed.
491 ///
492 /// A run that is neither finished nor being worked on is otherwise
493 /// indistinguishable from one whose daemon was killed, and the two want
494 /// opposite things from an operator: the first is expected to be resumed,
495 /// the second is a leftover. Cleared by the resume that carries it on.
496 #[serde(default)]
497 pub parked: bool,
498 /// Per-seat conversation state.
499 #[serde(default)]
500 pub seats: BTreeMap<String, SeatState>,
501 /// Last observation of the winner's pull request, when a land loop ran.
502 ///
503 /// Persisted rather than derived from the event log because the phone asks
504 /// two questions about a run that has opened a PR - how are its checks and
505 /// which round is it on - and parsing prose out of events to answer them
506 /// would break the first time an event message was reworded.
507 #[serde(default)]
508 pub pr: Option<PrRecord>,
509 /// Node log.
510 #[serde(default)]
511 pub events: Vec<Event>,
512}
513
514impl RunState {
515 /// A fresh run.
516 pub fn new(
517 repo: PathBuf,
518 base_branch: String,
519 base_commit: String,
520 instruction: String,
521 config: Config,
522 ) -> Self {
523 let now = Timestamp::now();
524 let seed = config.blind.seed.unwrap_or_else(crate::rng::entropy);
525 Self {
526 schema: SCHEMA,
527 id: new_id(),
528 repo,
529 base_branch,
530 base_commit,
531 instruction,
532 created_at: now,
533 updated_at: now,
534 status: RunStatus::Prep,
535 seed,
536 config,
537 enabled_worktree_config: false,
538 candidates: Vec::new(),
539 judgements: Vec::new(),
540 deliberation: Vec::new(),
541 votes: Vec::new(),
542 tally: None,
543 reviews: Vec::new(),
544 gate: Vec::new(),
545 merge: None,
546 leaks: Vec::new(),
547 quota: Vec::new(),
548 parked: false,
549 seats: BTreeMap::new(),
550 pr: None,
551 events: Vec::new(),
552 }
553 }
554
555 /// Directory holding this run's state and artifacts.
556 pub fn dir(&self) -> PathBuf {
557 run_dir(&self.id)
558 }
559
560 /// Short form used in branch names and reports.
561 pub fn short(&self) -> &str {
562 short_of(&self.id)
563 }
564
565 /// Branch name for a label.
566 pub fn branch_for(&self, label: char) -> String {
567 format!("magi/{}/{}", self.short(), label)
568 }
569
570 /// Root of this run's worktrees.
571 pub fn worktree_root(&self) -> PathBuf {
572 self.config
573 .graph
574 .worktree_root
575 .clone()
576 .unwrap_or_else(default_worktree_root)
577 .join(self.short())
578 }
579
580 /// Note something in the run log and on the tracing stream.
581 pub fn event(&mut self, node: &str, message: impl Into<String>) {
582 let message = message.into();
583 tracing::info!(node, "{message}");
584 self.events.push(Event {
585 at: Timestamp::now(),
586 node: node.to_owned(),
587 message,
588 });
589 }
590
591 /// Flush to `run.json`, atomically.
592 pub fn save(&mut self) -> Result<()> {
593 self.updated_at = Timestamp::now();
594 let dir = self.dir();
595 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
596 let body = serde_json::to_string_pretty(self).context("serialize run state")?;
597 let tmp = dir.join("run.json.tmp");
598 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
599 std::fs::rename(&tmp, dir.join("run.json")).with_context(|| "replace run.json")?;
600 Ok(())
601 }
602
603 /// Load a run by id or unambiguous id prefix.
604 pub fn load(id: &str) -> Result<Self> {
605 let resolved = resolve_id(id)?;
606 let path = run_dir(&resolved).join("run.json");
607 let body =
608 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
609 let state: Self =
610 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
611 if state.schema != SCHEMA {
612 bail!(
613 "run {} was written by a different magi (schema {}, this build \
614 speaks {SCHEMA})",
615 state.id,
616 state.schema
617 );
618 }
619 Ok(state)
620 }
621
622 /// The winning candidate, once the tally has run.
623 pub fn winner(&self) -> Option<&Candidate> {
624 let label = self.tally.as_ref()?.winner;
625 self.candidates.iter().find(|c| c.label == label)
626 }
627
628 /// Candidates eligible for judging.
629 pub fn viable(&self) -> Vec<&Candidate> {
630 self.candidates.iter().filter(|c| c.viable()).collect()
631 }
632
633 /// Local-time creation stamp for reports.
634 pub fn created_local(&self) -> String {
635 self.created_at
636 .to_zoned(jiff::tz::TimeZone::system())
637 .strftime("%Y-%m-%d %H:%M:%S")
638 .to_string()
639 }
640
641 /// Assert that this run is safe to delete.
642 ///
643 /// Refuses a run a live daemon is working on, and refuses any run whose
644 /// candidate worktrees and branches have not been folded away with `magi
645 /// fold`. The fold requirement is the real protection: it is what makes
646 /// "delete" mean "remove a record" rather than "throw away a worktree
647 /// somebody may still be editing".
648 ///
649 /// `in_flight` has to come from the caller, because a run's own status
650 /// cannot answer the question. A daemon killed mid-run leaves its status at
651 /// `implementing` forever, and a guard that trusted that would make every
652 /// interrupted run permanently undeletable - the operator's only recourse
653 /// being to edit `run.json` by hand, which is exactly the sort of thing
654 /// this command exists to avoid. The queue already treats an orphaned
655 /// `.lock` from a `SIGKILL`ed daemon the same way; this is that rule for
656 /// runs.
657 pub fn ensure_can_delete(&self, in_flight: bool) -> Result<()> {
658 if in_flight {
659 bail!(
660 "run {} is being worked on by a live daemon right now",
661 self.short()
662 );
663 }
664 if self.candidates.iter().any(|c| !c.folded) {
665 bail!(
666 "run {} has unfolded candidates; fold first with `magi fold`",
667 self.short()
668 );
669 }
670 Ok(())
671 }
672}
673
674/// The short form of a run id: the trailing block after the last `-`.
675///
676/// A free function as well as [`RunState::short`], because callers that have
677/// only an id - an error message, a daemon status, a route handler - were
678/// otherwise reimplementing the split, and two spellings of "short id" is one
679/// rename away from branch names that no longer match their run.
680pub fn short_of(id: &str) -> &str {
681 id.split('-').next_back().unwrap_or(id)
682}
683
684/// Where magi keeps its runs.
685///
686/// `MAGI_HOME` overrides the default, and [`set_home`] overrides both — which
687/// is what lets the integration tests drive a whole graph without writing into
688/// the operator's real history.
689///
690/// In a unit test build (`cfg(test)`), falling through to the real
691/// `<data_local>/magi` is not a fallback worth having: it is exactly how
692/// three broken fixture runs ended up in the operator's actual history and
693/// were counted as `unreadable` by the deck. A test that reaches this point
694/// forgot to call [`set_home`] (or set `MAGI_HOME`) - that is a bug in the
695/// test, not a case to serve, so it panics instead of writing anywhere.
696pub fn home() -> PathBuf {
697 resolve_home(HOME.get().cloned(), std::env::var_os("MAGI_HOME"))
698}
699
700/// The decision `home` makes, taking its two overrides as plain values
701/// instead of reading the `OnceLock` and the environment itself.
702///
703/// Pulled out so the `cfg(test)` panic is asserted directly against a
704/// `None, None` input, rather than racing every other unit test in the
705/// binary for who touches the process-global `HOME` first.
706fn resolve_home(pinned: Option<PathBuf>, magi_home_env: Option<std::ffi::OsString>) -> PathBuf {
707 if let Some(dir) = pinned {
708 return dir;
709 }
710 if let Some(dir) = magi_home_env {
711 return PathBuf::from(dir);
712 }
713 #[cfg(test)]
714 {
715 panic!(
716 "run::home() was reached in a test without run::set_home() or \
717 MAGI_HOME; this would write into the operator's real \
718 <data_local>/magi. Call `run::set_home(temp_dir)` before any \
719 code path that touches a RunState."
720 );
721 }
722 #[cfg(not(test))]
723 {
724 dirs::data_local_dir()
725 .unwrap_or_else(|| PathBuf::from("."))
726 .join("magi")
727 }
728}
729
730/// Pin the run home for this process. The first call wins.
731pub fn set_home(dir: PathBuf) {
732 let _ = HOME.set(dir);
733}
734
735static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
736
737/// `<home>/runs`.
738pub fn runs_root() -> PathBuf {
739 home().join("runs")
740}
741
742/// The worktree root a run uses when the config sets none: `~/wt/magi`.
743///
744/// One definition of the default, so the folder the janitor folds and the
745/// folder the health view sizes cannot drift apart: a run with no configured
746/// [`crate::config::Graph::worktree_root`] lays its worktrees exactly here.
747pub fn default_worktree_root() -> PathBuf {
748 dirs::home_dir()
749 .unwrap_or_else(|| PathBuf::from("."))
750 .join("wt")
751 .join("magi")
752}
753
754/// Directory for one run id.
755pub fn run_dir(id: &str) -> PathBuf {
756 runs_root().join(id)
757}
758
759/// Every run id on disk, newest first.
760///
761/// A directory is a run because of its **name**, not because it holds a
762/// readable `run.json`. A run whose very first save lost the machine's last
763/// free bytes leaves `<id>/run.json.tmp` and nothing else, and filtering on
764/// `run.json` made that run invisible everywhere: not in `magi list`, not in
765/// `runs_unreadable`, not on the phone, so nothing could report it and no
766/// route could clear it. `88c0` sat like that for two days. Unreadable is
767/// counted, never hidden - the readers already say why each one cannot be
768/// read, and `fold_unreadable` is how a record like this leaves.
769pub fn list_ids() -> Vec<String> {
770 let mut ids: Vec<String> = std::fs::read_dir(runs_root())
771 .into_iter()
772 .flatten()
773 .flatten()
774 .filter(|e| e.path().is_dir())
775 .map(|e| e.file_name().to_string_lossy().into_owned())
776 .filter(|name| is_run_id(name))
777 .collect();
778 // Ids start with a sortable timestamp.
779 ids.sort_unstable_by(|a, b| b.cmp(a));
780 ids
781}
782
783/// Does `name` have the shape [`new_id`] mints: `YYYYMMDD-HHMMSS-xxxx`?
784///
785/// The test for "this directory is a run", so a stray folder under
786/// `<home>/runs` is not reported as a broken run.
787///
788/// The tag is checked for length and for being alphanumeric, not for being
789/// hex: real ids are hex, but fixtures across this crate name runs
790/// `...-dead` / `...-gone` / `...-once`, and a predicate that disowned those
791/// would be asserting the fixtures' spelling rather than the shape.
792pub fn is_run_id(name: &str) -> bool {
793 let mut parts = name.split('-');
794 let (Some(day), Some(time), Some(tag), None) =
795 (parts.next(), parts.next(), parts.next(), parts.next())
796 else {
797 return false;
798 };
799 day.len() == 8
800 && day.bytes().all(|b| b.is_ascii_digit())
801 && time.len() == 6
802 && time.bytes().all(|b| b.is_ascii_digit())
803 && tag.len() == 4
804 && tag.bytes().all(|b| b.is_ascii_alphanumeric())
805}
806
807/// Expand an id prefix to exactly one run id.
808pub fn resolve_id(prefix: &str) -> Result<String> {
809 // A whole id names its directory, readable state or not: the run whose
810 // `run.json` never landed still has to be reachable by `magi show` and
811 // by the fold route, which is the only way its record ever leaves.
812 if is_run_id(prefix) && run_dir(prefix).is_dir() {
813 return Ok(prefix.to_owned());
814 }
815 let hits: Vec<String> = list_ids()
816 .into_iter()
817 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
818 .collect();
819 match hits.len() {
820 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
821 0 => bail!("no run matches `{prefix}`"),
822 _ => bail!(
823 "`{prefix}` matches {} runs: {}",
824 hits.len(),
825 hits.join(", ")
826 ),
827 }
828}
829
830/// The most recent run, if any.
831pub fn latest_id() -> Option<String> {
832 list_ids().into_iter().next()
833}
834
835/// `YYYYMMDD-HHMMSS-xxxx`, sortable and short enough for a branch name.
836///
837/// The four hex digits are fresh entropy, **not** `blind.seed`. They were the
838/// seed, and a pinned seed then made the whole id a function of the second it
839/// started in: two runs a second apart were distinguishable, two in the same
840/// second were not. Everything keyed on the id collided with them - the run
841/// directory, `artifacts/`, and the candidate worktrees under
842/// `wt/magi/<short>/`.
843///
844/// `tests/common` pins the seed on purpose, so its integration tests all share
845/// one suffix. On Windows the suite is slow enough that the seconds differ and
846/// nothing showed; on Linux `graph_dropped_stream`'s three tests run inside
847/// 16s, so two of them shared a run directory and the second read an artifact
848/// the first had written (`impl-B-resume.out`) - a failure that looked like the
849/// resume logic misbehaving and was really two runs in one directory.
850///
851/// A seed exists to make the *blind* decisions reproducible: label assignment
852/// and per-judge presentation order. It was never meant to name the run, and
853/// `RunState::seed` still carries it for what it is for.
854fn new_id() -> String {
855 let stamp = Zoned::now().strftime("%Y%m%d-%H%M%S");
856 let entropy = crate::rng::entropy();
857 format!("{stamp}-{:04x}", (entropy ^ (entropy >> 32)) & 0xffff)
858}
859
860/// Keep the last `max` bytes of `text`, on a line boundary.
861pub fn tail(text: &str, max: usize) -> String {
862 if text.len() <= max {
863 return text.to_owned();
864 }
865 let mut cut = text.len() - max;
866 while cut < text.len() && !text.is_char_boundary(cut) {
867 cut += 1;
868 }
869 let slice = &text[cut..];
870 let start = slice.find('\n').map_or(0, |i| i + 1);
871 format!(
872 "[... {} earlier bytes omitted ...]\n{}",
873 cut,
874 &slice[start..]
875 )
876}
877
878/// Path of a run artifact.
879pub fn artifact_path(run: &RunState, name: &str) -> PathBuf {
880 run.dir().join("artifacts").join(name)
881}
882
883/// Write an artifact, creating the directory if needed.
884pub fn write_artifact(run: &RunState, name: &str, body: &str) -> Result<PathBuf> {
885 let path = artifact_path(run, name);
886 if let Some(parent) = path.parent() {
887 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
888 }
889 std::fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
890 Ok(path)
891}
892
893/// Read an artifact back, e.g. a stored patch on resume.
894pub fn read_artifact(run: &RunState, name: &str) -> Option<String> {
895 std::fs::read_to_string(artifact_path(run, name)).ok()
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901
902 fn state() -> RunState {
903 RunState::new(
904 PathBuf::from("/repo"),
905 "main".to_owned(),
906 "abc1234def".to_owned(),
907 "add retries".to_owned(),
908 Config::default(),
909 )
910 }
911
912 #[test]
913 fn resolve_home_prefers_the_pin_then_the_env_var() {
914 let pinned = PathBuf::from("/pinned");
915 assert_eq!(
916 resolve_home(Some(pinned.clone()), Some("/env".into())),
917 pinned,
918 "a pin wins even over MAGI_HOME"
919 );
920 assert_eq!(
921 resolve_home(None, Some("/env".into())),
922 PathBuf::from("/env")
923 );
924 }
925
926 #[test]
927 #[should_panic(expected = "run::set_home()")]
928 fn resolve_home_refuses_to_fall_back_to_the_operators_real_home() {
929 // Neither override present is exactly the state a test reaches by
930 // forgetting `set_home`/`MAGI_HOME` - the accident that put three
931 // broken fixture runs into the operator's real history. Asserted
932 // against the pure decision directly, not `home()` itself, because
933 // `HOME` is a process-wide `OnceLock` another test may have already
934 // set - this must not depend on test execution order.
935 resolve_home(None, None);
936 }
937
938 #[test]
939 fn a_run_is_named_by_shape_so_a_state_less_directory_is_still_a_run() {
940 // The shape `new_id` mints. A directory answering to it is a run even
941 // with no readable `run.json`: that is how a save that ran out of
942 // disk stays visible instead of vanishing from every listing.
943 assert!(is_run_id(&new_id()));
944 assert!(is_run_id("20260904-014540-88c0"));
945 // Not runs: a stray folder, a truncated id, a non-hex tag, and an id
946 // with an extra segment (a worktree label, say).
947 assert!(!is_run_id("scratch"));
948 assert!(!is_run_id("20260904-014540"));
949 assert!(!is_run_id("20260904-014540-88c0f"));
950 assert!(!is_run_id("2026090x-014540-88c0"));
951 assert!(!is_run_id("20260904-014540-88c0-A"));
952 }
953
954 #[test]
955 fn ids_are_sortable_and_short_suffixed() {
956 let s = state();
957 let parts: Vec<&str> = s.id.split('-').collect();
958 assert_eq!(parts.len(), 3);
959 assert_eq!(parts[0].len(), 8);
960 assert_eq!(parts[1].len(), 6);
961 assert_eq!(parts[2].len(), 4);
962 assert_eq!(s.short(), parts[2]);
963 }
964
965 #[test]
966 fn branch_names_carry_the_label_not_the_author() {
967 let s = state();
968 let b = s.branch_for('B');
969 assert_eq!(b, format!("magi/{}/B", s.short()));
970 assert!(!b.contains("claude"));
971 }
972
973 /// A pinned seed reproduces the blind decisions. It must **not** reproduce
974 /// the run's identity.
975 ///
976 /// `assert_eq!(a.short(), b.short())` used to stand where the last
977 /// assertion is now, and it was pinning the defect: with the id's suffix
978 /// derived from the seed, two runs started in the same second were the
979 /// same run as far as the filesystem was concerned - one directory, one
980 /// `artifacts/`, one set of candidate worktrees. `tests/common` pins a
981 /// seed for every integration test, so on Linux, where the suite is fast,
982 /// two tests in `graph_dropped_stream` shared a directory and one read the
983 /// other's artifact.
984 #[test]
985 fn a_pinned_seed_is_reproducible_but_never_the_run_id() {
986 let mut cfg = Config::default();
987 cfg.blind.seed = Some(1234);
988 let a = RunState::new(
989 PathBuf::from("/r"),
990 "main".to_owned(),
991 "c".to_owned(),
992 "t".to_owned(),
993 cfg.clone(),
994 );
995 let b = RunState::new(
996 PathBuf::from("/r"),
997 "main".to_owned(),
998 "c".to_owned(),
999 "t".to_owned(),
1000 cfg,
1001 );
1002 // What the seed is for: the same shuffles, run after run.
1003 assert_eq!(a.seed, 1234);
1004 assert_eq!(a.seed, b.seed);
1005 // What it is not for. Two runs are two runs, in the same second or
1006 // not, and everything keyed on the id depends on that.
1007 assert_ne!(
1008 a.id, b.id,
1009 "two runs sharing an id share a directory, artifacts and worktrees"
1010 );
1011 }
1012
1013 #[test]
1014 fn status_terminality() {
1015 assert!(RunStatus::Merged.done());
1016 assert!(RunStatus::Blocked.done());
1017 assert!(!RunStatus::Reviewing.done());
1018 }
1019
1020 #[test]
1021 fn candidate_viability_excludes_empty_and_failed() {
1022 let mut c = Candidate {
1023 index: 0,
1024 label: 'A',
1025 agent: "a".to_owned(),
1026 branch: "b".to_owned(),
1027 worktree: PathBuf::from("/w"),
1028 summary: String::new(),
1029 stat: String::new(),
1030 files: 1,
1031 commits: 1,
1032 empty: false,
1033 failed: None,
1034 duration_ms: 0,
1035 folded: false,
1036 };
1037 assert!(c.viable());
1038 c.empty = true;
1039 assert!(!c.viable());
1040 c.empty = false;
1041 c.failed = Some("timeout".to_owned());
1042 assert!(!c.viable());
1043 }
1044
1045 #[test]
1046 fn tail_keeps_the_end_on_a_line_boundary() {
1047 let text = (0..100).map(|i| format!("line {i}\n")).collect::<String>();
1048 let t = tail(&text, 40);
1049 assert!(t.starts_with("[..."));
1050 assert!(t.ends_with("line 99\n"));
1051 assert!(t.len() < 120);
1052 assert_eq!(tail("short", 40), "short");
1053 }
1054
1055 #[test]
1056 fn tail_survives_multibyte_cuts() {
1057 let text = "あ".repeat(50);
1058 let t = tail(&text, 10);
1059 assert!(t.contains("earlier bytes omitted"));
1060 assert!(t.ends_with('あ'));
1061 }
1062
1063 #[test]
1064 fn state_round_trips_through_json() {
1065 let s = state();
1066 let body = serde_json::to_string(&s).unwrap();
1067 let back: RunState = serde_json::from_str(&body).unwrap();
1068 assert_eq!(back.id, s.id);
1069 assert_eq!(back.instruction, "add retries");
1070 assert_eq!(back.status, RunStatus::Prep);
1071 }
1072
1073 #[test]
1074 fn ensure_can_delete_guards_live_and_unfolded_runs() {
1075 let mut s = state();
1076 // 1. A daemon is working on it right now.
1077 s.status = RunStatus::Prep;
1078 let err = s.ensure_can_delete(true).unwrap_err().to_string();
1079 assert!(err.contains("live daemon"), "{err}");
1080
1081 // 2. The same unfinished run with no daemon behind it is a leftover
1082 // from a killed process, and deletable. Without this an interrupted
1083 // run could never be removed: its status stays `prep` forever.
1084 assert!(s.ensure_can_delete(false).is_ok());
1085
1086 // 3. Unfolded candidates are refused either way — that is the guard
1087 // that stops a delete from discarding a worktree.
1088 s.status = RunStatus::Merged;
1089 s.candidates.push(Candidate {
1090 index: 0,
1091 label: 'A',
1092 agent: "a".to_owned(),
1093 branch: "b".to_owned(),
1094 worktree: PathBuf::from("/w"),
1095 summary: String::new(),
1096 stat: String::new(),
1097 files: 1,
1098 commits: 1,
1099 empty: false,
1100 failed: None,
1101 duration_ms: 0,
1102 folded: false,
1103 });
1104 let err = s.ensure_can_delete(false).unwrap_err().to_string();
1105 assert!(
1106 err.contains("magi fold"),
1107 "error must suggest `magi fold`: {err}"
1108 );
1109
1110 // 4. Folded and nobody working on it.
1111 s.candidates[0].folded = true;
1112 assert!(s.ensure_can_delete(false).is_ok());
1113 }
1114}