| 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 |