1use serde::{Deserialize, Serialize};
24
25use crate::run::{CommandOutcome, E2eStatus, ReviewRound, RunState};
26
27pub(crate) async fn restore_repair(wt: &std::path::Path, before: &str) -> anyhow::Result<()> {
29 crate::git::restore_to(wt, before).await?;
30 let tip = crate::git::rev_parse(wt, "HEAD").await?;
31 anyhow::ensure!(tip == before, "the winner is at {tip}, not {before}");
32 Ok(())
33}
34
35fn short(sha: &str) -> &str {
36 sha.get(..8).unwrap_or(sha)
37}
38use crate::verdict::{Finding, ReviewVote};
39
40pub const MAX_GENERATION: u32 = 2;
43
44pub const TAIL_MAX: usize = 6_000;
46
47pub const SINCE_DIFF_MAX: usize = 60_000;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "kebab-case")]
53pub enum Mode {
54 VerifyRepair,
57 FullReview,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "kebab-case")]
64pub enum Source {
65 E2e,
67 Gate,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Evidence {
74 pub source: Source,
76 pub command: String,
78 pub code: Option<i32>,
80 pub output_tail: String,
82 pub head: String,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct PriorRound {
89 pub round: usize,
91 pub head: String,
93 pub blocking: usize,
95 pub answered: usize,
97 pub expected: usize,
99 pub verdict: Option<ReviewVote>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "kebab-case")]
107pub enum Repair {
108 NotStarted,
110 Started {
112 before: String,
114 },
115 WaitingForRestore {
117 before: String,
119 },
120 Done {
122 before: String,
124 after: String,
126 committed: bool,
128 failed: Option<String>,
130 },
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct RecoveryRecord {
136 pub from_run: String,
138 pub generation: u32,
140 pub mode: Mode,
142 #[serde(default)]
145 pub reasons: Vec<String>,
146 #[serde(default)]
148 pub reviewed_head: Option<String>,
149 #[serde(default)]
151 pub rounds: Vec<PriorRound>,
152 #[serde(default)]
154 pub open_findings: Vec<Finding>,
155 #[serde(default)]
157 pub evidence: Option<Evidence>,
158 #[serde(default)]
160 pub criteria_inherited: bool,
161 #[serde(default = "not_started")]
163 pub repair: Repair,
164}
165
166fn not_started() -> Repair {
167 Repair::NotStarted
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum Relation {
173 Same,
175 Descendant,
177 Diverged,
179}
180
181pub fn tail_of(text: &str, max: usize) -> String {
183 if text.len() <= max {
184 return text.to_owned();
185 }
186 let mut start = text.len() - max;
187 while !text.is_char_boundary(start) {
188 start += 1;
189 }
190 format!("[...]\n{}", &text[start..])
191}
192
193pub fn head_of(text: &str, max: usize) -> String {
195 if text.len() <= max {
196 return text.to_owned();
197 }
198 let mut end = max;
199 while !text.is_char_boundary(end) {
200 end -= 1;
201 }
202 format!("{}\n[... truncated]", &text[..end])
203}
204
205fn evidence_from(round: &ReviewRound, prior: &RunState) -> Option<Evidence> {
206 let scrub = |s: &str| {
207 crate::scrub::scrub(
208 &crate::run::summarize_verify(s, TAIL_MAX),
209 &crate::scrub::Identity::current(),
210 )
211 };
212 let failed = |o: &&CommandOutcome| !o.ok() && !o.resource_blocked;
213 if round.e2e_status() == E2eStatus::Failed
214 && let (Some(o), Some(head)) = (round.e2e.iter().find(failed), &round.verified_head)
215 {
216 return Some(Evidence {
217 source: Source::E2e,
218 command: o.command.clone(),
219 code: o.code,
220 output_tail: scrub(&o.output_tail),
221 head: head.clone(),
222 });
223 }
224 if prior.gate_ran
227 && prior.gate_fixes.is_empty()
228 && let Some(o) = prior.gate.iter().find(failed)
229 {
230 return Some(Evidence {
231 source: Source::Gate,
232 command: o.command.clone(),
233 code: o.code,
234 output_tail: scrub(&o.output_tail),
235 head: round.head.clone(),
236 });
237 }
238 None
239}
240
241pub fn assess(
247 from_run: &str,
248 prior: Option<&RunState>,
249 base_now: &str,
250 relation: Relation,
251) -> RecoveryRecord {
252 let mut rec = RecoveryRecord {
253 from_run: from_run.to_owned(),
254 generation: 0,
255 mode: Mode::FullReview,
256 reasons: Vec::new(),
257 reviewed_head: None,
258 rounds: Vec::new(),
259 open_findings: Vec::new(),
260 evidence: None,
261 criteria_inherited: false,
262 repair: Repair::NotStarted,
263 };
264 let Some(prior) = prior else {
265 rec.reasons
266 .push("the earlier run's record could not be read".to_owned());
267 return rec;
268 };
269 rec.generation = prior.recovery.as_ref().map_or(0, |r| r.generation) + 1;
270 rec.rounds = prior
271 .reviews
272 .iter()
273 .map(|r| PriorRound {
274 round: r.round,
275 head: r.head.clone(),
276 blocking: r.blocking,
277 answered: r.answered,
278 expected: r.expected,
279 verdict: r.verdict,
280 })
281 .collect();
282 let Some(last) = prior.reviews.last() else {
283 rec.reasons
284 .push("the earlier run never completed a review round".to_owned());
285 return rec;
286 };
287 rec.reviewed_head = Some(last.head.clone());
288 rec.open_findings = last
289 .reviews
290 .iter()
291 .flat_map(|r| r.findings.iter().cloned())
292 .collect();
293 rec.evidence = evidence_from(last, prior);
294
295 if rec.generation > MAX_GENERATION {
296 rec.reasons.push(format!(
297 "evidence has already been handed over {MAX_GENERATION} times"
298 ));
299 }
300 if last.answered < last.expected || last.reviews.iter().any(|r| r.failed.is_some()) {
301 rec.reasons.push(format!(
302 "only {} of {} reviewer seat(s) answered the last round; a panel that did not \
303 answer is not a clean one",
304 last.answered, last.expected
305 ));
306 }
307 if last.blocking > 0 {
308 rec.reasons.push(format!(
309 "the last round still had {} blocking finding(s)",
310 last.blocking
311 ));
312 }
313 match &rec.evidence {
314 None => rec
315 .reasons
316 .push("no failed verification with a known command was recorded".to_owned()),
317 Some(e) if e.output_tail.trim().is_empty() => rec
318 .reasons
319 .push("the failed verification kept no output to act on".to_owned()),
320 Some(e) if e.head != last.head => {
321 let sync = prior.base_sync.as_ref().map_or_else(
322 || "no base sync recorded".to_owned(),
323 |sync| {
324 format!(
325 "base sync to {}, {} rebase attempt(s), conflict: {}",
326 sync.tip,
327 sync.attempts,
328 sync.conflict.as_deref().unwrap_or("none")
329 )
330 },
331 );
332 rec.reasons.push(format!(
333 "the failed verification was about a different commit {} than the one reviewed {}; \
334 {sync}; additional review is required before approval can carry forward",
335 e.head, last.head,
336 ));
337 }
338 Some(_) => {}
339 }
340 match relation {
341 Relation::Same => {}
342 Relation::Descendant => rec.reasons.push(
343 "commits were added after the reviewed commit; they were never reviewed".to_owned(),
344 ),
345 Relation::Diverged => rec.reasons.push(
346 "the reviewed commit is not an ancestor of the branch: it was rebased or rewritten"
347 .to_owned(),
348 ),
349 }
350 if prior.base_commit != base_now {
351 rec.reasons.push(format!(
352 "the base moved from {} to {}",
353 short(&prior.base_commit),
354 short(base_now)
355 ));
356 }
357 if rec.reasons.is_empty() {
358 rec.mode = Mode::VerifyRepair;
359 }
360 rec
361}
362
363pub fn recheck(rec: &mut RecoveryRecord, head: &str) -> bool {
367 if rec.mode != Mode::VerifyRepair {
368 return false;
369 }
370 let Some(reviewed) = rec.reviewed_head.as_deref() else {
371 return false;
372 };
373 if reviewed == head {
374 return false;
375 }
376 rec.mode = Mode::FullReview;
377 rec.reasons.push(format!(
378 "the branch moved from {} to {} before the review started (rebased onto the base or \
379 changed); the earlier approval does not carry",
380 short(reviewed),
381 short(head)
382 ));
383 true
384}
385
386impl RecoveryRecord {
387 pub fn summary(&self) -> String {
389 let from = crate::run::short_of(&self.from_run);
390 match self.mode {
391 Mode::VerifyRepair => format!(
392 "inherited from run {from}: reviewed commit, {} round(s), failed verification \
393 `{}`; repair first, then a review scoped to the changes since",
394 self.rounds.len(),
395 self.evidence.as_ref().map_or("?", |e| e.command.as_str())
396 ),
397 Mode::FullReview => format!(
398 "full review after run {from}: {}",
399 if self.reasons.is_empty() {
400 "no narrower recovery applied".to_owned()
401 } else {
402 self.reasons.join("; ")
403 }
404 ),
405 }
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::run::ReviewRecord;
413 use crate::verdict::Severity;
414
415 fn outcome(code: i32, tail: &str) -> CommandOutcome {
416 CommandOutcome {
417 command: "cargo test".to_owned(),
418 code: Some(code),
419 output_tail: tail.to_owned(),
420 duration_ms: 1,
421 resource_blocked: false,
422 }
423 }
424
425 fn seat(failed: bool, findings: Vec<Finding>) -> ReviewRecord {
426 ReviewRecord {
427 reviewer: 1,
428 agent: "a".to_owned(),
429 summary: String::new(),
430 findings,
431 vote: Some(ReviewVote::Approve),
432 failed: failed.then(|| "timed out".to_owned()),
433 duration_ms: 0,
434 attempts: 0,
435 }
436 }
437
438 fn round(head: &str, e2e: Vec<CommandOutcome>) -> ReviewRound {
439 ReviewRound {
440 round: 3,
441 head: head.to_owned(),
442 verified_head: Some(head.to_owned()),
443 verified_at: None,
444 reviews: vec![seat(false, Vec::new())],
445 e2e,
446 verify_retried: false,
447 e2e_deferred: false,
448 e2e_defer_reason: None,
449 fix: None,
450 blocking: 0,
451 answered: 1,
452 expected: 1,
453 clean: false,
454 progressed: false,
455 vote_split: false,
456 reconsideration: Vec::new(),
457 verdict: Some(ReviewVote::Approve),
458 }
459 }
460
461 fn prior(round: ReviewRound) -> RunState {
462 let mut s = RunState::new(
463 std::path::PathBuf::from("/tmp/x"),
464 "main".to_owned(),
465 "base1".to_owned(),
466 "task".to_owned(),
467 crate::config::Config::default(),
468 );
469 s.reviews.push(round);
470 s
471 }
472
473 fn failing() -> RunState {
474 prior(round("aaa111", vec![outcome(101, "1 failed: quiet_spawn")]))
475 }
476
477 #[test]
478 fn unchanged_commit_with_a_nonblocking_failed_verification_is_a_verify_repair() {
479 let r = assess("r1", Some(&failing()), "base1", Relation::Same);
480 assert_eq!(r.mode, Mode::VerifyRepair, "{:?}", r.reasons);
481 assert!(r.reasons.is_empty());
482 assert_eq!(r.generation, 1);
483 let e = r.evidence.unwrap();
484 assert_eq!(e.command, "cargo test");
485 assert_eq!(e.head, "aaa111");
486 assert!(e.output_tail.contains("quiet_spawn"));
487 }
488
489 #[test]
490 fn verification_recovery_preserves_panic_before_large_backtrace() {
491 let output = format!(
492 "[full output: artifacts/e2e-1.out]\n---- widget stdout ----\nwidget log\nthread 'widget' panicked at widget.rs:12:\nassertion failed: widget missing\n{}test result: FAILED\n",
493 " 41: 0x1234 - backtrace frame\n".repeat(10000)
494 );
495 let p = prior(round("aaa111", vec![outcome(101, &output)]));
496 let r = assess("r1", Some(&p), "base1", Relation::Same);
497 let prompt = crate::prompt::recovery_fix("repair widget", &r, 2, "en");
498 assert!(prompt.contains("panicked at widget.rs:12"));
499 assert!(prompt.contains("assertion failed: widget missing"));
500 let evidence = r.evidence.unwrap();
501 assert!(evidence.output_tail.len() <= TAIL_MAX);
502 assert!(evidence.output_tail.contains("panicked at widget.rs:12"));
503 assert!(
504 evidence
505 .output_tail
506 .contains("assertion failed: widget missing")
507 );
508 assert!(evidence.output_tail.contains("artifacts/e2e-1.out"));
509 }
510
511 #[test]
512 fn descendant_rebased_and_moved_base_widen_the_review() {
513 let p = failing();
514 let d = assess("r1", Some(&p), "base1", Relation::Descendant);
515 assert_eq!(d.mode, Mode::FullReview);
516 assert!(d.reasons[0].contains("never reviewed"));
517 let x = assess("r1", Some(&p), "base1", Relation::Diverged);
518 assert_eq!(x.mode, Mode::FullReview);
519 assert!(x.reasons[0].contains("rebased"));
520 let b = assess("r1", Some(&p), "base2", Relation::Same);
521 assert_eq!(b.mode, Mode::FullReview);
522 assert!(b.reasons[0].contains("base moved"));
523 assert!(b.evidence.is_some());
525 }
526
527 #[test]
528 fn missing_or_empty_evidence_falls_back_to_a_full_review() {
529 let none = prior(round("aaa111", Vec::new()));
530 let r = assess("r1", Some(&none), "base1", Relation::Same);
531 assert_eq!(r.mode, Mode::FullReview);
532 assert!(r.reasons[0].contains("no failed verification"));
533 let empty = prior(round("aaa111", vec![outcome(1, " \n")]));
534 let r = assess("r1", Some(&empty), "base1", Relation::Same);
535 assert_eq!(r.mode, Mode::FullReview);
536 assert!(r.reasons[0].contains("no output"));
537 let r = assess("r1", None, "base1", Relation::Same);
538 assert_eq!(r.mode, Mode::FullReview);
539 assert!(r.reasons[0].contains("could not be read"));
540 }
541
542 #[test]
543 fn evidence_about_another_commit_is_not_reused() {
544 let mut p = failing();
545 p.reviews[0].verified_head = Some("bbb222".to_owned());
546 let r = assess("r1", Some(&p), "base1", Relation::Same);
547 assert_eq!(r.mode, Mode::FullReview);
548 assert!(r.reasons[0].contains("different commit"));
549 }
550
551 #[test]
552 fn a_timed_out_seat_or_a_blocking_finding_is_never_a_clean_panel() {
553 let mut p = failing();
554 p.reviews[0].answered = 0;
555 p.reviews[0].reviews[0] = seat(true, Vec::new());
556 let r = assess("r1", Some(&p), "base1", Relation::Same);
557 assert_eq!(r.mode, Mode::FullReview);
558 assert!(r.reasons[0].contains("did not answer"));
559
560 let mut p = failing();
561 p.reviews[0].blocking = 1;
562 p.reviews[0].reviews[0].findings = vec![Finding {
563 id: "R3-1-1".to_owned(),
564 severity: Severity::Major,
565 file: None,
566 line: None,
567 title: "t".to_owned(),
568 detail: String::new(),
569 }];
570 let r = assess("r1", Some(&p), "base1", Relation::Same);
571 assert_eq!(r.mode, Mode::FullReview);
572 assert_eq!(r.open_findings.len(), 1);
573 }
574
575 #[test]
576 fn generations_are_bounded() {
577 let mut p = failing();
578 let mut inherited = assess("r0", Some(&failing()), "base1", Relation::Same);
579 inherited.generation = MAX_GENERATION;
580 p.recovery = Some(inherited);
581 let r = assess("r1", Some(&p), "base1", Relation::Same);
582 assert_eq!(r.generation, MAX_GENERATION + 1);
583 assert_eq!(r.mode, Mode::FullReview);
584 assert!(r.reasons[0].contains("handed over"));
585 }
586
587 #[test]
588 fn a_gate_failure_counts_only_while_no_gate_fix_moved_the_tree() {
589 let mut p = prior(round("aaa111", Vec::new()));
590 p.gate_ran = true;
591 p.gate = vec![outcome(2, "gate red")];
592 let r = assess("r1", Some(&p), "base1", Relation::Same);
593 assert_eq!(r.mode, Mode::VerifyRepair);
594 assert_eq!(r.evidence.unwrap().source, Source::Gate);
595 p.gate_fixes.push(crate::run::GateFixRecord {
596 agent: "a".to_owned(),
597 failed: Vec::new(),
598 notes: String::new(),
599 committed: true,
600 error: None,
601 });
602 let r = assess("r1", Some(&p), "base1", Relation::Same);
603 assert_eq!(r.mode, Mode::FullReview);
604 }
605
606 #[test]
607 fn recheck_downgrades_a_moved_branch() {
608 let mut r = assess("r1", Some(&failing()), "base1", Relation::Same);
609 assert!(!recheck(&mut r, "aaa111"));
610 assert_eq!(r.mode, Mode::VerifyRepair);
611 assert!(recheck(&mut r, "ccc333"));
612 assert_eq!(r.mode, Mode::FullReview);
613 assert!(r.reasons[0].contains("moved"));
614 }
615
616 #[test]
617 fn tails_are_clipped_on_char_boundaries() {
618 let s = "あ".repeat(100);
619 assert!(tail_of(&s, 10).len() < 30);
620 assert!(head_of(&s, 10).starts_with('あ'));
621 assert_eq!(tail_of("ok", 10), "ok");
622 }
623}