1use crate::events::{Event, EventKind};
35use chrono::{DateTime, Utc};
36use serde::{Deserialize, Serialize};
37
38const RUBBER_STAMP_THRESHOLD_MS: u64 = 10_000;
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct TracedDefect {
46 pub ticket: String,
48 pub mission_id: String,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct AutonomyMetric {
56 pub closed_missions: u64,
57 pub zero_intervention_missions: u64,
58 pub zero_intervention_share: Option<f64>,
60 pub completed: AutonomyOutcomeSplit,
61 pub failed: AutonomyOutcomeSplit,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct AutonomyOutcomeSplit {
68 pub missions: u64,
69 pub zero_intervention: u64,
70 pub zero_intervention_share: Option<f64>,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct RubberStamp {
78 pub decided_grants: u64,
80 pub p50_ms: Option<u64>,
82 pub p90_ms: Option<u64>,
83 pub under_ten_seconds: u64,
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct FalseGreenSplit {
92 pub completed_missions: u64,
93 pub false_greens: u64,
94 pub rate: Option<f64>,
96}
97
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct FalseGreens {
102 pub completed_missions: u64,
103 pub false_greens: u64,
104 pub false_green_rate: Option<f64>,
106 pub with_interventions: FalseGreenSplit,
109 pub zero_intervention: FalseGreenSplit,
110 pub traced_defects: Vec<TracedDefect>,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "lowercase")]
118pub enum LedgerKind {
119 Grant,
120 Steer,
121}
122
123impl LedgerKind {
124 pub fn as_str(&self) -> &'static str {
126 match self {
127 Self::Grant => "grant",
128 Self::Steer => "steer",
129 }
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct LedgerRow {
138 pub ts: DateTime<Utc>,
139 pub mission_id: String,
140 pub kind: LedgerKind,
141 pub milestone_id: Option<String>,
143 pub ask: String,
145 pub decision: String,
147 pub latency_ms: Option<u64>,
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct EscalationMetrics {
155 pub autonomy: AutonomyMetric,
156 pub rubber_stamp: RubberStamp,
157 pub false_greens: FalseGreens,
158 pub ledger: Vec<LedgerRow>,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163enum TerminalOutcome {
164 Completed,
165 Failed,
166 Abandoned,
169}
170
171#[derive(Debug, Clone, PartialEq)]
174pub struct MissionEscalation {
175 terminal: Option<TerminalOutcome>,
176 interventions: u64,
177 latencies_ms: Vec<u64>,
178 ledger: Vec<LedgerRow>,
179}
180
181pub(crate) fn grant_kind_str(kind: &crate::types::GrantKind) -> &'static str {
185 match kind {
186 crate::types::GrantKind::Command => "command",
187 crate::types::GrantKind::TouchPath => "touch-path",
188 crate::types::GrantKind::WorkerDeny => "worker-deny",
189 crate::types::GrantKind::Egress => "egress",
190 }
191}
192
193pub(crate) fn is_engine_lift(reason: &str, context: Option<&crate::types::BlockContext>) -> bool {
198 match context {
199 Some(context) => context.is_workspace_gate(),
200 None => reason == crate::workspace_gate::GATE_LIFT_REASON,
201 }
202}
203
204pub(crate) fn pair_grant_decisions(mission_events: &[&Event]) -> Vec<(usize, Option<usize>)> {
217 let mut used_decisions = vec![false; mission_events.len()];
218 let mut pairs = Vec::new();
219 for (req_idx, req) in mission_events.iter().enumerate() {
220 let EventKind::GrantRequested { command, .. } = &req.kind else {
221 continue;
222 };
223
224 let mut matched: Option<usize> = None;
225 for (i, cand) in mission_events.iter().enumerate() {
226 if i <= req_idx || used_decisions[i] {
227 continue;
228 }
229 let cand_command = match &cand.kind {
230 EventKind::GrantApproved { command, .. }
231 | EventKind::GrantDenied { command, .. } => command,
232 _ => continue,
233 };
234 if cand_command == command {
235 matched = Some(i);
236 break;
237 }
238 }
239 if matched.is_none() {
240 for (i, cand) in mission_events.iter().enumerate() {
244 if i <= req_idx || used_decisions[i] {
245 continue;
246 }
247 if matches!(
248 cand.kind,
249 EventKind::GrantApproved { .. } | EventKind::GrantDenied { .. }
250 ) {
251 matched = Some(i);
252 break;
253 }
254 }
255 }
256 if let Some(i) = matched {
257 used_decisions[i] = true;
258 }
259 pairs.push((req_idx, matched));
260 }
261 pairs
262}
263
264pub fn mission_escalation(mission_id: &str, events: &[Event]) -> MissionEscalation {
269 let mission_events: Vec<&Event> = events
270 .iter()
271 .filter(|e| e.mission_id == mission_id)
272 .collect();
273
274 let terminal = mission_events.iter().find_map(|e| match &e.kind {
275 EventKind::MissionCompleted {} => Some(TerminalOutcome::Completed),
276 EventKind::MissionFailed { .. } => Some(TerminalOutcome::Failed),
277 EventKind::MissionAbandoned { .. } => Some(TerminalOutcome::Abandoned),
278 _ => None,
279 });
280
281 let plan_approved_seq = mission_events
282 .iter()
283 .find(|e| matches!(e.kind, EventKind::PlanApproved { .. }))
284 .map(|e| e.seq);
285
286 let mut interventions: u64 = 0;
287 let mut ledger = Vec::new();
288
289 for e in &mission_events {
291 match &e.kind {
292 EventKind::UserMessage { text, .. } => {
293 if let Some(approved_seq) = plan_approved_seq {
298 if e.seq >= approved_seq {
299 interventions += 1;
300 ledger.push(LedgerRow {
301 ts: e.ts,
302 mission_id: mission_id.to_string(),
303 kind: LedgerKind::Steer,
304 milestone_id: None,
305 ask: text.clone(),
306 decision: "steered".to_string(),
307 latency_ms: None,
308 });
309 }
310 }
311 }
312 EventKind::GrantApproved { .. }
313 | EventKind::GrantDenied { .. }
314 | EventKind::PlanRevised { .. }
315 | EventKind::PlanRevisionRejected { .. } => {
316 interventions += 1;
317 }
318 EventKind::MilestoneUnblocked {
319 reason,
320 block_context,
321 ..
322 } if !is_engine_lift(reason, block_context.as_ref()) => {
323 interventions += 1;
324 }
325 _ => {}
326 }
327 }
328
329 let mut latencies_ms = Vec::new();
333 for (req_idx, matched) in pair_grant_decisions(&mission_events) {
334 let req = mission_events[req_idx];
335 let EventKind::GrantRequested {
336 milestone_id,
337 kind,
338 command,
339 } = &req.kind
340 else {
341 unreachable!("pair_grant_decisions only returns grant.requested indices")
342 };
343
344 let (decision, latency_ms) = match matched {
345 Some(i) => {
346 let decided = mission_events[i];
347 let latency = (decided.ts - req.ts).num_milliseconds();
348 let latency_ms = if latency >= 0 {
349 Some(latency as u64)
350 } else {
351 None
352 };
353 if let Some(l) = latency_ms {
354 latencies_ms.push(l);
355 }
356 let decision = match &decided.kind {
357 EventKind::GrantApproved { .. } => "approved".to_string(),
358 EventKind::GrantDenied { reason, .. } => format!("denied: {reason}"),
359 _ => unreachable!(),
360 };
361 (decision, latency_ms)
362 }
363 None => ("pending".to_string(), None),
364 };
365
366 ledger.push(LedgerRow {
367 ts: req.ts,
368 mission_id: mission_id.to_string(),
369 kind: LedgerKind::Grant,
370 milestone_id: Some(milestone_id.clone()),
371 ask: format!("{}: {command}", grant_kind_str(kind)),
372 decision,
373 latency_ms,
374 });
375 }
376
377 MissionEscalation {
378 terminal,
379 interventions,
380 latencies_ms,
381 ledger,
382 }
383}
384
385fn percentile_nearest_rank(sorted: &[u64], p: u64) -> Option<u64> {
389 if sorted.is_empty() {
390 return None;
391 }
392 let n = sorted.len() as u64;
393 let rank = p.saturating_mul(n).saturating_add(99) / 100;
394 let rank = rank.clamp(1, n);
395 Some(sorted[(rank - 1) as usize])
396}
397
398pub fn aggregate(
402 missions: &[(String, Vec<Event>)],
403 traced_defects: &[TracedDefect],
404) -> EscalationMetrics {
405 let mut closed_missions: u64 = 0;
406 let mut zero_intervention_missions: u64 = 0;
407 let mut completed_missions: u64 = 0;
408 let mut completed_zero_intervention: u64 = 0;
409 let mut failed_missions: u64 = 0;
410 let mut failed_zero_intervention: u64 = 0;
411 let mut all_latencies_ms = Vec::new();
412 let mut ledger = Vec::new();
413 let mut completed_with_interventions: Vec<&str> = Vec::new();
415 let mut completed_clean: Vec<&str> = Vec::new();
416
417 for (mission_id, events) in missions {
418 let fold = mission_escalation(mission_id, events);
419 match fold.terminal {
420 Some(TerminalOutcome::Completed) => {
421 closed_missions += 1;
422 completed_missions += 1;
423 if fold.interventions == 0 {
424 zero_intervention_missions += 1;
425 completed_zero_intervention += 1;
426 completed_clean.push(mission_id);
427 } else {
428 completed_with_interventions.push(mission_id);
429 }
430 }
431 Some(TerminalOutcome::Failed) => {
432 closed_missions += 1;
433 failed_missions += 1;
434 if fold.interventions == 0 {
435 zero_intervention_missions += 1;
436 failed_zero_intervention += 1;
437 }
438 }
439 Some(TerminalOutcome::Abandoned) | None => {}
440 }
441 all_latencies_ms.extend(fold.latencies_ms);
442 ledger.extend(fold.ledger);
443 }
444
445 let mut joined: Vec<TracedDefect> = Vec::new();
450 let mut false_green_with_interventions: u64 = 0;
451 let mut false_green_clean: u64 = 0;
452 for defect in traced_defects {
453 let id = defect.mission_id.as_str();
454 let is_completed =
455 completed_with_interventions.contains(&id) || completed_clean.contains(&id);
456 if is_completed {
457 joined.push(defect.clone());
458 }
459 }
460 let counted: std::collections::HashSet<&str> =
461 joined.iter().map(|d| d.mission_id.as_str()).collect();
462 for id in &completed_with_interventions {
463 if counted.contains(id) {
464 false_green_with_interventions += 1;
465 }
466 }
467 for id in &completed_clean {
468 if counted.contains(id) {
469 false_green_clean += 1;
470 }
471 }
472 joined.sort_by(|a, b| {
473 a.mission_id
474 .cmp(&b.mission_id)
475 .then_with(|| a.ticket.cmp(&b.ticket))
476 });
477 let false_greens_total = false_green_with_interventions + false_green_clean;
478
479 all_latencies_ms.sort_unstable();
480 let decided = all_latencies_ms.len() as u64;
481 let under_ten_seconds = all_latencies_ms
482 .iter()
483 .filter(|&&l| l < RUBBER_STAMP_THRESHOLD_MS)
484 .count() as u64;
485
486 ledger.sort_by_key(|row| std::cmp::Reverse(row.ts));
487
488 EscalationMetrics {
489 autonomy: AutonomyMetric {
490 closed_missions,
491 zero_intervention_missions,
492 zero_intervention_share: (closed_missions > 0)
493 .then(|| zero_intervention_missions as f64 / closed_missions as f64),
494 completed: AutonomyOutcomeSplit {
495 missions: completed_missions,
496 zero_intervention: completed_zero_intervention,
497 zero_intervention_share: (completed_missions > 0)
498 .then(|| completed_zero_intervention as f64 / completed_missions as f64),
499 },
500 failed: AutonomyOutcomeSplit {
501 missions: failed_missions,
502 zero_intervention: failed_zero_intervention,
503 zero_intervention_share: (failed_missions > 0)
504 .then(|| failed_zero_intervention as f64 / failed_missions as f64),
505 },
506 },
507 rubber_stamp: RubberStamp {
508 decided_grants: decided,
509 p50_ms: percentile_nearest_rank(&all_latencies_ms, 50),
510 p90_ms: percentile_nearest_rank(&all_latencies_ms, 90),
511 under_ten_seconds,
512 },
513 false_greens: FalseGreens {
514 completed_missions,
515 false_greens: false_greens_total,
516 false_green_rate: (completed_missions > 0)
517 .then(|| false_greens_total as f64 / completed_missions as f64),
518 with_interventions: FalseGreenSplit {
519 completed_missions: completed_with_interventions.len() as u64,
520 false_greens: false_green_with_interventions,
521 rate: (!completed_with_interventions.is_empty()).then(|| {
522 false_green_with_interventions as f64
523 / completed_with_interventions.len() as f64
524 }),
525 },
526 zero_intervention: FalseGreenSplit {
527 completed_missions: completed_clean.len() as u64,
528 false_greens: false_green_clean,
529 rate: (!completed_clean.is_empty())
530 .then(|| false_green_clean as f64 / completed_clean.len() as f64),
531 },
532 traced_defects: joined,
533 },
534 ledger,
535 }
536}
537
538pub(crate) fn traced_defects_from_tickets(repo_root: &std::path::Path) -> Vec<TracedDefect> {
545 let mut out: Vec<TracedDefect> = crate::ticket::Ticket::list(repo_root)
546 .into_iter()
547 .filter_map(|ticket| {
548 ticket.traced_from_mission.map(|mission_id| TracedDefect {
549 ticket: ticket.slug,
550 mission_id,
551 })
552 })
553 .collect();
554 out.sort_by(|a, b| {
555 a.mission_id
556 .cmp(&b.mission_id)
557 .then_with(|| a.ticket.cmp(&b.ticket))
558 });
559 out
560}
561
562pub fn compute_escalation_metrics(
570 repo_root: &std::path::Path,
571) -> anyhow::Result<EscalationMetrics> {
572 let index_contents = std::fs::read_to_string(
573 crate::paths::MissionPaths::new(repo_root, "_")
574 .missions_dir()
575 .join("index.md"),
576 )
577 .unwrap_or_default();
578
579 let mut ids = crate::paths::MissionPaths::list_missions(repo_root);
580 for id in crate::mission_catalog::mission_index_ids(&index_contents) {
581 if !ids.contains(&id) {
582 ids.push(id);
583 }
584 }
585 ids.sort();
586
587 let mut missions = Vec::new();
588 for id in ids {
589 let paths = crate::paths::MissionPaths::new(repo_root, &id);
590 let events_path = paths.events_file();
591 if !events_path.is_file() {
592 continue;
593 }
594 if paths.require_no_follow().is_err() {
597 continue;
598 }
599 let events = match crate::event_log::EventLog::read_events(&events_path) {
600 Ok(events) => events,
601 Err(_) => continue, };
603 missions.push((id, events));
604 }
605
606 Ok(aggregate(
607 &missions,
608 &traced_defects_from_tickets(repo_root),
609 ))
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615 use crate::event_log::{EventLog, LockForce};
616 use crate::paths::MissionPaths;
617 use crate::types::{GrantKind, MissionConfig, Plan};
618 use std::time::Duration;
619 use tempfile::TempDir;
620
621 fn ev(seq: u64, mission_id: &str, ts_ms: i64, kind: EventKind) -> Event {
622 Event {
623 seq,
624 ts: DateTime::from_timestamp_millis(ts_ms).unwrap(),
625 mission_id: mission_id.to_string(),
626 kind,
627 }
628 }
629
630 fn sample_plan() -> Plan {
631 Plan {
632 goal: "g".into(),
633 validation_contract: vec![],
634 milestones: vec![],
635 considered_alternatives: None,
636 command_grants: vec![],
637 touch_set: vec![],
638 standards_manifest: None,
639 reviewer_independence: None,
640 }
641 }
642
643 fn created() -> EventKind {
644 EventKind::MissionCreated {
645 goal: "g".into(),
646 base_branch: "main".into(),
647 mission_branch: "kranz/mission-x".into(),
648 config: MissionConfig::default(),
649 }
650 }
651
652 fn anti_vacuity_missions() -> Vec<(String, Vec<Event>)> {
661 let m1 = vec![
662 ev(1, "m-1", 0, created()),
663 ev(2, "m-1", 1_000, EventKind::MissionCompleted {}),
664 ];
665 let m2 = vec![
666 ev(1, "m-2", 0, created()),
667 ev(
668 2,
669 "m-2",
670 1_000,
671 EventKind::PlanApproved {
672 plan: sample_plan(),
673 base_sha: None,
674 },
675 ),
676 ev(
677 3,
678 "m-2",
679 10_000,
680 EventKind::GrantRequested {
681 milestone_id: "ms-1".into(),
682 kind: GrantKind::Command,
683 command: "cargo test".into(),
684 },
685 ),
686 ev(
687 4,
688 "m-2",
689 15_000,
690 EventKind::GrantApproved {
691 kind: GrantKind::Command,
692 command: "cargo test".into(),
693 },
694 ),
695 ev(
696 5,
697 "m-2",
698 20_000,
699 EventKind::GrantRequested {
700 milestone_id: "ms-1".into(),
701 kind: GrantKind::Egress,
702 command: "registry.npmjs.org:443".into(),
703 },
704 ),
705 ev(
706 6,
707 "m-2",
708 920_000,
709 EventKind::GrantDenied {
710 kind: GrantKind::Egress,
711 command: "registry.npmjs.org:443".into(),
712 reason: "not needed".into(),
713 },
714 ),
715 ev(7, "m-2", 921_000, EventKind::MissionCompleted {}),
716 ];
717 let m3 = vec![
718 ev(1, "m-3", 0, created()),
719 ev(
720 2,
721 "m-3",
722 1_000,
723 EventKind::PlanApproved {
724 plan: sample_plan(),
725 base_sha: None,
726 },
727 ),
728 ev(
729 3,
730 "m-3",
731 2_000,
732 EventKind::UserMessage {
733 text: "skip the flaky test".into(),
734 interrupt: false,
735 },
736 ),
737 ev(
738 4,
739 "m-3",
740 3_000,
741 EventKind::MissionFailed {
742 reason: "honest failure".into(),
743 },
744 ),
745 ];
746 vec![
747 ("m-1".to_string(), m1),
748 ("m-2".to_string(), m2),
749 ("m-3".to_string(), m3),
750 ]
751 }
752
753 #[test]
754 fn escalation_metrics_autonomy_ratio_split_by_outcome() {
755 let metrics = aggregate(&anti_vacuity_missions(), &[]);
756 let autonomy = &metrics.autonomy;
757 assert_eq!(autonomy.closed_missions, 3);
758 assert_eq!(autonomy.zero_intervention_missions, 1);
759 assert_eq!(autonomy.zero_intervention_share, Some(1.0 / 3.0));
760 assert_eq!(autonomy.completed.missions, 2);
761 assert_eq!(autonomy.completed.zero_intervention, 1);
762 assert_eq!(autonomy.completed.zero_intervention_share, Some(0.5));
763 assert_eq!(autonomy.failed.missions, 1);
764 assert_eq!(autonomy.failed.zero_intervention, 0);
765 assert_eq!(autonomy.failed.zero_intervention_share, Some(0.0));
766 }
767
768 #[test]
769 fn escalation_metrics_rubber_stamp_percentiles_and_under_10s() {
770 let metrics = aggregate(&anti_vacuity_missions(), &[]);
771 let stamp = &metrics.rubber_stamp;
772 assert_eq!(stamp.decided_grants, 2);
773 assert_eq!(stamp.p50_ms, Some(5_000));
775 assert_eq!(stamp.p90_ms, Some(900_000));
776 assert_eq!(stamp.under_ten_seconds, 1);
777 }
778
779 #[test]
780 fn escalation_metrics_percentiles_empty_population_is_none() {
781 let metrics = aggregate(&[], &[]);
782 assert_eq!(metrics.rubber_stamp.decided_grants, 0);
783 assert_eq!(metrics.rubber_stamp.p50_ms, None);
784 assert_eq!(metrics.rubber_stamp.p90_ms, None);
785 assert_eq!(metrics.autonomy.zero_intervention_share, None);
786 assert_eq!(metrics.false_greens.false_green_rate, None);
787 }
788
789 #[test]
790 fn escalation_metrics_false_greens_join_and_split() {
791 let traced = vec![
792 TracedDefect {
794 ticket: "defect-login-regression".into(),
795 mission_id: "m-1".into(),
796 },
797 TracedDefect {
799 ticket: "defect-on-a-failure".into(),
800 mission_id: "m-3".into(),
801 },
802 TracedDefect {
804 ticket: "defect-unknown-mission".into(),
805 mission_id: "m-999".into(),
806 },
807 ];
808 let metrics = aggregate(&anti_vacuity_missions(), &traced);
809 let fg = &metrics.false_greens;
810 assert_eq!(fg.completed_missions, 2);
811 assert_eq!(fg.false_greens, 1);
812 assert_eq!(fg.false_green_rate, Some(0.5));
813 assert_eq!(fg.with_interventions.completed_missions, 1);
814 assert_eq!(fg.with_interventions.false_greens, 0);
815 assert_eq!(fg.with_interventions.rate, Some(0.0));
816 assert_eq!(fg.zero_intervention.completed_missions, 1);
817 assert_eq!(fg.zero_intervention.false_greens, 1);
818 assert_eq!(fg.zero_intervention.rate, Some(1.0));
819 assert_eq!(
820 fg.traced_defects,
821 vec![TracedDefect {
822 ticket: "defect-login-regression".into(),
823 mission_id: "m-1".into(),
824 }]
825 );
826 }
827
828 #[test]
829 fn escalation_metrics_two_defects_one_mission_count_once() {
830 let traced = vec![
831 TracedDefect {
832 ticket: "defect-a".into(),
833 mission_id: "m-1".into(),
834 },
835 TracedDefect {
836 ticket: "defect-b".into(),
837 mission_id: "m-1".into(),
838 },
839 ];
840 let metrics = aggregate(&anti_vacuity_missions(), &traced);
841 assert_eq!(metrics.false_greens.false_greens, 1);
842 assert_eq!(metrics.false_greens.traced_defects.len(), 2);
843 }
844
845 #[test]
846 fn escalation_metrics_ledger_rows_grants_and_steers() {
847 let metrics = aggregate(&anti_vacuity_missions(), &[]);
848 assert_eq!(metrics.ledger.len(), 3);
851 let egress = &metrics.ledger[0];
852 assert_eq!(egress.kind, LedgerKind::Grant);
853 assert_eq!(egress.mission_id, "m-2");
854 assert_eq!(egress.milestone_id.as_deref(), Some("ms-1"));
855 assert_eq!(egress.ask, "egress: registry.npmjs.org:443");
856 assert_eq!(egress.decision, "denied: not needed");
857 assert_eq!(egress.latency_ms, Some(900_000));
858 let command = &metrics.ledger[1];
859 assert_eq!(command.kind, LedgerKind::Grant);
860 assert_eq!(command.ask, "command: cargo test");
861 assert_eq!(command.decision, "approved");
862 assert_eq!(command.latency_ms, Some(5_000));
863 let steer = &metrics.ledger[2];
864 assert_eq!(steer.kind, LedgerKind::Steer);
865 assert_eq!(steer.mission_id, "m-3");
866 assert_eq!(steer.milestone_id, None);
867 assert_eq!(steer.ask, "skip the flaky test");
868 assert_eq!(steer.decision, "steered");
869 assert_eq!(steer.latency_ms, None);
870 }
871
872 #[test]
873 fn escalation_metrics_pending_grant_rows_and_pre_approval_messages() {
874 let events = vec![
877 ev(
878 1,
879 "m-1",
880 0,
881 EventKind::UserMessage {
882 text: "draft note".into(),
883 interrupt: false,
884 },
885 ),
886 ev(
887 2,
888 "m-1",
889 1_000,
890 EventKind::PlanApproved {
891 plan: sample_plan(),
892 base_sha: None,
893 },
894 ),
895 ev(
896 3,
897 "m-1",
898 2_000,
899 EventKind::GrantRequested {
900 milestone_id: "ms-1".into(),
901 kind: GrantKind::TouchPath,
902 command: "docs/**".into(),
903 },
904 ),
905 ];
906 let fold = mission_escalation("m-1", &events);
907 assert_eq!(fold.interventions, 0);
908 assert!(fold.latencies_ms.is_empty());
909 assert_eq!(fold.ledger.len(), 1);
910 assert_eq!(fold.ledger[0].decision, "pending");
911 assert_eq!(fold.ledger[0].latency_ms, None);
912 assert_eq!(fold.ledger[0].ask, "touch-path: docs/**");
913 }
914
915 #[test]
916 fn escalation_metrics_operator_unblocks_count_engine_lifts_do_not() {
917 let events = vec![
918 ev(
919 1,
920 "m-1",
921 0,
922 EventKind::MilestoneBlocked {
923 block_context: None,
924 milestone_id: "ms-1".into(),
925 reason: "workspace gate: bootstrap failed".into(),
926 },
927 ),
928 ev(
931 2,
932 "m-1",
933 1_000,
934 EventKind::MilestoneUnblocked {
935 block_context: None,
936 milestone_id: "ms-1".into(),
937 reason: crate::workspace_gate::GATE_LIFT_REASON.to_string(),
938 validator_guidance: None,
939 },
940 ),
941 ev(
942 3,
943 "m-1",
944 2_000,
945 EventKind::MilestoneBlocked {
946 block_context: None,
947 milestone_id: "ms-1".into(),
948 reason: "fix-cycle cap".into(),
949 },
950 ),
951 ev(
953 4,
954 "m-1",
955 3_000,
956 EventKind::MilestoneUnblocked {
957 block_context: None,
958 milestone_id: "ms-1".into(),
959 reason: "user skipped findings".into(),
960 validator_guidance: None,
961 },
962 ),
963 ];
964 let fold = mission_escalation("m-1", &events);
965 assert_eq!(fold.interventions, 1);
966 }
967
968 #[test]
969 fn escalation_metrics_revision_decisions_count_as_interventions() {
970 let events = vec![
971 ev(
972 1,
973 "m-1",
974 0,
975 EventKind::PlanRevised {
976 revision: 1,
977 plan: sample_plan(),
978 },
979 ),
980 ev(
981 2,
982 "m-1",
983 1_000,
984 EventKind::PlanRevisionRejected {
985 revision: 2,
986 reason: "too risky".into(),
987 },
988 ),
989 ev(3, "m-1", 2_000, EventKind::MissionCompleted {}),
990 ];
991 let fold = mission_escalation("m-1", &events);
992 assert_eq!(fold.interventions, 2);
993 let metrics = aggregate(&[("m-1".to_string(), events)], &[]);
994 assert_eq!(metrics.autonomy.zero_intervention_missions, 0);
995 assert_eq!(metrics.autonomy.completed.zero_intervention, 0);
996 }
997
998 #[test]
999 fn escalation_metrics_abandoned_is_not_a_closed_mission() {
1000 let events = vec![
1001 ev(1, "m-1", 0, created()),
1002 ev(
1003 2,
1004 "m-1",
1005 1_000,
1006 EventKind::MissionAbandoned {
1007 reason: "operator retired it".into(),
1008 },
1009 ),
1010 ];
1011 let metrics = aggregate(&[("m-1".to_string(), events)], &[]);
1012 assert_eq!(metrics.autonomy.closed_missions, 0);
1013 assert_eq!(metrics.autonomy.zero_intervention_share, None);
1014 assert_eq!(metrics.false_greens.completed_missions, 0);
1015 }
1016
1017 #[test]
1018 fn escalation_metrics_other_missions_events_are_filtered_out() {
1019 let events = vec![
1020 ev(1, "m-1", 0, created()),
1021 ev(
1022 2,
1023 "m-2",
1024 500,
1025 EventKind::GrantApproved {
1026 kind: GrantKind::Command,
1027 command: "other".into(),
1028 },
1029 ),
1030 ev(3, "m-1", 1_000, EventKind::MissionCompleted {}),
1031 ];
1032 let fold = mission_escalation("m-1", &events);
1033 assert_eq!(fold.interventions, 0);
1034 assert!(fold.terminal == Some(TerminalOutcome::Completed));
1035 }
1036
1037 fn seed_mission(repo_root: &std::path::Path, id: &str, kinds: Vec<EventKind>) {
1041 let paths = MissionPaths::new(repo_root, id);
1042 let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
1043 for kind in kinds {
1044 log.append(kind).unwrap();
1045 }
1046 }
1047
1048 fn seed_ticket(repo_root: &std::path::Path, slug: &str, frontmatter: &str) {
1049 let dir = crate::ticket::Ticket::tickets_dir(repo_root);
1050 std::fs::create_dir_all(&dir).unwrap();
1051 std::fs::write(
1052 dir.join(format!("{slug}.md")),
1053 format!("{frontmatter}\n## Goal\nfix it\n"),
1054 )
1055 .unwrap();
1056 }
1057
1058 #[test]
1059 fn escalation_metrics_compute_end_to_end_over_fixture_repo() {
1060 let tmp = TempDir::new().unwrap();
1061 seed_mission(
1062 tmp.path(),
1063 "m-1",
1064 vec![created(), EventKind::MissionCompleted {}],
1065 );
1066 seed_mission(
1067 tmp.path(),
1068 "m-2",
1069 vec![
1070 created(),
1071 EventKind::GrantRequested {
1072 milestone_id: "ms-1".into(),
1073 kind: GrantKind::Command,
1074 command: "cargo test".into(),
1075 },
1076 EventKind::GrantApproved {
1077 kind: GrantKind::Command,
1078 command: "cargo test".into(),
1079 },
1080 EventKind::MissionCompleted {},
1081 ],
1082 );
1083 seed_ticket(
1086 tmp.path(),
1087 "defect-regression",
1088 "---\ntitle: Login regression\ntraced-from-mission: m-1\n---\n",
1089 );
1090 seed_ticket(
1091 tmp.path(),
1092 "ordinary-task",
1093 "---\ntitle: Ordinary task\n---\n",
1094 );
1095
1096 let metrics = compute_escalation_metrics(tmp.path()).unwrap();
1097 assert_eq!(metrics.autonomy.closed_missions, 2);
1098 assert_eq!(metrics.autonomy.zero_intervention_missions, 1);
1099 assert_eq!(metrics.false_greens.false_greens, 1);
1100 assert_eq!(metrics.false_greens.false_green_rate, Some(0.5));
1101 assert_eq!(
1102 metrics.false_greens.traced_defects,
1103 vec![TracedDefect {
1104 ticket: "defect-regression".into(),
1105 mission_id: "m-1".into(),
1106 }]
1107 );
1108 assert_eq!(metrics.rubber_stamp.decided_grants, 1);
1111 assert_eq!(metrics.rubber_stamp.under_ten_seconds, 1);
1112 assert_eq!(metrics.ledger.len(), 1);
1113 assert_eq!(metrics.ledger[0].decision, "approved");
1114 }
1115}