返回 CodeWhale
review_repair.rs
根目录 / crates / workflow / src / review_repair.rs
1 //! Bounded review→repair automation (#3832).
2 //!
3 //! ## What this is not
4 //!
5 //! It is **not a fourth Mode.** Codewhale has exactly three Modes — Plan, Act,
6 //! Operate — and exactly three permission postures — Ask, Auto-Review, Full
7 //! Access. A review→repair loop is a *Workflow shape*: an ordering of existing
8 //! roles over existing gates. It therefore lives here, beside [`crate::gates`],
9 //! and carries no mode, no posture, and no permission of its own. Whatever Mode
10 //! and posture the session already has continue to govern every step; this type
11 //! only decides *when to stop*.
12 //!
13 //! ## The four things that make it safe
14 //!
15 //! 1. **Explicit ceilings.** [`ReviewRepairBounds`] caps iterations, wall-clock
16 //! seconds, and tool calls. Every ceiling is checked *before* work starts, so
17 //! the loop cannot overrun by one extra iteration; a zero ceiling means the
18 //! loop never runs rather than running forever.
19 //! 2. **Exact routes on the record.** Each iteration must carry a
20 //! [`RouteReceipt`] for the reviewer and, where the policy requires one, the
21 //! verifier: role, exact provider/model, requested→effective reasoning, and
22 //! who routed it. An iteration with no reviewer route is refused.
23 //! 3. **Human ratification where policy says.** When
24 //! [`ReviewRepairPolicy::require_human_ratification`] is set, a clean verify
25 //! does not finish the loop: it parks at
26 //! [`StopReason::AwaitingRatification`] until [`ReviewRepairLoop::ratify`]
27 //! records an explicit human decision.
28 //! 4. **Fail closed on stale input.** The loop pins the digest of the artifact
29 //! under review. If an iteration reports a different digest — the branch
30 //! moved, the diff was rebuilt, the findings came from an older tree — the
31 //! loop halts at [`StopReason::StaleInput`] instead of repairing against
32 //! something the reviewer never saw.
33
34 use serde::{Deserialize, Serialize};
35
36 /// Explicit ceilings on a review→repair loop. There is no unbounded variant.
37 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38 pub struct ReviewRepairBounds {
39 /// Maximum review→repair iterations. `0` means the loop never starts.
40 pub max_iterations: u32,
41 /// Maximum cumulative wall-clock seconds across all iterations.
42 pub max_wall_clock_secs: u64,
43 /// Maximum cumulative tool calls across all iterations.
44 pub max_tool_calls: u32,
45 }
46
47 impl ReviewRepairBounds {
48 /// Conservative defaults for an unattended loop: short, cheap, and easy to
49 /// re-run by hand if it stops early.
50 #[must_use]
51 pub fn conservative() -> Self {
52 Self {
53 max_iterations: 3,
54 max_wall_clock_secs: 900,
55 max_tool_calls: 120,
56 }
57 }
58 }
59
60 impl Default for ReviewRepairBounds {
61 fn default() -> Self {
62 Self::conservative()
63 }
64 }
65
66 /// Who chose the route for a step. Distinguishing these is the difference
67 /// between "the user picked this model" and "a Router picked it".
68 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69 #[serde(rename_all = "snake_case")]
70 pub enum RoutedBy {
71 /// The exact Fleet member route, frozen at Fleet capture.
72 Fleet,
73 /// A Router chose reasoning for the frozen member route. The Router's own
74 /// exact identity is recorded so its spend is attributable.
75 Router { provider: String, model: String },
76 /// The session's current model selection.
77 SessionDefault,
78 }
79
80 impl RoutedBy {
81 /// Human-readable source label for receipts and Workflow rows.
82 #[must_use]
83 pub fn label(&self) -> String {
84 match self {
85 Self::Fleet => "fleet".to_string(),
86 Self::Router { provider, model } => format!("router {provider}/{model}"),
87 Self::SessionDefault => "session default".to_string(),
88 }
89 }
90 }
91
92 /// The exact route a reviewer or verifier ran on.
93 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94 pub struct RouteReceipt {
95 /// Fleet role (`reviewer`, `verifier`, `implementer`, …).
96 pub role: String,
97 pub provider: String,
98 pub model: String,
99 /// What the plan asked for.
100 pub requested_reasoning: String,
101 /// What the provider actually applied. Divergence is shown, not smoothed.
102 pub effective_reasoning: String,
103 pub routed_by: RoutedBy,
104 }
105
106 impl RouteReceipt {
107 /// True when every identity field is present. An incomplete route is not a
108 /// receipt; it is a claim.
109 #[must_use]
110 pub fn is_complete(&self) -> bool {
111 !self.role.trim().is_empty()
112 && !self.provider.trim().is_empty()
113 && !self.model.trim().is_empty()
114 && !self.requested_reasoning.trim().is_empty()
115 && !self.effective_reasoning.trim().is_empty()
116 }
117
118 /// One-line display: role, exact route, requested→effective, routed by.
119 #[must_use]
120 pub fn display_line(&self) -> String {
121 let reasoning = if self.requested_reasoning == self.effective_reasoning {
122 self.effective_reasoning.clone()
123 } else {
124 format!("{}→{}", self.requested_reasoning, self.effective_reasoning)
125 };
126 format!(
127 "{}: {}/{} reasoning {reasoning} (routed by {})",
128 self.role,
129 self.provider,
130 self.model,
131 self.routed_by.label()
132 )
133 }
134 }
135
136 /// The verdict a reviewer/verifier pair produced for one iteration.
137 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
138 #[serde(rename_all = "snake_case")]
139 pub enum IterationVerdict {
140 /// Review and verification both passed. The loop may finish.
141 Clean,
142 /// Findings remain; another repair iteration is warranted.
143 RepairsNeeded,
144 /// The reviewer refused to judge (missing input, tool failure, …). The loop
145 /// stops rather than treating an absent judgment as a pass.
146 Inconclusive,
147 }
148
149 /// What one iteration actually did.
150 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151 pub struct IterationReceipt {
152 /// 1-based iteration number, assigned by the loop.
153 pub iteration: u32,
154 /// Digest of the artifact this iteration reviewed.
155 pub input_digest: String,
156 pub reviewer: RouteReceipt,
157 /// Present when the policy requires a separate verify step.
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub verifier: Option<RouteReceipt>,
160 pub verdict: IterationVerdict,
161 /// Bounded finding summaries. Not the findings themselves.
162 #[serde(default)]
163 pub finding_summaries: Vec<String>,
164 pub tool_calls_used: u32,
165 pub elapsed_secs: u64,
166 }
167
168 /// Why a review→repair loop stopped. Every variant is terminal.
169 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170 #[serde(rename_all = "snake_case")]
171 pub enum StopReason {
172 /// Clean verdict, and either no ratification was required or it was given.
173 Verified,
174 /// A human explicitly rejected the result.
175 RatificationDeclined {
176 note: String,
177 },
178 /// Clean verdict, waiting on the human the policy requires.
179 AwaitingRatification,
180 IterationCeiling {
181 max_iterations: u32,
182 },
183 TimeCeiling {
184 max_wall_clock_secs: u64,
185 elapsed_secs: u64,
186 },
187 ToolCeiling {
188 max_tool_calls: u32,
189 used: u32,
190 },
191 /// The artifact under review changed underneath the loop.
192 StaleInput {
193 pinned: String,
194 reported: String,
195 },
196 /// The reviewer could not judge, or its receipt was incomplete.
197 Inconclusive {
198 reason: String,
199 },
200 }
201
202 impl StopReason {
203 /// True only for the one outcome that means "this loop succeeded".
204 #[must_use]
205 pub fn is_success(&self) -> bool {
206 matches!(self, Self::Verified)
207 }
208
209 /// True when the loop stopped because it hit an explicit ceiling. These are
210 /// honest incompletions, not failures.
211 #[must_use]
212 pub fn is_ceiling(&self) -> bool {
213 matches!(
214 self,
215 Self::IterationCeiling { .. } | Self::TimeCeiling { .. } | Self::ToolCeiling { .. }
216 )
217 }
218
219 /// Stable receipt line. UI surfaces localize around it; the numbers here are
220 /// the ones the user must be able to check.
221 #[must_use]
222 pub fn receipt(&self) -> String {
223 match self {
224 Self::Verified => "verified".to_string(),
225 Self::RatificationDeclined { note } => format!("ratification declined: {note}"),
226 Self::AwaitingRatification => {
227 "clean; awaiting human ratification before this counts as done".to_string()
228 }
229 Self::IterationCeiling { max_iterations } => {
230 format!("stopped at the iteration ceiling ({max_iterations})")
231 }
232 Self::TimeCeiling {
233 max_wall_clock_secs,
234 elapsed_secs,
235 } => format!("stopped at the time ceiling ({elapsed_secs}s of {max_wall_clock_secs}s)"),
236 Self::ToolCeiling {
237 max_tool_calls,
238 used,
239 } => format!("stopped at the tool ceiling ({used} of {max_tool_calls} calls)"),
240 Self::StaleInput { pinned, reported } => format!(
241 "stopped: input changed under review (pinned {pinned}, reported {reported})"
242 ),
243 Self::Inconclusive { reason } => format!("stopped: no usable verdict ({reason})"),
244 }
245 }
246 }
247
248 /// Policy knobs that are not ceilings.
249 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250 pub struct ReviewRepairPolicy {
251 /// A clean verdict parks at [`StopReason::AwaitingRatification`] until a
252 /// human decides.
253 #[serde(default)]
254 pub require_human_ratification: bool,
255 /// Every iteration must carry a verifier receipt in addition to the
256 /// reviewer's. A missing verifier is inconclusive, never a pass.
257 #[serde(default)]
258 pub require_verifier_receipt: bool,
259 }
260
261 impl Default for ReviewRepairPolicy {
262 fn default() -> Self {
263 // Fail closed by default: a human confirms, and review alone is not
264 // verification.
265 Self {
266 require_human_ratification: true,
267 require_verifier_receipt: true,
268 }
269 }
270 }
271
272 /// A bounded review→repair loop over one lane's artifact.
273 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274 pub struct ReviewRepairLoop {
275 pub lane_id: String,
276 pub bounds: ReviewRepairBounds,
277 pub policy: ReviewRepairPolicy,
278 /// Digest of the artifact this loop was authorized to work on.
279 pub pinned_input_digest: String,
280 #[serde(default)]
281 pub iterations: Vec<IterationReceipt>,
282 #[serde(default)]
283 pub elapsed_secs: u64,
284 #[serde(default)]
285 pub tool_calls_used: u32,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub stopped: Option<StopReason>,
288 }
289
290 /// Refusal from [`ReviewRepairLoop::begin_iteration`] or
291 /// [`ReviewRepairLoop::record_iteration`].
292 #[derive(Debug, Clone, PartialEq, Eq)]
293 pub enum ReviewRepairError {
294 /// The loop already stopped; it does not restart.
295 AlreadyStopped(StopReason),
296 /// A ceiling or fail-closed condition ended the loop on this call.
297 Stopped(StopReason),
298 /// The receipt itself was unusable.
299 IncompleteReceipt(String),
300 }
301
302 impl ReviewRepairError {
303 /// The terminal reason, when this refusal carries one.
304 #[must_use]
305 pub fn stop_reason(&self) -> Option<&StopReason> {
306 match self {
307 Self::AlreadyStopped(reason) | Self::Stopped(reason) => Some(reason),
308 Self::IncompleteReceipt(_) => None,
309 }
310 }
311 }
312
313 impl std::fmt::Display for ReviewRepairError {
314 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315 match self {
316 Self::AlreadyStopped(reason) => {
317 write!(f, "review-repair already stopped: {}", reason.receipt())
318 }
319 Self::Stopped(reason) => write!(f, "{}", reason.receipt()),
320 Self::IncompleteReceipt(detail) => {
321 write!(f, "review-repair receipt is unusable: {detail}")
322 }
323 }
324 }
325 }
326
327 impl std::error::Error for ReviewRepairError {}
328
329 impl ReviewRepairLoop {
330 /// Start a loop pinned to the digest of the artifact under review.
331 #[must_use]
332 pub fn new(
333 lane_id: impl Into<String>,
334 pinned_input_digest: impl Into<String>,
335 bounds: ReviewRepairBounds,
336 policy: ReviewRepairPolicy,
337 ) -> Self {
338 Self {
339 lane_id: lane_id.into(),
340 bounds,
341 policy,
342 pinned_input_digest: pinned_input_digest.into(),
343 iterations: Vec::new(),
344 elapsed_secs: 0,
345 tool_calls_used: 0,
346 stopped: None,
347 }
348 }
349
350 /// Whether the loop has stopped, and why.
351 #[must_use]
352 pub fn stop_reason(&self) -> Option<&StopReason> {
353 self.stopped.as_ref()
354 }
355
356 /// Claim the next iteration slot, or refuse.
357 ///
358 /// Ceilings are checked here — *before* any model or tool spend — so a loop
359 /// that has exhausted its budget cannot buy one more iteration to find out.
360 pub fn begin_iteration(&mut self) -> Result<u32, ReviewRepairError> {
361 if let Some(reason) = self.stopped.clone() {
362 return Err(ReviewRepairError::AlreadyStopped(reason));
363 }
364 let next = self.iterations.len() as u32 + 1;
365 if next > self.bounds.max_iterations {
366 return Err(self.stop(StopReason::IterationCeiling {
367 max_iterations: self.bounds.max_iterations,
368 }));
369 }
370 if self.elapsed_secs >= self.bounds.max_wall_clock_secs {
371 return Err(self.stop(StopReason::TimeCeiling {
372 max_wall_clock_secs: self.bounds.max_wall_clock_secs,
373 elapsed_secs: self.elapsed_secs,
374 }));
375 }
376 if self.tool_calls_used >= self.bounds.max_tool_calls {
377 return Err(self.stop(StopReason::ToolCeiling {
378 max_tool_calls: self.bounds.max_tool_calls,
379 used: self.tool_calls_used,
380 }));
381 }
382 Ok(next)
383 }
384
385 /// Record a completed iteration and decide whether the loop continues.
386 ///
387 /// Returns `Ok(None)` when another iteration is warranted, `Ok(Some(reason))`
388 /// when the loop is done.
389 pub fn record_iteration(
390 &mut self,
391 receipt: IterationReceipt,
392 ) -> Result<Option<StopReason>, ReviewRepairError> {
393 if let Some(reason) = self.stopped.clone() {
394 return Err(ReviewRepairError::AlreadyStopped(reason));
395 }
396
397 // Fail closed on stale input before anything in the receipt is trusted:
398 // a verdict about a different tree is not a verdict about this one.
399 if receipt.input_digest != self.pinned_input_digest {
400 return Err(self.stop(StopReason::StaleInput {
401 pinned: self.pinned_input_digest.clone(),
402 reported: receipt.input_digest.clone(),
403 }));
404 }
405 if !receipt.reviewer.is_complete() {
406 return Err(ReviewRepairError::IncompleteReceipt(format!(
407 "reviewer route for iteration {} is missing identity fields",
408 receipt.iteration
409 )));
410 }
411 if self.policy.require_verifier_receipt {
412 match receipt.verifier.as_ref() {
413 None => {
414 return Err(self.stop(StopReason::Inconclusive {
415 reason: "policy requires a verifier receipt and none was produced"
416 .to_string(),
417 }));
418 }
419 Some(verifier) if !verifier.is_complete() => {
420 return Err(ReviewRepairError::IncompleteReceipt(format!(
421 "verifier route for iteration {} is missing identity fields",
422 receipt.iteration
423 )));
424 }
425 Some(_) => {}
426 }
427 }
428
429 self.elapsed_secs = self.elapsed_secs.saturating_add(receipt.elapsed_secs);
430 self.tool_calls_used = self.tool_calls_used.saturating_add(receipt.tool_calls_used);
431 let verdict = receipt.verdict;
432 self.iterations.push(receipt);
433
434 // Overrun is reported honestly: the ceiling is the reason we stop, even
435 // though this iteration's spend already happened.
436 if self.tool_calls_used > self.bounds.max_tool_calls {
437 let reason = StopReason::ToolCeiling {
438 max_tool_calls: self.bounds.max_tool_calls,
439 used: self.tool_calls_used,
440 };
441 self.stopped = Some(reason.clone());
442 return Ok(Some(reason));
443 }
444 if self.elapsed_secs > self.bounds.max_wall_clock_secs {
445 let reason = StopReason::TimeCeiling {
446 max_wall_clock_secs: self.bounds.max_wall_clock_secs,
447 elapsed_secs: self.elapsed_secs,
448 };
449 self.stopped = Some(reason.clone());
450 return Ok(Some(reason));
451 }
452
453 match verdict {
454 IterationVerdict::Inconclusive => {
455 let reason = StopReason::Inconclusive {
456 reason: "reviewer returned no usable verdict".to_string(),
457 };
458 self.stopped = Some(reason.clone());
459 Ok(Some(reason))
460 }
461 IterationVerdict::Clean => {
462 let reason = if self.policy.require_human_ratification {
463 StopReason::AwaitingRatification
464 } else {
465 StopReason::Verified
466 };
467 self.stopped = Some(reason.clone());
468 Ok(Some(reason))
469 }
470 IterationVerdict::RepairsNeeded => {
471 if self.iterations.len() as u32 >= self.bounds.max_iterations {
472 let reason = StopReason::IterationCeiling {
473 max_iterations: self.bounds.max_iterations,
474 };
475 self.stopped = Some(reason.clone());
476 return Ok(Some(reason));
477 }
478 Ok(None)
479 }
480 }
481 }
482
483 /// Record the human decision a parked loop is waiting for.
484 ///
485 /// Only a loop actually parked at [`StopReason::AwaitingRatification`] can be
486 /// ratified: a loop that stopped at a ceiling, on stale input, or
487 /// inconclusively cannot be waved through, because there is no clean result
488 /// to approve.
489 pub fn ratify(
490 &mut self,
491 approved: bool,
492 note: impl Into<String>,
493 ) -> Result<&StopReason, ReviewRepairError> {
494 match self.stopped.clone() {
495 Some(StopReason::AwaitingRatification) => {
496 self.stopped = Some(if approved {
497 StopReason::Verified
498 } else {
499 StopReason::RatificationDeclined { note: note.into() }
500 });
501 Ok(self.stopped.as_ref().expect("just set"))
502 }
503 Some(other) => Err(ReviewRepairError::AlreadyStopped(other)),
504 None => Err(ReviewRepairError::IncompleteReceipt(
505 "the loop has not produced a clean result to ratify".to_string(),
506 )),
507 }
508 }
509
510 /// Every route this loop ran, in order, for the Workflow row and receipts.
511 #[must_use]
512 pub fn route_lines(&self) -> Vec<String> {
513 let mut lines = Vec::new();
514 for iteration in &self.iterations {
515 lines.push(format!(
516 "#{} {}",
517 iteration.iteration,
518 iteration.reviewer.display_line()
519 ));
520 if let Some(verifier) = iteration.verifier.as_ref() {
521 lines.push(format!(
522 "#{} {}",
523 iteration.iteration,
524 verifier.display_line()
525 ));
526 }
527 }
528 lines
529 }
530
531 /// One-line budget statement: what was used against what was allowed.
532 #[must_use]
533 pub fn budget_line(&self) -> String {
534 format!(
535 "iterations {}/{}, tools {}/{}, elapsed {}s/{}s",
536 self.iterations.len(),
537 self.bounds.max_iterations,
538 self.tool_calls_used,
539 self.bounds.max_tool_calls,
540 self.elapsed_secs,
541 self.bounds.max_wall_clock_secs,
542 )
543 }
544
545 fn stop(&mut self, reason: StopReason) -> ReviewRepairError {
546 self.stopped = Some(reason.clone());
547 ReviewRepairError::Stopped(reason)
548 }
549 }
550
551 #[cfg(test)]
552 mod tests {
553 use super::*;
554
555 fn route(role: &str) -> RouteReceipt {
556 RouteReceipt {
557 role: role.to_string(),
558 provider: "zhipu".to_string(),
559 model: "glm-5.2".to_string(),
560 requested_reasoning: "high".to_string(),
561 effective_reasoning: "high".to_string(),
562 routed_by: RoutedBy::Fleet,
563 }
564 }
565
566 fn receipt(iteration: u32, digest: &str, verdict: IterationVerdict) -> IterationReceipt {
567 IterationReceipt {
568 iteration,
569 input_digest: digest.to_string(),
570 reviewer: route("reviewer"),
571 verifier: Some(route("verifier")),
572 verdict,
573 finding_summaries: Vec::new(),
574 tool_calls_used: 5,
575 elapsed_secs: 10,
576 }
577 }
578
579 fn loop_with(bounds: ReviewRepairBounds, policy: ReviewRepairPolicy) -> ReviewRepairLoop {
580 ReviewRepairLoop::new("lane-1", "digest-a", bounds, policy)
581 }
582
583 #[test]
584 fn iteration_ceiling_stops_before_spending_another_turn() {
585 let bounds = ReviewRepairBounds {
586 max_iterations: 2,
587 ..ReviewRepairBounds::conservative()
588 };
589 let mut lane = loop_with(bounds, ReviewRepairPolicy::default());
590
591 for i in 1..=2 {
592 let n = lane.begin_iteration().expect("iteration within ceiling");
593 assert_eq!(n, i);
594 let stop = lane
595 .record_iteration(receipt(i, "digest-a", IterationVerdict::RepairsNeeded))
596 .expect("recorded");
597 if i < 2 {
598 assert!(stop.is_none());
599 } else {
600 assert_eq!(
601 stop,
602 Some(StopReason::IterationCeiling { max_iterations: 2 })
603 );
604 }
605 }
606
607 let err = lane.begin_iteration().expect_err("loop is done");
608 assert!(matches!(err, ReviewRepairError::AlreadyStopped(_)));
609 assert!(lane.stop_reason().expect("stopped").is_ceiling());
610 assert!(!lane.stop_reason().expect("stopped").is_success());
611 }
612
613 #[test]
614 fn zero_iteration_ceiling_never_starts() {
615 let mut lane = loop_with(
616 ReviewRepairBounds {
617 max_iterations: 0,
618 ..ReviewRepairBounds::conservative()
619 },
620 ReviewRepairPolicy::default(),
621 );
622 let err = lane
623 .begin_iteration()
624 .expect_err("a zero ceiling runs nothing");
625 assert_eq!(
626 err.stop_reason(),
627 Some(&StopReason::IterationCeiling { max_iterations: 0 })
628 );
629 }
630
631 #[test]
632 fn tool_and_time_ceilings_stop_the_loop_and_report_the_overrun() {
633 let mut lane = loop_with(
634 ReviewRepairBounds {
635 max_iterations: 5,
636 max_wall_clock_secs: 600,
637 max_tool_calls: 4,
638 },
639 ReviewRepairPolicy::default(),
640 );
641 lane.begin_iteration().expect("first iteration");
642 let stop = lane
643 .record_iteration(receipt(1, "digest-a", IterationVerdict::RepairsNeeded))
644 .expect("recorded");
645 assert_eq!(
646 stop,
647 Some(StopReason::ToolCeiling {
648 max_tool_calls: 4,
649 used: 5
650 })
651 );
652 assert!(lane.budget_line().contains("tools 5/4"));
653
654 let mut timed = loop_with(
655 ReviewRepairBounds {
656 max_iterations: 5,
657 max_wall_clock_secs: 5,
658 max_tool_calls: 100,
659 },
660 ReviewRepairPolicy::default(),
661 );
662 timed.begin_iteration().expect("first iteration");
663 let stop = timed
664 .record_iteration(receipt(1, "digest-a", IterationVerdict::RepairsNeeded))
665 .expect("recorded");
666 assert_eq!(
667 stop,
668 Some(StopReason::TimeCeiling {
669 max_wall_clock_secs: 5,
670 elapsed_secs: 10
671 })
672 );
673 }
674
675 #[test]
676 fn stale_input_fails_closed_without_repairing() {
677 let mut lane = loop_with(
678 ReviewRepairBounds::conservative(),
679 ReviewRepairPolicy::default(),
680 );
681 lane.begin_iteration().expect("first iteration");
682
683 let err = lane
684 .record_iteration(receipt(1, "digest-b", IterationVerdict::Clean))
685 .expect_err("a verdict about another tree is not a verdict about this one");
686
687 assert_eq!(
688 err.stop_reason(),
689 Some(&StopReason::StaleInput {
690 pinned: "digest-a".to_string(),
691 reported: "digest-b".to_string(),
692 })
693 );
694 assert!(lane.iterations.is_empty(), "stale work is not recorded");
695 assert!(!lane.stop_reason().expect("stopped").is_success());
696 }
697
698 #[test]
699 fn clean_verdict_parks_for_human_ratification() {
700 let mut lane = loop_with(
701 ReviewRepairBounds::conservative(),
702 ReviewRepairPolicy::default(),
703 );
704 lane.begin_iteration().expect("first iteration");
705 let stop = lane
706 .record_iteration(receipt(1, "digest-a", IterationVerdict::Clean))
707 .expect("recorded");
708
709 assert_eq!(stop, Some(StopReason::AwaitingRatification));
710 assert!(!lane.stop_reason().expect("stopped").is_success());
711
712 let after = lane.ratify(true, "").expect("ratify a parked loop");
713 assert_eq!(after, &StopReason::Verified);
714 assert!(lane.stop_reason().expect("stopped").is_success());
715 }
716
717 #[test]
718 fn declined_ratification_is_not_success() {
719 let mut lane = loop_with(
720 ReviewRepairBounds::conservative(),
721 ReviewRepairPolicy::default(),
722 );
723 lane.begin_iteration().expect("first iteration");
724 lane.record_iteration(receipt(1, "digest-a", IterationVerdict::Clean))
725 .expect("recorded");
726
727 lane.ratify(false, "diff touches release scripts")
728 .expect("decline is a valid decision");
729 assert_eq!(
730 lane.stop_reason(),
731 Some(&StopReason::RatificationDeclined {
732 note: "diff touches release scripts".to_string()
733 })
734 );
735 assert!(!lane.stop_reason().expect("stopped").is_success());
736 }
737
738 #[test]
739 fn a_ceiling_stop_cannot_be_waved_through_by_ratification() {
740 let mut lane = loop_with(
741 ReviewRepairBounds {
742 max_iterations: 1,
743 ..ReviewRepairBounds::conservative()
744 },
745 ReviewRepairPolicy::default(),
746 );
747 lane.begin_iteration().expect("first iteration");
748 lane.record_iteration(receipt(1, "digest-a", IterationVerdict::RepairsNeeded))
749 .expect("recorded");
750
751 let err = lane
752 .ratify(true, "looks fine to me")
753 .expect_err("there is no clean result to approve");
754 assert!(matches!(err, ReviewRepairError::AlreadyStopped(_)));
755 assert!(!lane.stop_reason().expect("stopped").is_success());
756 }
757
758 #[test]
759 fn without_the_ratification_policy_a_clean_verdict_verifies_directly() {
760 let mut lane = loop_with(
761 ReviewRepairBounds::conservative(),
762 ReviewRepairPolicy {
763 require_human_ratification: false,
764 require_verifier_receipt: true,
765 },
766 );
767 lane.begin_iteration().expect("first iteration");
768 let stop = lane
769 .record_iteration(receipt(1, "digest-a", IterationVerdict::Clean))
770 .expect("recorded");
771 assert_eq!(stop, Some(StopReason::Verified));
772 }
773
774 #[test]
775 fn a_missing_verifier_is_inconclusive_not_a_pass() {
776 let mut lane = loop_with(
777 ReviewRepairBounds::conservative(),
778 ReviewRepairPolicy::default(),
779 );
780 lane.begin_iteration().expect("first iteration");
781 let mut r = receipt(1, "digest-a", IterationVerdict::Clean);
782 r.verifier = None;
783
784 let err = lane
785 .record_iteration(r)
786 .expect_err("review alone is not verification");
787 assert!(matches!(
788 err.stop_reason(),
789 Some(StopReason::Inconclusive { .. })
790 ));
791 }
792
793 #[test]
794 fn an_incomplete_route_is_refused_without_ending_the_loop() {
795 let mut lane = loop_with(
796 ReviewRepairBounds::conservative(),
797 ReviewRepairPolicy::default(),
798 );
799 lane.begin_iteration().expect("first iteration");
800 let mut r = receipt(1, "digest-a", IterationVerdict::Clean);
801 r.reviewer.model = " ".to_string();
802
803 let err = lane
804 .record_iteration(r)
805 .expect_err("a route without a model is a claim");
806 assert!(matches!(err, ReviewRepairError::IncompleteReceipt(_)));
807 assert!(
808 lane.stop_reason().is_none(),
809 "the caller may retry with a real receipt"
810 );
811 }
812
813 #[test]
814 fn inconclusive_review_stops_instead_of_passing() {
815 let mut lane = loop_with(
816 ReviewRepairBounds::conservative(),
817 ReviewRepairPolicy::default(),
818 );
819 lane.begin_iteration().expect("first iteration");
820 let stop = lane
821 .record_iteration(receipt(1, "digest-a", IterationVerdict::Inconclusive))
822 .expect("recorded");
823 assert!(matches!(stop, Some(StopReason::Inconclusive { .. })));
824 }
825
826 #[test]
827 fn route_lines_show_exact_routes_and_requested_to_effective_reasoning() {
828 let mut lane = loop_with(
829 ReviewRepairBounds::conservative(),
830 ReviewRepairPolicy::default(),
831 );
832 lane.begin_iteration().expect("first iteration");
833 let mut r = receipt(1, "digest-a", IterationVerdict::Clean);
834 r.reviewer.requested_reasoning = "max".to_string();
835 r.reviewer.effective_reasoning = "high".to_string();
836 r.reviewer.routed_by = RoutedBy::Router {
837 provider: "moonshot".to_string(),
838 model: "kimi-k3".to_string(),
839 };
840 lane.record_iteration(r).expect("recorded");
841
842 let lines = lane.route_lines();
843 assert_eq!(
844 lines.len(),
845 2,
846 "reviewer and verifier both appear: {lines:?}"
847 );
848 assert!(lines[0].contains("reviewer: zhipu/glm-5.2"));
849 assert!(lines[0].contains("max→high"), "{lines:?}");
850 assert!(lines[0].contains("routed by router moonshot/kimi-k3"));
851 assert!(lines[1].contains("verifier: zhipu/glm-5.2"));
852 }
853
854 #[test]
855 fn default_policy_fails_closed() {
856 let policy = ReviewRepairPolicy::default();
857 assert!(policy.require_human_ratification);
858 assert!(policy.require_verifier_receipt);
859 let bounds = ReviewRepairBounds::default();
860 assert!(bounds.max_iterations > 0);
861 assert!(bounds.max_wall_clock_secs > 0);
862 assert!(bounds.max_tool_calls > 0);
863 }
864
865 #[test]
866 fn review_repair_defines_no_mode_or_permission_posture() {
867 // #3832 must not grow a fourth Mode or a fourth posture. This module's
868 // serialized surface is the check: a loop carries ceilings, routes, and
869 // verdicts — never a mode, posture, approval policy, or sandbox setting.
870 let lane = loop_with(
871 ReviewRepairBounds::conservative(),
872 ReviewRepairPolicy::default(),
873 );
874 let json = serde_json::to_string(&lane).expect("serialize");
875 for forbidden in [
876 "mode",
877 "posture",
878 "approval_policy",
879 "sandbox",
880 "permission",
881 "auto_review",
882 "full_access",
883 ] {
884 assert!(
885 !json.contains(forbidden),
886 "review-repair leaked {forbidden} into its own state: {json}"
887 );
888 }
889 }
890 }
891
891 lines RUST