返回 CodeWhale
gates.rs
根目录 / crates / workflow / src / gates.rs
1 //! Workflow gate nodes and role-to-role handoffs (#4179).
2 //!
3 //! Gates live in the Workflow definition; Fleet only supplies roles.
4 //! Handoff artifacts are **lane-scoped** (keyed by lane id), never fleet-scoped.
5 //!
6 //! Gate semantics:
7 //! - **block** — downstream role cannot start until the gate passes or a human
8 //! override approves
9 //! - **approve** — promote an artifact into the next role's context substrate
10 //! - **escalate** — after N retries, surface to parent / lane status
11 //!
12 //! This module is pure IR + evaluation. Runtime admission and Lane status UI
13 //! consume the same board; gates do not grant tool or publication authority.
14
15 use std::collections::BTreeMap;
16 use std::path::{Path, PathBuf};
17
18 use serde::{Deserialize, Serialize};
19 use thiserror::Error;
20
21 /// When a gate fires relative to role lifecycle.
22 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23 #[serde(rename_all = "snake_case")]
24 pub enum GateOn {
25 /// After a fleet role task completes successfully.
26 RoleComplete,
27 /// Before a fleet role is allowed to start.
28 /// Hosts without start-time gate evaluation must refuse this trigger.
29 RoleStart,
30 }
31
32 /// Kind of verification / review gate.
33 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34 #[serde(rename_all = "snake_case")]
35 pub enum GateKind {
36 /// Compile/test/lint suite (verifier role; #4013).
37 Verify,
38 /// Diff review (reviewer role).
39 Review,
40 /// Accept a role's result and promote its handoff artifact. This kind does
41 /// not imply human approval; an explicit override is `GateOutcome::HumanApprove`.
42 Approve,
43 }
44
45 /// Policy when a gate fails.
46 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47 #[serde(rename_all = "snake_case")]
48 pub enum GateOnFail {
49 /// Re-run the upstream role (up to `max_retries`).
50 Retry,
51 /// Block downstream until resolved.
52 Block,
53 /// Surface to parent / lane status after retries exhausted.
54 Escalate,
55 }
56
57 /// One gate node in a Workflow definition.
58 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59 pub struct GateSpec {
60 pub id: String,
61 /// Role whose completion (or start) triggers this gate.
62 pub role: String,
63 #[serde(rename = "on")]
64 pub on: GateOn,
65 pub gate: GateKind,
66 pub on_fail: GateOnFail,
67 /// Downstream role blocked until this gate passes.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub blocks_role: Option<String>,
70 /// Max retries before escalate (default 1).
71 #[serde(default = "default_max_retries")]
72 pub max_retries: u32,
73 /// Optional artifact type this gate produces/consumes (e.g. `findings`).
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub artifact_kind: Option<String>,
76 /// Require a standalone first-line PASS/APPROVE/BLOCK/FAIL verdict from a
77 /// successfully completed role. When enabled, missing or malformed
78 /// verdicts fail closed instead of inheriting legacy pass-on-success
79 /// behavior.
80 #[serde(default, skip_serializing_if = "is_false")]
81 pub require_explicit_verdict: bool,
82 }
83
84 fn default_max_retries() -> u32 {
85 1
86 }
87
88 fn is_false(value: &bool) -> bool {
89 !*value
90 }
91
92 /// Live state of one gate within a lane.
93 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94 #[serde(rename_all = "snake_case")]
95 pub enum GateState {
96 /// A required outcome has not arrived; dependent roles must wait.
97 Pending,
98 Passed,
99 Blocked {
100 reason: String,
101 },
102 Retrying {
103 attempt: u32,
104 reason: String,
105 },
106 Escalated {
107 reason: String,
108 },
109 }
110
111 impl GateState {
112 pub fn as_str(&self) -> &'static str {
113 match self {
114 Self::Pending => "pending",
115 Self::Passed => "passed",
116 Self::Blocked { .. } => "blocked",
117 Self::Retrying { .. } => "retrying",
118 Self::Escalated { .. } => "escalated",
119 }
120 }
121
122 pub fn is_blocking(&self) -> bool {
123 !matches!(self, Self::Passed)
124 }
125
126 pub fn blocked_reason(&self) -> Option<&str> {
127 match self {
128 Self::Pending => Some("waiting for required gate outcome"),
129 Self::Blocked { reason }
130 | Self::Retrying { reason, .. }
131 | Self::Escalated { reason } => Some(reason.as_str()),
132 Self::Passed => None,
133 }
134 }
135 }
136
137 /// Outcome reported by a verifier/reviewer/human for a gate evaluation.
138 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139 #[serde(rename_all = "snake_case")]
140 pub enum GateOutcome {
141 Pass,
142 Fail {
143 reason: String,
144 },
145 /// Explicit human override that clears a block.
146 HumanApprove {
147 note: String,
148 },
149 }
150
151 /// Lane-scoped handoff artifact produced by a role for the next role.
152 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153 pub struct HandoffArtifact {
154 pub id: String,
155 pub lane_id: String,
156 /// Producing fleet role (e.g. `scout`).
157 pub from_role: String,
158 /// Consuming fleet role (e.g. `implementer`).
159 pub to_role: String,
160 /// Artifact kind (`findings`, `diff`, `verify_report`, …).
161 pub kind: String,
162 /// Opaque payload (JSON text or path reference).
163 pub payload: String,
164 pub created_at: String,
165 }
166
167 /// In-memory (and serializable) gate + handoff store for one lane.
168 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
169 pub struct LaneGateBoard {
170 pub lane_id: String,
171 /// Gate id → current state.
172 #[serde(default)]
173 pub gates: BTreeMap<String, GateState>,
174 /// Retry counters per gate id.
175 #[serde(default)]
176 pub retries: BTreeMap<String, u32>,
177 /// Handoff artifacts for this lane.
178 #[serde(default)]
179 pub artifacts: Vec<HandoffArtifact>,
180 }
181
182 impl LaneGateBoard {
183 pub fn new(lane_id: impl Into<String>) -> Self {
184 Self {
185 lane_id: lane_id.into(),
186 gates: BTreeMap::new(),
187 retries: BTreeMap::new(),
188 artifacts: Vec::new(),
189 }
190 }
191
192 /// Register gate specs in pending state.
193 pub fn install_gates(&mut self, specs: &[GateSpec]) {
194 for spec in specs {
195 self.gates
196 .entry(spec.id.clone())
197 .or_insert(GateState::Pending);
198 }
199 }
200
201 /// Evaluate a gate against an outcome; updates board state.
202 pub fn evaluate(
203 &mut self,
204 spec: &GateSpec,
205 outcome: GateOutcome,
206 ) -> Result<GateState, GateError> {
207 if spec.id.trim().is_empty() {
208 return Err(GateError::EmptyGateId);
209 }
210 match outcome {
211 GateOutcome::Pass | GateOutcome::HumanApprove { .. } => {
212 let state = GateState::Passed;
213 self.gates.insert(spec.id.clone(), state.clone());
214 self.retries.remove(&spec.id);
215 Ok(state)
216 }
217 GateOutcome::Fail { reason } => {
218 let attempt = self.retries.entry(spec.id.clone()).or_insert(0);
219 *attempt = attempt.saturating_add(1);
220 let attempt = *attempt;
221 let state = match spec.on_fail {
222 GateOnFail::Block => GateState::Blocked { reason },
223 GateOnFail::Retry if attempt <= spec.max_retries => {
224 GateState::Retrying { attempt, reason }
225 }
226 GateOnFail::Retry | GateOnFail::Escalate => GateState::Escalated { reason },
227 };
228 self.gates.insert(spec.id.clone(), state.clone());
229 Ok(state)
230 }
231 }
232 }
233
234 /// Whether `role` has an unmet prerequisite. Missing board entries are
235 /// pending too: an incomplete persisted board cannot imply a passed gate.
236 pub fn role_is_blocked(&self, specs: &[GateSpec], role: &str) -> Option<&GateState> {
237 static PENDING: GateState = GateState::Pending;
238 let role = role.trim();
239 if role.is_empty() {
240 return None;
241 }
242 for spec in specs {
243 let blocks = spec
244 .blocks_role
245 .as_deref()
246 .unwrap_or("")
247 .trim()
248 .eq_ignore_ascii_case(role);
249 if !blocks {
250 continue;
251 }
252 let state = self.gates.get(&spec.id).unwrap_or(&PENDING);
253 if state.is_blocking() {
254 return Some(state);
255 }
256 }
257 None
258 }
259
260 /// Record a scout→implementer (etc.) handoff artifact.
261 pub fn record_handoff(&mut self, artifact: HandoffArtifact) -> Result<(), GateError> {
262 if artifact.lane_id != self.lane_id {
263 return Err(GateError::LaneMismatch {
264 expected: self.lane_id.clone(),
265 got: artifact.lane_id,
266 });
267 }
268 self.artifacts.push(artifact);
269 Ok(())
270 }
271
272 /// Latest handoff of `kind` from `from_role` to `to_role`, if any.
273 pub fn latest_handoff(
274 &self,
275 from_role: &str,
276 to_role: &str,
277 kind: &str,
278 ) -> Option<&HandoffArtifact> {
279 self.artifacts.iter().rev().find(|a| {
280 a.from_role.eq_ignore_ascii_case(from_role)
281 && a.to_role.eq_ignore_ascii_case(to_role)
282 && a.kind.eq_ignore_ascii_case(kind)
283 })
284 }
285
286 /// Remove and return the latest handoffs addressed to `to_role` (newest
287 /// first), up to `limit`.
288 ///
289 /// Handoffs are single-use: the consumer that receives one is also issued
290 /// a `HandoffConsumed` receipt, so leaving the artifact on the board would
291 /// re-deliver it to every later same-role task — stale evidence, repeated
292 /// token cost, and a receipt that lies about being spent.
293 pub fn consume_handoffs_for(&mut self, to_role: &str, limit: usize) -> Vec<HandoffArtifact> {
294 // Collect matching indices newest-first; removing in descending index
295 // order keeps every not-yet-removed index valid.
296 let mut picked: Vec<usize> = Vec::new();
297 for (index, artifact) in self.artifacts.iter().enumerate().rev() {
298 if picked.len() >= limit {
299 break;
300 }
301 if artifact.to_role.eq_ignore_ascii_case(to_role) {
302 picked.push(index);
303 }
304 }
305 let mut consumed = Vec::with_capacity(picked.len());
306 for index in picked {
307 consumed.push(self.artifacts.remove(index));
308 }
309 consumed
310 }
311
312 /// Persist board under a lane directory (JSON).
313 pub fn save_to_dir(&self, dir: &Path) -> Result<PathBuf, GateError> {
314 std::fs::create_dir_all(dir).map_err(|e| GateError::Io(e.to_string()))?;
315 let path = dir.join("gates.json");
316 let json =
317 serde_json::to_string_pretty(self).map_err(|e| GateError::Serde(e.to_string()))?;
318 std::fs::write(&path, json).map_err(|e| GateError::Io(e.to_string()))?;
319 Ok(path)
320 }
321
322 pub fn load_from_dir(dir: &Path) -> Result<Self, GateError> {
323 let path = dir.join("gates.json");
324 let text = std::fs::read_to_string(&path).map_err(|e| GateError::Io(e.to_string()))?;
325 serde_json::from_str(&text).map_err(|e| GateError::Serde(e.to_string()))
326 }
327
328 /// Compact status for `lane status` / panel surfaces.
329 pub fn status_summary(&self) -> Vec<GateStatusLine> {
330 self.gates
331 .iter()
332 .map(|(id, state)| GateStatusLine {
333 gate_id: id.clone(),
334 state: state.as_str().to_string(),
335 blocked_reason: state.blocked_reason().map(str::to_string),
336 })
337 .collect()
338 }
339 }
340
341 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342 pub struct GateStatusLine {
343 pub gate_id: String,
344 pub state: String,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub blocked_reason: Option<String>,
347 }
348
349 #[derive(Debug, Clone, PartialEq, Eq, Error)]
350 pub enum GateError {
351 #[error("gate id must not be empty")]
352 EmptyGateId,
353 #[error("handoff lane id `{got}` does not match board lane `{expected}`")]
354 LaneMismatch { expected: String, got: String },
355 #[error("io error: {0}")]
356 Io(String),
357 #[error("serde error: {0}")]
358 Serde(String),
359 }
360
361 /// Canonical stopship-style gate pipeline (scout → implementer → reviewer → verifier → release_lead).
362 pub fn stopship_gate_pipeline() -> Vec<GateSpec> {
363 vec![
364 GateSpec {
365 id: "scout-findings".into(),
366 role: "scout".into(),
367 on: GateOn::RoleComplete,
368 gate: GateKind::Approve,
369 on_fail: GateOnFail::Block,
370 blocks_role: Some("implementer".into()),
371 max_retries: 0,
372 artifact_kind: Some("findings".into()),
373 require_explicit_verdict: false,
374 },
375 GateSpec {
376 id: "reviewer-diff".into(),
377 role: "reviewer".into(),
378 on: GateOn::RoleComplete,
379 gate: GateKind::Review,
380 on_fail: GateOnFail::Block,
381 blocks_role: Some("verifier".into()),
382 max_retries: 1,
383 artifact_kind: Some("diff_review".into()),
384 require_explicit_verdict: false,
385 },
386 GateSpec {
387 id: "verifier-suite".into(),
388 role: "verifier".into(),
389 on: GateOn::RoleComplete,
390 gate: GateKind::Verify,
391 on_fail: GateOnFail::Retry,
392 blocks_role: Some("release_lead".into()),
393 max_retries: 2,
394 artifact_kind: Some("verify_report".into()),
395 require_explicit_verdict: false,
396 },
397 ]
398 }
399
400 #[cfg(test)]
401 mod tests {
402 use super::*;
403 use tempfile::tempdir;
404
405 #[test]
406 fn scout_handoff_passes_findings_to_implementer() {
407 let mut board = LaneGateBoard::new("lane-test");
408 let gates = stopship_gate_pipeline();
409 board.install_gates(&gates);
410
411 board
412 .record_handoff(HandoffArtifact {
413 id: "art-1".into(),
414 lane_id: "lane-test".into(),
415 from_role: "scout".into(),
416 to_role: "implementer".into(),
417 kind: "findings".into(),
418 payload: r#"{"issue":4090,"files":["app.rs"]}"#.into(),
419 created_at: "2026-07-09T00:00:00Z".into(),
420 })
421 .unwrap();
422
423 let art = board
424 .latest_handoff("scout", "implementer", "findings")
425 .expect("findings artifact");
426 assert_eq!(art.id, "art-1");
427 assert!(art.payload.contains("4090"));
428 assert_eq!(
429 board.role_is_blocked(&gates, "implementer"),
430 Some(&GateState::Pending),
431 "an artifact alone does not satisfy its approval gate"
432 );
433
434 // Approve scout gate so implementer unblocks.
435 let state = board.evaluate(&gates[0], GateOutcome::Pass).unwrap();
436 assert_eq!(state, GateState::Passed);
437 assert!(board.role_is_blocked(&gates, "implementer").is_none());
438 }
439
440 #[test]
441 fn pending_prerequisite_blocks_only_its_target_until_passed() {
442 let mut board = LaneGateBoard::new("lane-pending");
443 let mut gates = stopship_gate_pipeline();
444 gates[0].blocks_role = Some(" Implementer ".into());
445 board.install_gates(&gates);
446
447 let pending = board
448 .role_is_blocked(&gates, " IMPLEMENTER ")
449 .expect("pending prerequisite blocks normalized role");
450 assert_eq!(pending, &GateState::Pending);
451 assert_eq!(
452 pending.blocked_reason(),
453 Some("waiting for required gate outcome")
454 );
455 assert!(board.role_is_blocked(&gates, "scout").is_none());
456 assert!(board.role_is_blocked(&gates, "unrelated").is_none());
457 assert!(board.role_is_blocked(&gates, "").is_none());
458
459 board.evaluate(&gates[0], GateOutcome::Pass).unwrap();
460 assert!(board.role_is_blocked(&gates, "implementer").is_none());
461 assert_eq!(
462 board.role_is_blocked(&gates, "verifier"),
463 Some(&GateState::Pending),
464 "another role's gate remains unsatisfied"
465 );
466 }
467
468 #[test]
469 fn every_prerequisite_must_pass_before_the_shared_target_runs() {
470 let mut board = LaneGateBoard::new("lane-multiple");
471 let mut gates = stopship_gate_pipeline();
472 gates[0].blocks_role = Some("release_lead".into());
473 board.install_gates(&gates);
474
475 board.evaluate(&gates[0], GateOutcome::Pass).unwrap();
476 assert_eq!(
477 board.role_is_blocked(&gates, "release_lead"),
478 Some(&GateState::Pending),
479 "scout approval cannot satisfy the verifier prerequisite"
480 );
481 board.evaluate(&gates[2], GateOutcome::Pass).unwrap();
482 assert!(board.role_is_blocked(&gates, "release_lead").is_none());
483 assert_eq!(
484 board.role_is_blocked(&gates, "verifier"),
485 Some(&GateState::Pending),
486 "unrelated pending prerequisites retain their own targets"
487 );
488 }
489
490 #[test]
491 fn missing_persisted_gate_is_pending_and_reinstall_preserves_real_passes() {
492 let dir = tempdir().unwrap();
493 let gates = stopship_gate_pipeline();
494 let mut board = LaneGateBoard::new("lane-incomplete");
495 board.install_gates(&gates);
496 board.evaluate(&gates[0], GateOutcome::Pass).unwrap();
497 board.gates.remove(&gates[1].id);
498 board.save_to_dir(dir.path()).unwrap();
499
500 let mut restored = LaneGateBoard::load_from_dir(dir.path()).unwrap();
501 assert!(restored.role_is_blocked(&gates, "implementer").is_none());
502 assert_eq!(
503 restored.role_is_blocked(&gates, "verifier"),
504 Some(&GateState::Pending),
505 "absence of a gate record is not successful evaluation"
506 );
507 restored.install_gates(&gates);
508 assert!(restored.role_is_blocked(&gates, "implementer").is_none());
509 assert_eq!(restored.gates[&gates[1].id], GateState::Pending);
510 restored.evaluate(&gates[1], GateOutcome::Pass).unwrap();
511 assert!(restored.role_is_blocked(&gates, "verifier").is_none());
512 }
513
514 #[test]
515 fn cancelled_prerequisite_stays_blocked_through_retry_and_escalation() {
516 let mut board = LaneGateBoard::new("lane-cancelled");
517 let gates = stopship_gate_pipeline();
518 let verify = &gates[2];
519 board.install_gates(&gates);
520 assert_eq!(
521 board.role_is_blocked(&gates, "release_lead"),
522 Some(&GateState::Pending)
523 );
524
525 for attempt in 1..=verify.max_retries + 1 {
526 let state = board
527 .evaluate(
528 verify,
529 GateOutcome::Fail {
530 reason: "prerequisite worker cancelled".into(),
531 },
532 )
533 .unwrap();
534 if attempt <= verify.max_retries {
535 assert!(
536 matches!(state, GateState::Retrying { attempt: actual, .. } if actual == attempt)
537 );
538 } else {
539 assert!(matches!(state, GateState::Escalated { .. }));
540 }
541 assert_eq!(board.role_is_blocked(&gates, "release_lead"), Some(&state));
542 assert_eq!(board.retries[&verify.id], attempt);
543 }
544
545 board
546 .evaluate(
547 verify,
548 GateOutcome::HumanApprove {
549 note: "operator reviewed the prerequisite evidence".into(),
550 },
551 )
552 .unwrap();
553 assert!(board.role_is_blocked(&gates, "release_lead").is_none());
554 assert!(!board.retries.contains_key(&verify.id));
555 }
556
557 #[test]
558 fn handoff_is_consumed_exactly_once() {
559 let mut board = LaneGateBoard::new("lane-consume");
560 let record = |board: &mut LaneGateBoard, id: &str, to_role: &str, payload: &str| {
561 board
562 .record_handoff(HandoffArtifact {
563 id: id.into(),
564 lane_id: "lane-consume".into(),
565 from_role: "scout".into(),
566 to_role: to_role.into(),
567 kind: "findings".into(),
568 payload: payload.into(),
569 created_at: "2026-08-03T00:00:00Z".into(),
570 })
571 .unwrap();
572 };
573 record(&mut board, "art-old", "implementer", "first");
574 record(&mut board, "art-new", "implementer", "second");
575 record(&mut board, "art-other", "reviewer", "untouched");
576
577 let consumed = board.consume_handoffs_for("implementer", 4);
578 assert_eq!(consumed.len(), 2);
579 assert_eq!(consumed[0].id, "art-new", "newest handoff delivers first");
580 assert_eq!(consumed[1].id, "art-old");
581
582 assert!(
583 board.consume_handoffs_for("implementer", 4).is_empty(),
584 "a consumed handoff must not be delivered again"
585 );
586 assert_eq!(
587 board.artifacts.len(),
588 1,
589 "handoffs for other roles stay on the board"
590 );
591 assert_eq!(board.artifacts[0].id, "art-other");
592 }
593
594 #[test]
595 fn reviewer_block_prevents_verifier() {
596 let mut board = LaneGateBoard::new("lane-rev");
597 let gates = stopship_gate_pipeline();
598 board.install_gates(&gates);
599
600 let state = board
601 .evaluate(
602 &gates[1],
603 GateOutcome::Fail {
604 reason: "regression in Ctrl+C path".into(),
605 },
606 )
607 .unwrap();
608 assert!(matches!(state, GateState::Blocked { .. }));
609 let blocked = board
610 .role_is_blocked(&gates, "verifier")
611 .expect("verifier blocked");
612 assert_eq!(blocked.blocked_reason(), Some("regression in Ctrl+C path"));
613 }
614
615 #[test]
616 fn verifier_retry_then_escalate() {
617 let mut board = LaneGateBoard::new("lane-ver");
618 let gates = stopship_gate_pipeline();
619 board.install_gates(&gates);
620 let verify = &gates[2];
621 assert_eq!(verify.max_retries, 2);
622
623 let s1 = board
624 .evaluate(
625 verify,
626 GateOutcome::Fail {
627 reason: "cargo test failed".into(),
628 },
629 )
630 .unwrap();
631 assert!(matches!(s1, GateState::Retrying { attempt: 1, .. }));
632
633 let s2 = board
634 .evaluate(
635 verify,
636 GateOutcome::Fail {
637 reason: "cargo test failed again".into(),
638 },
639 )
640 .unwrap();
641 assert!(matches!(s2, GateState::Retrying { attempt: 2, .. }));
642
643 let s3 = board
644 .evaluate(
645 verify,
646 GateOutcome::Fail {
647 reason: "still red".into(),
648 },
649 )
650 .unwrap();
651 assert!(matches!(s3, GateState::Escalated { .. }));
652 assert!(board.role_is_blocked(&gates, "release_lead").is_some());
653 }
654
655 #[test]
656 fn human_approve_clears_block() {
657 let mut board = LaneGateBoard::new("lane-hum");
658 let gates = stopship_gate_pipeline();
659 board.install_gates(&gates);
660 board
661 .evaluate(
662 &gates[1],
663 GateOutcome::Fail {
664 reason: "needs human".into(),
665 },
666 )
667 .unwrap();
668 assert!(board.role_is_blocked(&gates, "verifier").is_some());
669
670 let state = board
671 .evaluate(
672 &gates[1],
673 GateOutcome::HumanApprove {
674 note: "override: known flaky".into(),
675 },
676 )
677 .unwrap();
678 assert_eq!(state, GateState::Passed);
679 assert!(board.role_is_blocked(&gates, "verifier").is_none());
680 }
681
682 #[test]
683 fn board_persists_for_lane_status() {
684 let dir = tempdir().unwrap();
685 let mut board = LaneGateBoard::new("lane-persist");
686 board.install_gates(&stopship_gate_pipeline());
687 board
688 .evaluate(
689 &stopship_gate_pipeline()[0],
690 GateOutcome::Fail {
691 reason: "scout incomplete".into(),
692 },
693 )
694 .unwrap();
695 board.save_to_dir(dir.path()).unwrap();
696 let loaded = LaneGateBoard::load_from_dir(dir.path()).unwrap();
697 let summary = loaded.status_summary();
698 assert!(
699 summary
700 .iter()
701 .any(|l| l.gate_id == "scout-findings" && l.state == "blocked")
702 );
703 }
704 }
705
705 lines RUST