1use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33use std::collections::BTreeMap;
34use std::path::Path;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum Severity {
41 Broken,
43 Attention,
46}
47
48impl Severity {
49 pub fn as_str(&self) -> &'static str {
50 match self {
51 Severity::Broken => "broken",
52 Severity::Attention => "attention",
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Remedy {
60 pub description: String,
63 pub argv: Vec<String>,
65 pub needs_terminal: bool,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct Finding {
75 pub component: String,
76 pub severity: Severity,
77 pub summary: String,
78 pub detail: String,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub remedy: Option<Remedy>,
81}
82
83impl Finding {
84 fn unreadable(component: &str, what: &str, why: impl std::fmt::Display) -> Finding {
87 Finding {
88 component: component.to_string(),
89 severity: Severity::Attention,
90 summary: format!("store unreadable: {what}"),
91 detail: why.to_string(),
92 remedy: None,
93 }
94 }
95}
96
97const STUCK_DRAFT_AFTER: chrono::Duration = chrono::Duration::hours(48);
100
101const STALE_REQUEST_AFTER: chrono::Duration = chrono::Duration::hours(72);
104
105pub fn examine(home: &Path, now: DateTime<Utc>) -> Vec<Finding> {
111 let mut findings = Vec::new();
112 findings.extend(check_mail(&home.join("mail")));
113 findings.extend(check_legacy_mail(home));
114 findings.extend(check_outbox(&home.join("outbox"), now));
115 findings.extend(check_questions(&home.join("questions"), now));
116 findings.extend(check_frontdoor(&home.join("requests"), now));
117 findings.extend(check_triggers(&home.join("triggers"), now));
118 findings.extend(check_charter(&home.join("charter.toml")));
119 findings.extend(check_runs(&home.join("sessions")));
120 findings.extend(check_harness(&home.join("learning").join("harness"), now));
121 findings.extend(check_learning(&home.join("learning"), now));
122 if let Some(parent) = home.parent() {
126 findings.extend(check_graph_nightly(&parent.join(".mecha-graph"), now));
127 }
128 sort(&mut findings);
129 findings
130}
131
132pub fn sort(findings: &mut [Finding]) {
135 findings.sort_by(|a, b| {
136 a.severity
137 .cmp(&b.severity)
138 .then_with(|| a.component.cmp(&b.component))
139 });
140}
141
142#[derive(Debug, Deserialize)]
149struct AuthMarker {
150 at: String,
151 message: String,
152}
153
154#[derive(Debug, Default, Deserialize)]
157struct MailAccounts {
158 #[serde(default, rename = "account")]
159 accounts: Vec<MailAccount>,
160}
161
162#[derive(Debug, Deserialize)]
163struct MailAccount {
164 name: String,
165 provider: String,
166 #[serde(default)]
170 grant_lifetime_days: Option<u32>,
171}
172
173fn check_mail(mail: &Path) -> Vec<Finding> {
177 let mut out = Vec::new();
178 if !mail.is_dir() {
179 return out;
180 }
181
182 let declared: Vec<MailAccount> = std::fs::read_to_string(mail.join("accounts.toml"))
185 .ok()
186 .and_then(|text| toml::from_str::<MailAccounts>(&text).ok())
187 .map(|file| file.accounts)
188 .unwrap_or_default();
189 let providers: BTreeMap<String, String> = declared
190 .iter()
191 .map(|a| (a.name.clone(), a.provider.clone()))
192 .collect();
193 let lifetimes: BTreeMap<String, u32> = declared
194 .iter()
195 .filter_map(|a| a.grant_lifetime_days.map(|d| (a.name.clone(), d)))
196 .collect();
197
198 let entries = match std::fs::read_dir(mail) {
199 Ok(entries) => entries,
200 Err(e) => {
201 out.push(Finding::unreadable(
202 "mail",
203 "the mail directory",
204 format!("{}: {e}", mail.display()),
205 ));
206 return out;
207 }
208 };
209
210 for entry in entries.flatten() {
211 let dir = entry.path();
212 if !dir.is_dir() {
213 continue;
214 }
215 let Some(account) = dir.file_name().and_then(|n| n.to_str()).map(String::from) else {
216 continue;
217 };
218 out.extend(check_triage_scope(&dir, &account, providers.get(&account)));
223 out.extend(check_grant_age(
224 &dir,
225 &account,
226 providers.get(&account),
227 lifetimes.get(&account).copied(),
228 ));
229
230 let marker_path = dir.join("auth_error.json");
231 if !marker_path.is_file() {
232 continue;
233 }
234 let text = match std::fs::read_to_string(&marker_path) {
235 Ok(text) => text,
236 Err(e) => {
237 out.push(Finding::unreadable(
238 "mail",
239 &format!("auth_error.json for `{account}`"),
240 format!("{}: {e}", marker_path.display()),
241 ));
242 continue;
243 }
244 };
245 match serde_json::from_str::<AuthMarker>(&text) {
246 Ok(marker) => {
247 let provider = providers.get(&account);
248 let mut argv = vec![
249 "mecha-mail".to_string(),
250 "auth".to_string(),
251 account.clone(),
252 ];
253 if let Some(provider) = provider {
254 argv.push("--provider".to_string());
255 argv.push(provider.clone());
256 }
257 out.push(Finding {
258 component: "mail".to_string(),
259 severity: Severity::Broken,
260 summary: format!("mail auth for `{account}` is dead"),
261 detail: format!(
266 "permanent refresh failure since {}: {}",
267 marker.at, marker.message
268 ),
269 remedy: Some(Remedy {
270 description: format!(
271 "re-authenticate the `{account}` account (opens an OAuth flow)"
272 ),
273 argv,
274 needs_terminal: true,
275 }),
276 });
277 }
278 Err(e) => out.push(Finding::unreadable(
279 "mail",
280 &format!("auth_error.json for `{account}` did not parse"),
281 format!("{}: {e}", marker_path.display()),
282 )),
283 }
284 }
285 out
286}
287
288#[derive(Debug, serde::Deserialize)]
295struct StoredGrant {
296 #[serde(default)]
297 granted_scopes: Option<String>,
298 #[serde(default)]
299 granted_at: Option<String>,
300}
301
302const GRANT_WARN_WITHIN_DAYS: i64 = 2;
309
310fn check_grant_age(
323 dir: &Path,
324 account: &str,
325 provider: Option<&String>,
326 lifetime_days: Option<u32>,
327) -> Vec<Finding> {
328 let Some(lifetime) = lifetime_days.filter(|d| *d > 0) else {
329 return Vec::new();
330 };
331 let Ok(text) = std::fs::read_to_string(dir.join("oauth.json")) else {
332 return Vec::new();
333 };
334 let Ok(grant) = serde_json::from_str::<StoredGrant>(&text) else {
335 return Vec::new(); };
337 let Some(granted_at) = grant.granted_at.as_deref() else {
341 return Vec::new();
342 };
343 let Ok(granted) = chrono::DateTime::parse_from_rfc3339(granted_at) else {
344 return Vec::new();
345 };
346 let expires = granted.with_timezone(&chrono::Utc) + chrono::Duration::days(lifetime as i64);
347 let hours_left = (expires - chrono::Utc::now()).num_hours();
352 let left = (hours_left as f64 / 24.0).ceil() as i64;
353 if left > GRANT_WARN_WITHIN_DAYS {
354 return Vec::new();
355 }
356 let when = if hours_left < 0 {
357 "has expired".to_string()
358 } else if hours_left < 24 {
359 "expires within a day".to_string()
360 } else {
361 format!("expires in {left} days")
362 };
363 let mut argv = vec![
364 "mecha-mail".to_string(),
365 "auth".to_string(),
366 account.to_string(),
367 ];
368 if let Some(p) = provider {
369 argv.push("--provider".to_string());
370 argv.push(p.clone());
371 }
372 vec![Finding {
373 component: "mail".to_string(),
374 severity: Severity::Attention,
375 summary: format!("`{account}` sign-in {when}"),
376 detail: format!(
377 "this grant lasts {lifetime} days from consent ({granted_at}) and refreshing does \
378 not extend it. Re-authenticate before it lapses — once it does, the failure looks \
379 like a revoked token and every scheduled run using this account stops."
380 ),
381 remedy: Some(Remedy {
382 description: format!("re-authenticate `{account}` now (opens an OAuth flow)"),
383 argv,
384 needs_terminal: true,
385 }),
386 }]
387}
388
389fn triage_scope_for(provider: &str) -> Option<&'static str> {
393 match provider {
394 "google" => Some("gmail.modify"),
395 "outlook" | "microsoft" => Some("Mail.ReadWrite"),
396 _ => None,
397 }
398}
399
400fn check_triage_scope(dir: &Path, account: &str, provider: Option<&String>) -> Vec<Finding> {
416 let Some(provider) = provider else {
417 return Vec::new();
418 };
419 let Some(needed) = triage_scope_for(provider) else {
420 return Vec::new();
421 };
422 let path = dir.join("oauth.json");
423 let Ok(text) = std::fs::read_to_string(&path) else {
424 return Vec::new();
427 };
428 let Ok(grant) = serde_json::from_str::<StoredGrant>(&text) else {
429 return vec![Finding::unreadable(
430 "mail",
431 &format!("oauth.json for `{account}` did not parse"),
432 format!("{}", path.display()),
433 )];
434 };
435 if grant
436 .granted_scopes
437 .as_deref()
438 .is_some_and(|g| g.contains(needed))
439 {
440 return Vec::new();
441 }
442 let admin_note = if provider == "outlook" || provider == "microsoft" {
443 " Microsoft blocks `Mail.ReadWrite` from end-user consent under its \
444 recommended policy, so on a managed tenant an administrator has to \
445 grant it to the app registration before this can succeed."
446 } else {
447 ""
448 };
449 vec![Finding {
450 component: "mail".to_string(),
451 severity: Severity::Attention,
452 summary: format!("`{account}` cannot archive, spam or mark mail read"),
453 detail: format!(
454 "the stored grant does not include `{needed}`, so mail_triage will fail on this \
455 account. Reading, sending and calendar work are unaffected.{admin_note}"
456 ),
457 remedy: Some(Remedy {
458 description: format!(
459 "re-authenticate `{account}` to add the triage scope (opens an OAuth flow)"
460 ),
461 argv: vec![
462 "mecha-mail".to_string(),
463 "auth".to_string(),
464 account.to_string(),
465 "--provider".to_string(),
466 provider.clone(),
467 ],
468 needs_terminal: true,
469 }),
470 }]
471}
472
473#[cfg(test)]
474mod grant_age_tests {
475 use super::*;
476
477 fn store(dir: &Path, granted_at: Option<&str>) {
478 std::fs::create_dir_all(dir).unwrap();
479 let stamp = granted_at
480 .map(|g| format!(r#","granted_at":"{g}""#))
481 .unwrap_or_default();
482 std::fs::write(
483 dir.join("oauth.json"),
484 format!(r#"{{"client_id":"i","access_token":"a","refresh_token":"r","expires_at":1{stamp}}}"#),
485 )
486 .unwrap();
487 }
488
489 fn days_ago(n: i64) -> String {
490 (chrono::Utc::now() - chrono::Duration::days(n)).to_rfc3339()
491 }
492
493 #[test]
495 fn a_grant_nearing_its_declared_lifetime_is_reported_early() {
496 let tmp = std::env::temp_dir().join(format!("mecha-grant-{}", std::process::id()));
497 let g = "google".to_string();
498
499 store(&tmp, Some(&days_ago(1)));
501 assert!(check_grant_age(&tmp, "personal", Some(&g), Some(7)).is_empty());
502
503 store(&tmp, Some(&days_ago(5)));
505 let f = check_grant_age(&tmp, "personal", Some(&g), Some(7));
506 assert_eq!(f.len(), 1, "should warn with 2 days left");
507 assert!(
508 f[0].summary.contains("expires in 2 days"),
509 "{}",
510 f[0].summary
511 );
512 assert!(f[0].remedy.as_ref().unwrap().needs_terminal);
513
514 store(&tmp, Some(&days_ago(7)));
516 let f = check_grant_age(&tmp, "personal", Some(&g), Some(7));
517 assert!(f[0].summary.contains("within a day"), "{}", f[0].summary);
518
519 store(&tmp, Some(&days_ago(9)));
521 let f = check_grant_age(&tmp, "personal", Some(&g), Some(7));
522 assert!(f[0].summary.contains("has expired"), "{}", f[0].summary);
523
524 assert!(check_grant_age(&tmp, "personal", Some(&g), None).is_empty());
526
527 store(&tmp, None);
529 assert!(check_grant_age(&tmp, "personal", Some(&g), Some(7)).is_empty());
530
531 std::fs::remove_dir_all(&tmp).ok();
532 }
533}
534
535fn check_legacy_mail(home: &Path) -> Vec<Finding> {
542 let mut out = Vec::new();
543 for provider in ["google", "outlook"] {
544 let marker_path = home.join(provider).join("auth_error.json");
545 if !marker_path.is_file() {
546 continue;
547 }
548 let text = match std::fs::read_to_string(&marker_path) {
549 Ok(text) => text,
550 Err(e) => {
551 out.push(Finding::unreadable(
552 "mail",
553 &format!("auth_error.json for the legacy {provider} store"),
554 format!("{}: {e}", marker_path.display()),
555 ));
556 continue;
557 }
558 };
559 match serde_json::from_str::<AuthMarker>(&text) {
560 Ok(marker) => out.push(Finding {
561 component: "mail".to_string(),
562 severity: Severity::Broken,
563 summary: format!("legacy {provider} mail auth is dead"),
564 detail: format!(
568 "permanent refresh failure since {}: {}",
569 marker.at, marker.message
570 ),
571 remedy: Some(Remedy {
572 description: format!(
573 "bring the legacy {provider} login into the unified registry — \
574 and re-authenticate it per the detail, which no import fixes"
575 ),
576 argv: vec![
577 "mecha-mail".to_string(),
578 "import".to_string(),
579 provider.to_string(),
580 "--provider".to_string(),
581 provider.to_string(),
582 ],
583 needs_terminal: false,
584 }),
585 }),
586 Err(e) => out.push(Finding::unreadable(
587 "mail",
588 &format!("auth_error.json for the legacy {provider} store did not parse"),
589 format!("{}: {e}", marker_path.display()),
590 )),
591 }
592 }
593 out
594}
595
596fn check_outbox(root: &Path, now: DateTime<Utc>) -> Vec<Finding> {
602 let mut out = Vec::new();
603 if !root.is_dir() {
604 return out;
605 }
606 let entries = match std::fs::read_dir(root) {
607 Ok(entries) => entries,
608 Err(e) => {
609 out.push(Finding::unreadable(
610 "outbox",
611 "the outbox directory",
612 format!("{}: {e}", root.display()),
613 ));
614 return out;
615 }
616 };
617
618 let review = Remedy {
619 description: "open the outbox review surface — doctor never releases a draft".to_string(),
620 argv: vec!["mecha".into(), "outbox".into(), "review".into()],
621 needs_terminal: true,
622 };
623
624 let mut stale: Vec<String> = Vec::new();
625 for entry in entries.flatten() {
626 let path = entry.path();
627 if path.extension().and_then(|e| e.to_str()) != Some("json") {
628 continue;
629 }
630 let item: crate::outbox::OutboxItem =
631 match std::fs::read_to_string(&path).map(|t| serde_json::from_str(&t)) {
632 Ok(Ok(item)) => item,
633 Ok(Err(e)) => {
634 out.push(Finding::unreadable(
635 "outbox",
636 &format!(
637 "item {} did not parse",
638 path.file_name().unwrap_or_default().to_string_lossy()
639 ),
640 format!("{}: {e}", path.display()),
641 ));
642 continue;
643 }
644 Err(e) => {
645 out.push(Finding::unreadable(
646 "outbox",
647 &format!(
648 "item {} could not be read",
649 path.file_name().unwrap_or_default().to_string_lossy()
650 ),
651 format!("{}: {e}", path.display()),
652 ));
653 continue;
654 }
655 };
656 if item.status != "pending" {
657 continue;
658 }
659 if let Some(error) = &item.error {
660 out.push(Finding {
661 component: "outbox".to_string(),
662 severity: Severity::Broken,
663 summary: format!("release failed: {error}"),
664 detail: format!(
665 "{} · {} — still pending; the draft is good, the delivery was not",
666 item.id, item.summary
667 ),
668 remedy: Some(review.clone()),
669 });
670 } else if age_of(&item.created_at, now).is_some_and(|age| age > STUCK_DRAFT_AFTER) {
671 stale.push(format!(
672 "{} · {} — staged {}",
673 item.id,
674 item.summary,
675 render_age(now, &item.created_at)
676 ));
677 }
678 }
679
680 if !stale.is_empty() {
681 stale.sort();
683 out.push(Finding {
684 component: "outbox".to_string(),
685 severity: Severity::Attention,
686 summary: format!(
687 "{} draft{} pending for more than 48h",
688 stale.len(),
689 if stale.len() == 1 { "" } else { "s" }
690 ),
691 detail: stale.join("\n"),
692 remedy: Some(review),
693 });
694 }
695 out
696}
697
698const UNANSWERED_QUESTION_AFTER: chrono::Duration = chrono::Duration::hours(24);
709
710fn check_questions(root: &Path, now: DateTime<Utc>) -> Vec<Finding> {
713 let mut out = Vec::new();
714 if !root.is_dir() {
715 return out;
718 }
719 let entries = match std::fs::read_dir(root) {
720 Ok(entries) => entries,
721 Err(e) => {
722 out.push(Finding::unreadable(
723 "questions",
724 "the question store",
725 format!("{}: {e}", root.display()),
726 ));
727 return out;
728 }
729 };
730
731 let mut stale: Vec<String> = Vec::new();
732 for entry in entries.flatten() {
733 let path = entry.path();
734 if path.extension().and_then(|e| e.to_str()) != Some("json") {
735 continue;
736 }
737 let q: crate::questions::Question =
738 match std::fs::read_to_string(&path).map(|t| serde_json::from_str(&t)) {
739 Ok(Ok(q)) => q,
740 Ok(Err(e)) => {
741 out.push(Finding::unreadable(
742 "questions",
743 &format!(
744 "question {} did not parse",
745 path.file_name().unwrap_or_default().to_string_lossy()
746 ),
747 format!("{}: {e}", path.display()),
748 ));
749 continue;
750 }
751 Err(e) => {
752 out.push(Finding::unreadable(
753 "questions",
754 &format!(
755 "question {} could not be read",
756 path.file_name().unwrap_or_default().to_string_lossy()
757 ),
758 format!("{}: {e}", path.display()),
759 ));
760 continue;
761 }
762 };
763 if !q.is_open() {
764 continue;
765 }
766 if age_of(&q.asked_at, now).is_some_and(|age| age > UNANSWERED_QUESTION_AFTER) {
767 stale.push(format!(
768 "{} · {} — asked {}",
769 crate::questions::QuestionStore::short(&q.id),
770 q.summary(),
771 render_age(now, &q.asked_at)
772 ));
773 }
774 }
775
776 if !stale.is_empty() {
777 stale.sort();
778 out.push(Finding {
779 component: "questions".to_string(),
780 severity: Severity::Attention,
781 summary: format!(
782 "{} question{} unanswered for more than 24h — {} run{} cannot continue",
783 stale.len(),
784 if stale.len() == 1 { "" } else { "s" },
785 stale.len(),
786 if stale.len() == 1 { "" } else { "s" }
787 ),
788 detail: stale.join("\n"),
789 remedy: Some(Remedy {
794 description: "see what the agent is stuck on — doctor never answers for you"
795 .to_string(),
796 argv: vec!["mecha".into(), "questions".into(), "list".into()],
797 needs_terminal: true,
798 }),
799 });
800 }
801 out
802}
803
804const WAITING_ON_ME: [&str; 3] = [
812 crate::frontdoor::EXTRACTED,
813 crate::frontdoor::AWAITING_ME,
814 crate::frontdoor::TRIAGED,
815];
816
817fn check_frontdoor(root: &Path, now: DateTime<Utc>) -> Vec<Finding> {
820 let mut out = Vec::new();
821 if !root.is_dir() {
822 return out;
823 }
824 let entries = match std::fs::read_dir(root) {
825 Ok(entries) => entries,
826 Err(e) => {
827 out.push(Finding::unreadable(
828 "frontdoor",
829 "the request store",
830 format!("{}: {e}", root.display()),
831 ));
832 return out;
833 }
834 };
835
836 let list = Remedy {
837 description: "list the frontdoor queue".to_string(),
838 argv: vec!["mecha".into(), "frontdoor".into(), "list".into()],
839 needs_terminal: false,
840 };
841
842 let mut stale: Vec<(i64, String)> = Vec::new();
843 for entry in entries.flatten() {
844 let path = entry.path();
845 if path.extension().and_then(|e| e.to_str()) != Some("json") {
846 continue;
847 }
848 let Ok(Ok(record)) = std::fs::read_to_string(&path)
849 .map(|t| serde_json::from_str::<crate::frontdoor::Record>(&t))
850 else {
851 out.push(Finding::unreadable(
854 "frontdoor",
855 &format!(
856 "request {} did not parse",
857 path.file_name().unwrap_or_default().to_string_lossy()
858 ),
859 path.display().to_string(),
860 ));
861 continue;
862 };
863 if record.state == crate::frontdoor::EXTRACTION_FAILED {
864 out.push(Finding {
865 component: "frontdoor".to_string(),
866 severity: Severity::Broken,
867 summary: format!(
868 "request {} failed extraction and waits for a human",
869 record.seq
870 ),
871 detail: format!(
872 "{} ({}) — {}",
873 record.seq,
874 record.type_id,
875 record
876 .extraction_error
877 .as_deref()
878 .unwrap_or("no error recorded")
879 ),
880 remedy: Some(list.clone()),
881 });
882 } else if WAITING_ON_ME.contains(&record.state.as_str())
883 && request_age(&record, now).is_some_and(|age| age > STALE_REQUEST_AFTER)
884 {
885 stale.push((
886 record.seq,
887 format!(
888 "{} ({}) — {}, received {}",
889 record.seq,
890 record.type_id,
891 record.state,
892 render_age(now, &record.created_at)
893 ),
894 ));
895 }
896 }
897
898 if !stale.is_empty() {
899 stale.sort_by_key(|(seq, _)| *seq);
901 out.push(Finding {
902 component: "frontdoor".to_string(),
903 severity: Severity::Attention,
904 summary: format!(
905 "{} request{} waiting on you for more than 72h",
906 stale.len(),
907 if stale.len() == 1 { "" } else { "s" }
908 ),
909 detail: stale
910 .into_iter()
911 .map(|(_, line)| line)
912 .collect::<Vec<_>>()
913 .join("\n"),
914 remedy: Some(list),
915 });
916 }
917 out
918}
919
920fn request_age(record: &crate::frontdoor::Record, now: DateTime<Utc>) -> Option<chrono::Duration> {
925 age_of(&record.drained_at, now).or_else(|| age_of(&record.created_at, now))
926}
927
928const HEALTH_WINDOW: usize = 5;
935
936const HEALTH_MIN_CALLS: u32 = 10;
941
942const HEALTH_ERROR_RATE: f64 = 1.0 / 3.0;
949
950fn check_triggers(root: &Path, now: DateTime<Utc>) -> Vec<Finding> {
953 let mut out = Vec::new();
954 if !root.is_dir() {
955 return out;
956 }
957 let entries = match std::fs::read_dir(root) {
958 Ok(entries) => entries,
959 Err(e) => {
960 out.push(Finding::unreadable(
961 "triggers",
962 "the trigger store",
963 format!("{}: {e}", root.display()),
964 ));
965 return out;
966 }
967 };
968
969 let mut triggers: Vec<crate::trigger::Trigger> = Vec::new();
970 for entry in entries.flatten() {
971 let path = entry.path();
972 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
973 continue;
974 }
975 let name = path
976 .file_stem()
977 .and_then(|s| s.to_str())
978 .unwrap_or_default()
979 .to_string();
980 match std::fs::read_to_string(&path).map(|t| toml::from_str::<crate::trigger::Trigger>(&t))
981 {
982 Ok(Ok(mut trigger)) => {
983 trigger.name = name;
984 triggers.push(trigger);
985 }
986 _ => out.push(Finding::unreadable(
987 "triggers",
988 &format!("trigger file `{name}.toml` did not parse"),
989 path.display().to_string(),
990 )),
991 }
992 }
993
994 let mut recent: BTreeMap<String, Vec<crate::trigger::RunRecord>> = BTreeMap::new();
998 let mut last_slot: BTreeMap<String, DateTime<Utc>> = BTreeMap::new();
999 let ledger = root.join("runs.jsonl");
1000 if ledger.is_file() {
1001 match std::fs::read_to_string(&ledger) {
1002 Ok(text) => {
1003 for line in text.lines().filter(|l| !l.trim().is_empty()) {
1004 let Ok(row) = serde_json::from_str::<crate::trigger::RunRecord>(line) else {
1007 continue;
1008 };
1009 if let Some(slot) = row.slot {
1010 let newest = last_slot.entry(row.trigger.clone()).or_insert(slot);
1011 if slot > *newest {
1012 *newest = slot;
1013 }
1014 }
1015 if matches!(
1020 row.status,
1021 crate::trigger::RunStatus::Ok | crate::trigger::RunStatus::Error
1022 ) {
1023 let window = recent.entry(row.trigger.clone()).or_default();
1024 window.push(row);
1025 if window.len() > HEALTH_WINDOW {
1026 window.remove(0);
1027 }
1028 }
1029 }
1030 }
1031 Err(e) => out.push(Finding::unreadable(
1032 "triggers",
1033 "the run ledger",
1034 format!("{}: {e}", ledger.display()),
1035 )),
1036 }
1037 }
1038
1039 for trigger in &triggers {
1040 if !trigger.enabled {
1041 continue;
1042 }
1043
1044 let window = recent.get(&trigger.name);
1047 if let Some(row) = window.and_then(|w| w.last()) {
1048 if row.status == crate::trigger::RunStatus::Error {
1049 out.push(Finding {
1050 component: "triggers".to_string(),
1051 severity: Severity::Attention,
1052 summary: format!("trigger `{}`'s most recent run failed", trigger.name),
1053 detail: format!(
1054 "started {}: {}",
1055 row.started_at.to_rfc3339(),
1056 row.error.as_deref().unwrap_or("no error recorded")
1057 ),
1058 remedy: Some(Remedy {
1059 description: format!(
1060 "run `{}` by hand — a manual run is evidence, not a fire; it never advances the schedule",
1061 trigger.name
1062 ),
1063 argv: vec![
1064 "mecha".into(),
1065 "trigger".into(),
1066 "run".into(),
1067 trigger.name.clone(),
1068 ],
1069 needs_terminal: false,
1070 }),
1071 });
1072 }
1073 }
1074
1075 let (calls, errors) = window
1081 .map(|w| {
1082 w.iter().fold((0u32, 0u32), |(c, e), r| {
1083 (c + r.tool_calls, e + r.tool_errors)
1084 })
1085 })
1086 .unwrap_or((0, 0));
1087 if calls >= HEALTH_MIN_CALLS && f64::from(errors) / f64::from(calls) >= HEALTH_ERROR_RATE {
1088 let runs = window.map(Vec::len).unwrap_or(0);
1089 out.push(Finding {
1090 component: "triggers".to_string(),
1091 severity: Severity::Attention,
1092 summary: format!(
1093 "trigger `{}` failed {errors} of {calls} tool calls",
1094 trigger.name
1095 ),
1096 detail: format!(
1097 "across its last {runs} run(s){}. A run's answer arrives either way, so this is invisible in the ledger's status — and per-step reliability is what decides how long a task the run can finish, so a third of the calls failing is not a third of the work lost.",
1098 if window.is_some_and(|w| w.last().is_some_and(|r| r.ended_on_failed_call)) {
1099 ", and the most recent run answered with its last call failed"
1100 } else {
1101 ""
1102 }
1103 ),
1104 remedy: Some(Remedy {
1107 description: format!("read `{}`'s recent runs", trigger.name),
1108 argv: vec![
1109 "mecha".into(),
1110 "trigger".into(),
1111 "show".into(),
1112 trigger.name.clone(),
1113 ],
1114 needs_terminal: false,
1115 }),
1116 });
1117 }
1118
1119 if let Some(window) = window {
1133 let newest = window.last();
1134 let before: u32 = window[..window.len().saturating_sub(1)]
1135 .iter()
1136 .map(|r| r.tool_calls)
1137 .sum();
1138 let stopped = newest
1141 .is_some_and(|r| r.tool_calls == 0 && r.status == crate::trigger::RunStatus::Ok)
1142 && before >= HEALTH_MIN_CALLS;
1143 if stopped {
1144 out.push(Finding {
1145 component: "triggers".to_string(),
1146 severity: Severity::Attention,
1147 summary: format!(
1148 "trigger `{}`'s most recent run did no work",
1149 trigger.name
1150 ),
1151 detail: format!(
1152 "it succeeded having made no tool calls, where its previous {} run(s) made {before}. A run that does nothing and reports success is indistinguishable from a healthy one in every other signal — the status is `ok`, the schedule advanced, and the answer arrived.",
1153 window.len() - 1
1154 ),
1155 remedy: Some(Remedy {
1156 description: format!("read `{}`'s recent runs", trigger.name),
1157 argv: vec![
1158 "mecha".into(),
1159 "trigger".into(),
1160 "show".into(),
1161 trigger.name.clone(),
1162 ],
1163 needs_terminal: false,
1164 }),
1165 });
1166 }
1167 }
1168
1169 if trigger.catch_up != crate::trigger::CatchUp::Always {
1175 continue;
1176 }
1177 let Some(anchor) = last_slot.get(&trigger.name).copied().or(trigger.created_at) else {
1178 continue;
1181 };
1182 let tz = trigger.tz(None);
1183 let step = chrono::Duration::seconds(1);
1184 let missed_more_than_two = trigger
1185 .schedule
1186 .prev_at_or_before(now, tz)
1187 .and_then(|s0| trigger.schedule.prev_at_or_before(s0 - step, tz))
1188 .and_then(|s1| trigger.schedule.prev_at_or_before(s1 - step, tz))
1189 .is_some_and(|s2| s2 > anchor);
1190 if missed_more_than_two {
1191 out.push(Finding {
1192 component: "triggers".to_string(),
1193 severity: Severity::Attention,
1194 summary: format!("trigger `{}` has missed more than two slots", trigger.name),
1195 detail: format!(
1196 "last accounted slot {}; with catch_up=always a healthy scheduler fires \
1197 the most recent slot every tick, so the daemon or its timer may be down \
1198 (systemctl --user status mecha-triggers)",
1199 anchor.to_rfc3339()
1200 ),
1201 remedy: None,
1204 });
1205 }
1206 }
1207 out
1208}
1209
1210const RUNS_WINDOW: usize = 200;
1219
1220const RUNS_MIN: usize = 20;
1226
1227const ENDED_ON_FAILURE_RATE: f64 = 0.20;
1233
1234const TOOL_ERROR_RATE: f64 = 0.25;
1236
1237const RUNS_MIN_CALLS: u64 = 20;
1240
1241const CUT_SHORT_RATE: f64 = 0.25;
1245
1246fn cut_short(stats: &crate::session::RunStats) -> bool {
1249 stats.stop_cause.is_some_and(|c| c.cut_short())
1250}
1251
1252fn check_charter(path: &Path) -> Vec<Finding> {
1260 if !path.exists() {
1268 return Vec::new();
1269 }
1270 let remedy = |description: &str| {
1271 Some(Remedy {
1272 description: description.to_string(),
1273 argv: vec!["mecha".to_string(), "charter".to_string()],
1274 needs_terminal: false,
1275 })
1276 };
1277 match crate::charter::Charter::load(path) {
1278 Err(e) => vec![Finding {
1279 component: "charter".to_string(),
1280 severity: Severity::Broken,
1281 summary: "charter did not load".to_string(),
1282 detail: format!(
1283 "{}: {e:#} — every run is proceeding un-chartered",
1284 path.display()
1285 ),
1286 remedy: remedy("see the parse error and fix charter.toml"),
1287 }],
1288 Ok(charter) if charter.over_budget() => vec![Finding {
1293 component: "charter".to_string(),
1294 severity: Severity::Attention,
1295 summary: "charter is over its character budget".to_string(),
1296 detail: format!(
1297 "{} is {} characters, over the {}-character budget",
1298 path.display(),
1299 charter.char_count(),
1300 crate::charter::CHARTER_CHAR_BUDGET,
1301 ),
1302 remedy: remedy("review the charter and trim it"),
1303 }],
1304 Ok(charter) if charter.is_empty() => vec![Finding {
1314 component: "charter".to_string(),
1315 severity: Severity::Attention,
1316 summary: "charter file exists but has no lines".to_string(),
1317 detail: format!(
1318 "{} parsed cleanly with zero `[[line]]` entries — nothing from it \
1319 rides in any prompt",
1320 path.display()
1321 ),
1322 remedy: remedy("see what's actually in the charter file"),
1323 }],
1324 Ok(_) => Vec::new(),
1325 }
1326}
1327
1328fn check_runs(sessions: &Path) -> Vec<Finding> {
1337 use crate::runlog::{Corpus, Scan};
1338
1339 let mut out = Vec::new();
1340 if !sessions.is_dir() {
1341 return out;
1342 }
1343 let corpus = match Corpus::scan(
1344 sessions,
1345 &Scan {
1346 max_sessions: Some(RUNS_WINDOW),
1347 since: None,
1348 },
1349 ) {
1350 Ok(c) => c,
1351 Err(e) => {
1352 out.push(Finding::unreadable(
1353 "runs",
1354 "the session store",
1355 format!("{}: {e}", sessions.display()),
1356 ));
1357 return out;
1358 }
1359 };
1360
1361 if corpus.unreadable > 0 {
1368 out.push(Finding::unreadable(
1369 "runs",
1370 &format!("{} transcript(s) in the session store", corpus.unreadable),
1371 format!(
1377 "{}: files with a .jsonl extension that could not be read \
1378 or carry no session header; every reader silently skips them",
1379 sessions.display()
1380 ),
1381 ));
1382 }
1383
1384 let remedy = |what: &str| {
1385 Some(Remedy {
1386 description: format!("read the run-quality summary ({what})"),
1387 argv: vec![
1388 "mecha".into(),
1389 "sessions".into(),
1390 "health".into(),
1391 "--days".into(),
1392 "30".into(),
1393 ],
1394 needs_terminal: false,
1395 })
1396 };
1397
1398 for (model, runs) in corpus.by_model() {
1399 if runs.len() < RUNS_MIN {
1400 continue;
1401 }
1402 let n = runs.len();
1403
1404 if let Some(rate) = runs.rate_of(|r| r.stats.ended_on_failed_call) {
1405 if rate >= ENDED_ON_FAILURE_RATE {
1406 out.push(Finding {
1407 component: "runs".to_string(),
1408 severity: Severity::Attention,
1409 summary: format!(
1410 "{:.0}% of `{model}` runs finished on a failed tool call",
1411 rate * 100.0
1412 ),
1413 detail: format!(
1414 "{} of {n} recent run(s). The model stopped of its own accord with its last call failed, and the answer it wrote may report success over it — which nothing in the text or the stop reason can show.",
1415 runs.ended_on_failed_call()
1416 ),
1417 remedy: remedy("which runs, and what failed"),
1418 });
1419 }
1420 }
1421
1422 if let Some(rate) = runs.tool_error_rate() {
1423 if rate >= TOOL_ERROR_RATE && runs.tool_calls() >= RUNS_MIN_CALLS {
1428 out.push(Finding {
1429 component: "runs".to_string(),
1430 severity: Severity::Attention,
1431 summary: format!(
1432 "`{model}` runs fail {:.0}% of their tool calls",
1433 rate * 100.0
1434 ),
1435 detail: format!(
1436 "{} of {} call(s) across {n} run(s) were refused by the environment. Errors are how a run learns where it is, so some are healthy — a quarter of them says something moved: a renamed path, a revoked grant, a tool whose schema the model keeps mis-filling.",
1437 runs.tool_errors(),
1438 runs.tool_calls()
1439 ),
1440 remedy: remedy("which tool, and how it failed"),
1441 });
1442 }
1443 }
1444
1445 if let Some(rate) = runs.rate_of(|r| cut_short(&r.stats)) {
1446 if rate >= CUT_SHORT_RATE {
1447 let cut = runs.rows.iter().filter(|r| cut_short(&r.stats)).count();
1448 out.push(Finding {
1449 component: "runs".to_string(),
1450 severity: Severity::Attention,
1451 summary: format!(
1452 "the harness cut {:.0}% of `{model}` runs short",
1453 rate * 100.0
1454 ),
1455 detail: format!(
1456 "{cut} of {n} recent run(s) hit a turn, token or cost ceiling, or tripped the loop guard. A budget that stops a quarter of runs is measuring the budget rather than the work — the answers are truncated and say so only in `stop_cause`. Cancellations are not counted here.",
1457 ),
1458 remedy: remedy("which ceiling, and how often"),
1459 });
1460 }
1461 }
1462 }
1463 out
1464}
1465
1466const GRAPH_NIGHTLIES: &[(&str, &str)] = &[
1476 ("nightly-", "the graph's own sweep (ingest, extract, decay)"),
1477 ("mecha-nightly-", "the mecha half (vet, precheck, gossip)"),
1478];
1479
1480const STALE_CANDIDATE_AFTER: chrono::Duration = chrono::Duration::hours(72);
1484
1485const STARVED_LEARNER_MIN_EXCLUDED: usize = 10;
1490
1491fn check_learning(root: &Path, now: DateTime<Utc>) -> Vec<Finding> {
1504 let mut out = Vec::new();
1505 let path = root.join("reflections.jsonl");
1506 if !path.is_file() {
1507 return out;
1508 }
1509 let text = match std::fs::read_to_string(&path) {
1510 Ok(t) => t,
1511 Err(e) => {
1512 out.push(Finding::unreadable(
1513 "learning",
1514 "the reflections archive",
1515 format!("{}: {e}", path.display()),
1516 ));
1517 return out;
1518 }
1519 };
1520
1521 let mut total = 0usize;
1522 let mut excluded = 0usize;
1523 let mut newest_excluded: Option<DateTime<Utc>> = None;
1524 let mut waiting: std::collections::BTreeMap<String, usize> = Default::default();
1526 for line in text.lines().filter(|l| !l.trim().is_empty()) {
1527 let Ok(r) = serde_json::from_str::<crate::learning::Reflexion>(line) else {
1528 continue;
1532 };
1533 total += 1;
1534 if r.learnable() {
1544 if !r.is_processed {
1545 *waiting.entry(r.domain.clone()).or_default() += 1;
1546 }
1547 } else if r.dropped_at.is_none() {
1548 excluded += 1;
1558 if let Ok(t) = DateTime::parse_from_rfc3339(&r.created_at) {
1559 let t = t.with_timezone(&Utc);
1560 if newest_excluded.is_none_or(|n| t > n) {
1561 newest_excluded = Some(t);
1562 }
1563 }
1564 }
1565 }
1566
1567 let floor = crate::learning::LEARN_MIN_REFLECTIONS;
1568 if waiting.values().any(|&n| n >= floor) {
1571 return out;
1572 }
1573 if excluded < STARVED_LEARNER_MIN_EXCLUDED {
1574 return out;
1575 }
1576 let alive = newest_excluded.is_some_and(|t| now.signed_duration_since(t).num_days() <= 30);
1580 if !alive {
1581 return out;
1582 }
1583
1584 let pool = if waiting.is_empty() {
1585 "none clean and unprocessed".to_string()
1586 } else {
1587 waiting
1588 .iter()
1589 .map(|(d, n)| format!("{d} {n}/{floor}"))
1590 .collect::<Vec<_>>()
1591 .join(", ")
1592 };
1593 out.push(Finding {
1594 component: "learning".to_string(),
1595 severity: Severity::Attention,
1596 summary: format!(
1597 "the rule learner is starved: {excluded} of {total} reflections excluded by \
1598 origin, and no domain reaches the learn floor of {floor}"
1599 ),
1600 detail: format!(
1601 "reflect keeps mining and the provenance gate keeps excluding — the gate working \
1602 as designed, every night, with nothing downstream to show for it. Clean pool: \
1603 {pool}. The excluded records stay readable in {} — some are third-party evidence \
1604 the gate held back, some may be mecha's own words correctly kept out of a \
1605 feedback loop; the decision this proposes is yours, not a command's: read what \
1606 got excluded, and change what evidence the loop may consolidate if the mix \
1607 looks wrong.",
1608 path.display()
1609 ),
1610 remedy: Some(Remedy {
1611 description: "see how new interventions classify — doctor never loosens the gate"
1612 .to_string(),
1613 argv: vec!["mecha".into(), "reflect".into(), "--dry-run".into()],
1614 needs_terminal: false,
1615 }),
1616 });
1617 out
1618}
1619
1620fn check_harness(root: &Path, now: DateTime<Utc>) -> Vec<Finding> {
1625 let mut out = Vec::new();
1626 let dir = root.join("candidates");
1627 if !dir.is_dir() {
1628 return out;
1629 }
1630 let entries = match std::fs::read_dir(&dir) {
1631 Ok(entries) => entries,
1632 Err(e) => {
1633 out.push(Finding::unreadable(
1634 "harness",
1635 "the harness candidate directory",
1636 format!("{}: {e}", dir.display()),
1637 ));
1638 return out;
1639 }
1640 };
1641 let mut stale: Vec<String> = Vec::new();
1642 for entry in entries.flatten() {
1643 let path = entry.path();
1644 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1645 continue;
1646 }
1647 let cand: crate::harness::HarnessCandidate =
1648 match std::fs::read_to_string(&path).map(|t| serde_json::from_str(&t)) {
1649 Ok(Ok(cand)) => cand,
1650 Ok(Err(e)) => {
1651 out.push(Finding::unreadable(
1652 "harness",
1653 &format!(
1654 "candidate {} did not parse",
1655 path.file_name().unwrap_or_default().to_string_lossy()
1656 ),
1657 format!("{}: {e}", path.display()),
1658 ));
1659 continue;
1660 }
1661 Err(e) => {
1662 out.push(Finding::unreadable(
1663 "harness",
1664 &format!(
1665 "candidate {} could not be read",
1666 path.file_name().unwrap_or_default().to_string_lossy()
1667 ),
1668 format!("{}: {e}", path.display()),
1669 ));
1670 continue;
1671 }
1672 };
1673 if !cand.pending() {
1674 continue;
1675 }
1676 let old_enough = chrono::DateTime::parse_from_rfc3339(&cand.created_at)
1677 .map(|t| {
1678 now.signed_duration_since(t.with_timezone(&chrono::Utc)) > STALE_CANDIDATE_AFTER
1679 })
1680 .unwrap_or(true);
1682 if old_enough {
1683 stale.push(format!("{} · {:?} {}", cand.id, cand.class, cand.change));
1684 }
1685 }
1686 if !stale.is_empty() {
1687 stale.sort();
1688 out.push(Finding {
1689 component: "harness".to_string(),
1690 severity: Severity::Attention,
1691 summary: format!(
1692 "{} harness candidate(s) staged for more than {}h",
1693 stale.len(),
1694 STALE_CANDIDATE_AFTER.num_hours()
1695 ),
1696 detail: stale.join("\n"),
1697 remedy: Some(Remedy {
1698 description: "review the staged candidates — doctor never accepts one".to_string(),
1699 argv: vec!["mecha".into(), "harness".into(), "list".into()],
1700 needs_terminal: false,
1701 }),
1702 });
1703 }
1704 out
1705}
1706
1707fn check_graph_nightly(store: &Path, now: DateTime<Utc>) -> Vec<Finding> {
1715 let mut out = Vec::new();
1716 let logs = store.join("logs");
1717 if !logs.is_dir() {
1718 return out;
1719 }
1720 let names: Vec<String> = match std::fs::read_dir(&logs) {
1721 Ok(entries) => entries
1722 .flatten()
1723 .filter_map(|e| e.file_name().to_str().map(String::from))
1724 .collect(),
1725 Err(e) => {
1726 out.push(Finding::unreadable(
1727 "graph",
1728 "the graph nightly logs",
1729 format!("{}: {e}", logs.display()),
1730 ));
1731 return out;
1732 }
1733 };
1734
1735 for (prefix, what) in GRAPH_NIGHTLIES {
1736 let newest = names
1737 .iter()
1738 .filter_map(|n| {
1739 n.strip_prefix(prefix)?
1740 .strip_suffix(".log")
1741 .and_then(|d| chrono::NaiveDate::parse_from_str(d, "%Y%m%d").ok())
1742 })
1743 .max();
1744 let Some(newest) = newest else { continue };
1747 let days_quiet = (now.date_naive() - newest).num_days();
1748 if days_quiet > 1 {
1749 out.push(Finding {
1750 component: "graph".to_string(),
1751 severity: Severity::Attention,
1752 summary: format!(
1753 "the graph nightly ({}) has not run for {days_quiet} days",
1754 prefix.trim_end_matches('-'),
1755 ),
1756 detail: format!(
1757 "{what} last wrote {}{}.log under {}; it logs every \
1758 run including deferred ones, so a missing day means the \
1759 script never started — cron reports that nowhere",
1760 prefix,
1761 newest.format("%Y%m%d"),
1762 logs.display(),
1763 ),
1764 remedy: Some(Remedy {
1765 description: "list the cron entries that fire the graph nightlies, \
1766 then run the silent one by hand and read its error"
1767 .to_string(),
1768 argv: vec!["crontab".into(), "-l".into()],
1769 needs_terminal: false,
1770 }),
1771 });
1772 }
1773 }
1774 out
1775}
1776
1777fn age_of(stamp: &str, now: DateTime<Utc>) -> Option<chrono::Duration> {
1782 DateTime::parse_from_rfc3339(stamp)
1783 .ok()
1784 .map(|at| now - at.with_timezone(&Utc))
1785}
1786
1787fn render_age(now: DateTime<Utc>, stamp: &str) -> String {
1789 match age_of(stamp, now) {
1790 Some(age) if age >= chrono::Duration::days(2) => format!("{}d ago", age.num_days()),
1791 Some(age) if age >= chrono::Duration::hours(1) => format!("{}h ago", age.num_hours()),
1792 Some(age) => format!("{}m ago", age.num_minutes().max(0)),
1793 None => stamp.to_string(),
1794 }
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799 use super::*;
1800 use crate::agent::Taint;
1801 use crate::outbox::{OutboxItem, OutboxKind};
1802 use serde_json::json;
1803 use std::path::PathBuf;
1804
1805 fn utc(s: &str) -> DateTime<Utc> {
1806 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
1807 }
1808
1809 const NOW: &str = "2026-08-14T12:00:00Z";
1810
1811 fn home(name: &str) -> PathBuf {
1813 let dir = std::env::temp_dir().join(format!(
1814 "mecha-doctor-test-{name}-{}-{:?}",
1815 std::process::id(),
1816 std::thread::current().id()
1817 ));
1818 let _ = std::fs::remove_dir_all(&dir);
1819 std::fs::create_dir_all(&dir).unwrap();
1820 dir
1821 }
1822
1823 fn write_marker(home: &Path, account: &str, body: &str) {
1824 let dir = home.join("mail").join(account);
1825 std::fs::create_dir_all(&dir).unwrap();
1826 std::fs::write(dir.join("auth_error.json"), body).unwrap();
1827 }
1828
1829 fn valid_marker() -> String {
1830 json!({
1831 "at": "2026-08-11T09:00:00Z",
1832 "message": "the refresh token was revoked — run `mecha-mail auth personal --provider google` to sign in again",
1833 })
1834 .to_string()
1835 }
1836
1837 fn pending_item(home: &Path, id: &str, created_at: &str, error: Option<&str>) {
1838 let item = OutboxItem {
1839 id: id.to_string(),
1840 status: "pending".into(),
1841 tool: "mail__send".into(),
1842 kind: OutboxKind::Message,
1843 args_before: json!({"to": "a@x.org"}),
1844 args: json!({"to": "a@x.org"}),
1845 summary: "mail__send to a@x.org".into(),
1846 session_id: None,
1847 workspace: None,
1848 taint: Taint::default(),
1849 created_at: created_at.to_string(),
1850 resolved_at: None,
1851 reason: None,
1852 error: error.map(String::from),
1853 };
1854 let dir = home.join("outbox");
1855 std::fs::create_dir_all(&dir).unwrap();
1856 std::fs::write(
1857 dir.join(format!("{id}.json")),
1858 serde_json::to_string_pretty(&item).unwrap(),
1859 )
1860 .unwrap();
1861 }
1862
1863 fn request(home: &Path, seq: i64, state: &str, drained_at: &str) {
1864 let dir = home.join("requests");
1865 std::fs::create_dir_all(&dir).unwrap();
1866 let record = json!({
1867 "seq": seq,
1868 "type_id": "meeting",
1869 "state": state,
1870 "created_at": drained_at,
1871 "drained_at": drained_at,
1872 "valid": true,
1873 "values": {},
1874 "free_text": [],
1875 });
1876 std::fs::write(
1877 dir.join(format!("{seq:010}-meeting.json")),
1878 record.to_string(),
1879 )
1880 .unwrap();
1881 }
1882
1883 fn trigger_file(home: &Path, name: &str, extra: &str) {
1884 let dir = home.join("triggers");
1885 std::fs::create_dir_all(&dir).unwrap();
1886 std::fs::write(
1887 dir.join(format!("{name}.toml")),
1888 format!(
1889 "schedule = \"0 7 * * *\"\nprompt = \"brief me\"\ntimezone = \"UTC\"\n\
1890 created_at = \"2026-08-01T00:00:00Z\"\n{extra}"
1891 ),
1892 )
1893 .unwrap();
1894 }
1895
1896 fn ledger_row(home: &Path, row: &serde_json::Value) {
1897 use std::io::Write;
1898 let dir = home.join("triggers");
1899 std::fs::create_dir_all(&dir).unwrap();
1900 let mut file = std::fs::OpenOptions::new()
1901 .create(true)
1902 .append(true)
1903 .open(dir.join("runs.jsonl"))
1904 .unwrap();
1905 writeln!(file, "{row}").unwrap();
1906 }
1907
1908 fn of<'a>(findings: &'a [Finding], component: &str) -> Vec<&'a Finding> {
1909 findings
1910 .iter()
1911 .filter(|f| f.component == component)
1912 .collect()
1913 }
1914
1915 #[test]
1916 fn a_dead_auth_marker_is_found_and_an_absent_one_is_not() {
1917 let home = home("dead-auth");
1918 write_marker(&home, "personal", &valid_marker());
1919 std::fs::create_dir_all(home.join("mail").join("dartmouth")).unwrap();
1921 std::fs::write(
1922 home.join("mail").join("accounts.toml"),
1923 "[[account]]\nname = \"personal\"\nprovider = \"google\"\n\
1924 [[account]]\nname = \"dartmouth\"\nprovider = \"outlook\"\n",
1925 )
1926 .unwrap();
1927
1928 let findings = examine(&home, utc(NOW));
1929 let mail = of(&findings, "mail");
1930 assert_eq!(mail.len(), 1, "{findings:#?}");
1931 assert_eq!(mail[0].severity, Severity::Broken);
1932 assert!(mail[0].summary.contains("personal"), "{}", mail[0].summary);
1933 let remedy = mail[0].remedy.as_ref().expect("a dead login has a way out");
1934 assert_eq!(
1935 remedy.argv,
1936 vec!["mecha-mail", "auth", "personal", "--provider", "google"]
1937 );
1938 assert!(
1939 remedy.needs_terminal,
1940 "an OAuth flow needs the real terminal"
1941 );
1942
1943 let _ = std::fs::remove_dir_all(&home);
1944 }
1945
1946 #[test]
1947 fn a_provider_the_registry_cannot_name_is_omitted_from_the_remedy_not_guessed() {
1948 let home = home("no-registry");
1949 write_marker(&home, "personal", &valid_marker());
1951
1952 let findings = examine(&home, utc(NOW));
1953 let mail = of(&findings, "mail");
1954 assert_eq!(mail.len(), 1);
1955 let remedy = mail[0].remedy.as_ref().unwrap();
1956 assert_eq!(remedy.argv, vec!["mecha-mail", "auth", "personal"]);
1957 assert!(
1960 mail[0].detail.contains("--provider google"),
1961 "{}",
1962 mail[0].detail
1963 );
1964
1965 let _ = std::fs::remove_dir_all(&home);
1966 }
1967
1968 #[test]
1973 fn a_marker_in_a_legacy_per_provider_store_is_found_and_proposes_import() {
1974 let home = home("legacy-auth");
1975 let dir = home.join("google");
1976 std::fs::create_dir_all(&dir).unwrap();
1977 std::fs::write(
1978 dir.join("auth_error.json"),
1979 json!({
1980 "at": "2026-08-11T09:00:00Z",
1981 "message": "account `google`: refresh token expired or revoked — run `mecha-mail auth google --provider google` (invalid_grant)",
1982 })
1983 .to_string(),
1984 )
1985 .unwrap();
1986
1987 let findings = examine(&home, utc(NOW));
1988 let mail = of(&findings, "mail");
1989 assert_eq!(mail.len(), 1, "{findings:#?}");
1990 assert_eq!(mail[0].severity, Severity::Broken);
1991 assert!(
1992 mail[0].summary.contains("legacy google"),
1993 "{}",
1994 mail[0].summary
1995 );
1996 assert!(
1999 mail[0]
2000 .detail
2001 .contains("run `mecha-mail auth google --provider google`"),
2002 "{}",
2003 mail[0].detail
2004 );
2005 let remedy = mail[0].remedy.as_ref().expect("a way out");
2006 assert_eq!(
2007 remedy.argv,
2008 vec!["mecha-mail", "import", "google", "--provider", "google"]
2009 );
2010
2011 let _ = std::fs::remove_dir_all(&home);
2012 }
2013
2014 #[test]
2015 fn an_unparseable_marker_is_a_store_unreadable_finding_not_a_crash() {
2016 let home = home("bad-marker");
2017 write_marker(&home, "personal", "{ this is not json");
2018
2019 let findings = examine(&home, utc(NOW));
2020 let mail = of(&findings, "mail");
2021 assert_eq!(mail.len(), 1, "{findings:#?}");
2022 assert!(
2023 mail[0].summary.starts_with("store unreadable:"),
2024 "{}",
2025 mail[0].summary
2026 );
2027 assert!(mail[0].summary.contains("personal"), "{}", mail[0].summary);
2028
2029 let _ = std::fs::remove_dir_all(&home);
2030 }
2031
2032 #[test]
2033 fn a_pending_item_with_an_error_is_broken_and_a_resolved_one_is_not() {
2034 let home = home("outbox-error");
2035 pending_item(
2036 &home,
2037 "20260814-000001-aaa",
2038 NOW,
2039 Some("server unreachable"),
2040 );
2041 let mut sent = json!({
2043 "id": "20260810-000001-bbb",
2044 "status": "sent",
2045 "tool": "mail__send",
2046 "args_before": {},
2047 "args": {},
2048 "summary": "mail__send",
2049 "created_at": "2026-08-01T00:00:00Z",
2050 });
2051 sent["error"] = json!(null);
2052 std::fs::write(
2053 home.join("outbox").join("20260810-000001-bbb.json"),
2054 sent.to_string(),
2055 )
2056 .unwrap();
2057
2058 let findings = examine(&home, utc(NOW));
2059 let outbox = of(&findings, "outbox");
2060 assert_eq!(outbox.len(), 1, "{findings:#?}");
2061 assert_eq!(outbox[0].severity, Severity::Broken);
2062 assert!(
2063 outbox[0]
2064 .summary
2065 .contains("release failed: server unreachable"),
2066 "{}",
2067 outbox[0].summary
2068 );
2069 let remedy = outbox[0].remedy.as_ref().unwrap();
2070 assert_eq!(remedy.argv, vec!["mecha", "outbox", "review"]);
2071
2072 let _ = std::fs::remove_dir_all(&home);
2073 }
2074
2075 fn question(home: &Path, id: &str, asked_at: &str, status: &str) {
2076 let dir = home.join("questions");
2077 std::fs::create_dir_all(&dir).unwrap();
2078 let q = crate::questions::Question {
2079 id: id.into(),
2080 status: status.into(),
2081 question: "Which address should the letter go to?".into(),
2082 options: vec![],
2083 session_id: "sess-1".into(),
2084 task_id: Some("task-9".into()),
2085 workspace: None,
2086 taint: Default::default(),
2087 asked_at: asked_at.into(),
2088 answered_at: None,
2089 answer: None,
2090 };
2091 std::fs::write(
2092 dir.join(format!("{id}.json")),
2093 serde_json::to_string_pretty(&q).unwrap(),
2094 )
2095 .unwrap();
2096 }
2097
2098 #[test]
2102 fn an_unanswered_question_is_stale_at_25_hours_and_not_at_23() {
2103 let home = home("questions-stale");
2104 question(
2105 &home,
2106 "20260813-100000-aaaaaaaa",
2107 "2026-08-13T10:00:00Z",
2108 "open",
2109 );
2110 let findings = examine(&home, utc(NOW));
2111 let qs = of(&findings, "questions");
2112 assert_eq!(qs.len(), 1, "{findings:#?}");
2113 assert_eq!(qs[0].severity, Severity::Attention);
2114 assert!(
2115 qs[0].summary.contains("cannot continue"),
2116 "{:?}",
2117 qs[0].summary
2118 );
2119 assert_eq!(
2120 qs[0].remedy.as_ref().unwrap().argv,
2121 vec!["mecha", "questions", "list"],
2122 "doctor lists what is stuck; it never answers for the owner"
2123 );
2124
2125 let fresh = home;
2126 let _ = std::fs::remove_dir_all(fresh.join("questions"));
2127 question(
2128 &fresh,
2129 "20260813-130000-bbbbbbbb",
2130 "2026-08-13T13:00:00Z",
2131 "open",
2132 );
2133 assert!(of(&examine(&fresh, utc(NOW)), "questions").is_empty());
2134 let _ = std::fs::remove_dir_all(&fresh);
2135 }
2136
2137 #[test]
2141 fn an_answered_question_never_ages_into_a_finding() {
2142 let home = home("questions-answered");
2143 question(
2144 &home,
2145 "20260701-100000-cccccccc",
2146 "2026-07-01T10:00:00Z",
2147 "answered",
2148 );
2149 question(
2150 &home,
2151 "20260701-100000-dddddddd",
2152 "2026-07-01T10:00:00Z",
2153 "abandoned",
2154 );
2155 assert!(of(&examine(&home, utc(NOW)), "questions").is_empty());
2156 let _ = std::fs::remove_dir_all(&home);
2157 }
2158
2159 #[test]
2162 fn a_store_that_was_never_created_is_not_a_finding() {
2163 let home = home("questions-absent");
2164 assert!(of(&examine(&home, utc(NOW)), "questions").is_empty());
2165 let _ = std::fs::remove_dir_all(&home);
2166 }
2167
2168 #[test]
2169 fn a_pending_draft_is_stale_at_49_hours_and_not_at_47() {
2170 let home = home("outbox-stale");
2171 pending_item(&home, "20260812-110000-old", "2026-08-12T11:00:00Z", None);
2173 let findings = examine(&home, utc(NOW));
2174 let outbox = of(&findings, "outbox");
2175 assert_eq!(outbox.len(), 1, "{findings:#?}");
2176 assert_eq!(outbox[0].severity, Severity::Attention);
2177 assert!(outbox[0].summary.contains("pending for more than 48h"));
2178 assert_eq!(
2179 outbox[0].remedy.as_ref().unwrap().argv,
2180 vec!["mecha", "outbox", "review"],
2181 "the remedy is the review surface, never send"
2182 );
2183
2184 let fresh = home;
2186 let _ = std::fs::remove_dir_all(fresh.join("outbox"));
2187 pending_item(&fresh, "20260812-130000-new", "2026-08-12T13:00:00Z", None);
2188 let findings = examine(&fresh, utc(NOW));
2189 assert!(of(&findings, "outbox").is_empty(), "{findings:#?}");
2190
2191 let _ = std::fs::remove_dir_all(&fresh);
2192 }
2193
2194 fn harness_candidate(home: &Path, id: &str, created_at: &str, status: &str) {
2195 let dir = home.join("learning").join("harness").join("candidates");
2196 std::fs::create_dir_all(&dir).unwrap();
2197 let cand = crate::harness::HarnessCandidate {
2198 id: id.into(),
2199 created_at: created_at.into(),
2200 class: crate::candidate::ChangeClass::Config,
2201 change: "compact_at_tokens=24000".into(),
2202 metric: crate::candidate::Metric::CutShort,
2203 rationale: "test".into(),
2204 evidence: String::new(),
2205 model: None,
2206 status: status.into(),
2207 measurement: None,
2208 resolved_at: None,
2209 reason: None,
2210 };
2211 std::fs::write(
2212 dir.join(format!("{id}.json")),
2213 serde_json::to_string_pretty(&cand).unwrap(),
2214 )
2215 .unwrap();
2216 }
2217
2218 fn reflection_line(id: &str, origin: &str, processed: bool, created_at: &str) -> String {
2219 reflection_line_with_intervention(id, origin, processed, created_at, "")
2220 }
2221
2222 fn reflection_line_with_intervention(
2227 id: &str,
2228 origin: &str,
2229 processed: bool,
2230 created_at: &str,
2231 intervention: &str,
2232 ) -> String {
2233 serde_json::json!({
2234 "id": id,
2235 "domain": "behavior",
2236 "session_id": "s",
2237 "trigger": "steer",
2238 "context": "",
2239 "intervention": intervention,
2240 "reflexion_text": "test",
2241 "is_processed": processed,
2242 "created_at": created_at,
2243 "origin": origin,
2244 })
2245 .to_string()
2246 }
2247
2248 fn reflection_line_dropped(id: &str, origin: &str, created_at: &str) -> String {
2253 serde_json::json!({
2254 "id": id,
2255 "domain": "behavior",
2256 "session_id": "s",
2257 "trigger": "steer",
2258 "context": "",
2259 "intervention": "",
2260 "reflexion_text": "test",
2261 "is_processed": false,
2262 "created_at": created_at,
2263 "origin": origin,
2264 "dropped_at": created_at,
2265 })
2266 .to_string()
2267 }
2268
2269 fn write_reflections(home: &Path, lines: &[String]) {
2270 let dir = home.join("learning");
2271 std::fs::create_dir_all(&dir).unwrap();
2272 std::fs::write(dir.join("reflections.jsonl"), lines.join("\n")).unwrap();
2273 }
2274
2275 #[test]
2276 fn a_learner_fed_only_excluded_evidence_is_starved_and_a_met_floor_is_not() {
2277 let home = home("learning-starved");
2278 let mut lines: Vec<String> = (0..12)
2280 .map(|i| reflection_line(&format!("u{i}"), "untrusted", false, "2026-08-13T12:00:00Z"))
2281 .collect();
2282 lines.push(reflection_line(
2283 "c1",
2284 "clean",
2285 false,
2286 "2026-08-05T00:00:00Z",
2287 ));
2288 write_reflections(&home, &lines);
2289
2290 let findings = examine(&home, utc(NOW));
2291 let learning = of(&findings, "learning");
2292 assert_eq!(learning.len(), 1, "{findings:#?}");
2293 assert_eq!(learning[0].severity, Severity::Attention);
2294 assert!(
2295 learning[0].summary.contains("starved"),
2296 "{}",
2297 learning[0].summary
2298 );
2299 assert!(
2300 learning[0].summary.contains("12 of 13"),
2301 "{}",
2302 learning[0].summary
2303 );
2304 assert_eq!(
2305 learning[0].remedy.as_ref().unwrap().argv,
2306 vec!["mecha", "reflect", "--dry-run"],
2307 "the remedy shows classifications; nothing may loosen the gate"
2308 );
2309
2310 lines.push(reflection_line(
2312 "c2",
2313 "clean",
2314 false,
2315 "2026-08-06T00:00:00Z",
2316 ));
2317 lines.push(reflection_line(
2318 "c3",
2319 "clean",
2320 false,
2321 "2026-08-07T00:00:00Z",
2322 ));
2323 write_reflections(&home, &lines);
2324 let findings = examine(&home, utc(NOW));
2325 assert!(of(&findings, "learning").is_empty(), "{findings:#?}");
2326
2327 let _ = std::fs::remove_dir_all(&home);
2328 }
2329
2330 #[test]
2336 fn an_owners_drop_is_not_a_provenance_exclusion() {
2337 let home = home("learning-dropped");
2338 let lines: Vec<String> = (0..12)
2341 .map(|i| reflection_line_dropped(&format!("d{i}"), "untrusted", "2026-08-13T12:00:00Z"))
2342 .collect();
2343 write_reflections(&home, &lines);
2344 assert!(
2345 of(&examine(&home, utc(NOW)), "learning").is_empty(),
2346 "a dozen owner refusals must not read as a starved learner"
2347 );
2348
2349 let mut lines = lines;
2353 lines.extend((0..5).map(|i| {
2354 reflection_line(&format!("u{i}"), "untrusted", false, "2026-08-13T12:00:00Z")
2355 }));
2356 write_reflections(&home, &lines);
2357 assert!(
2358 of(&examine(&home, utc(NOW)), "learning").is_empty(),
2359 "5 genuine exclusions is below the floor even with 12 drops beside them"
2360 );
2361
2362 lines.extend((5..10).map(|i| {
2365 reflection_line(&format!("u{i}"), "untrusted", false, "2026-08-13T12:00:00Z")
2366 }));
2367 write_reflections(&home, &lines);
2368 let findings = examine(&home, utc(NOW));
2369 let learning = of(&findings, "learning");
2370 assert_eq!(learning.len(), 1, "{findings:#?}");
2371 assert!(
2372 learning[0].summary.contains("10 of"),
2373 "the 12 drops must not be counted as excluded: {}",
2374 learning[0].summary
2375 );
2376
2377 let _ = std::fs::remove_dir_all(&home);
2378 }
2379
2380 #[test]
2387 fn a_reflection_stored_clean_before_harness_voice_existed_does_not_count_as_waiting() {
2388 let home = home("learning-harness-voice");
2389 let mut lines: Vec<String> = (0..10)
2390 .map(|i| reflection_line(&format!("u{i}"), "untrusted", false, "2026-08-13T12:00:00Z"))
2391 .collect();
2392 lines.push(reflection_line_with_intervention(
2394 "h1",
2395 "clean",
2396 false,
2397 "2026-08-05T00:00:00Z",
2398 crate::agent::FINAL_ANSWER_NUDGE,
2399 ));
2400 lines.push(reflection_line_with_intervention(
2401 "h2",
2402 "clean",
2403 false,
2404 "2026-08-06T00:00:00Z",
2405 crate::agent::FINAL_ANSWER_NUDGE,
2406 ));
2407 lines.push(reflection_line(
2411 "c1",
2412 "clean",
2413 false,
2414 "2026-08-07T00:00:00Z",
2415 ));
2416 write_reflections(&home, &lines);
2417
2418 let findings = examine(&home, utc(NOW));
2419 let learning = of(&findings, "learning");
2420 assert_eq!(learning.len(), 1, "{findings:#?}");
2421 assert!(
2422 learning[0].summary.contains("starved"),
2423 "the two harness-voice records must not read as met-floor evidence: {}",
2424 learning[0].summary
2425 );
2426
2427 let _ = std::fs::remove_dir_all(&home);
2428 }
2429
2430 #[test]
2431 fn thin_or_dormant_exclusion_is_not_starvation() {
2432 let home = home("learning-thin");
2433 let lines: Vec<String> = (0..9)
2435 .map(|i| reflection_line(&format!("u{i}"), "untrusted", false, "2026-08-13T12:00:00Z"))
2436 .collect();
2437 write_reflections(&home, &lines);
2438 assert!(of(&examine(&home, utc(NOW)), "learning").is_empty());
2439
2440 let lines: Vec<String> = (0..12)
2443 .map(|i| reflection_line(&format!("u{i}"), "untrusted", false, "2026-05-01T12:00:00Z"))
2444 .collect();
2445 write_reflections(&home, &lines);
2446 assert!(of(&examine(&home, utc(NOW)), "learning").is_empty());
2447
2448 let mut lines: Vec<String> = (0..12)
2451 .map(|i| reflection_line(&format!("u{i}"), "untrusted", false, "2026-08-13T12:00:00Z"))
2452 .collect();
2453 for i in 0..3 {
2454 lines.push(reflection_line(
2455 &format!("p{i}"),
2456 "clean",
2457 true,
2458 "2026-08-05T00:00:00Z",
2459 ));
2460 }
2461 write_reflections(&home, &lines);
2462 let findings = examine(&home, utc(NOW));
2463 assert_eq!(of(&findings, "learning").len(), 1, "{findings:#?}");
2464
2465 let _ = std::fs::remove_dir_all(&home);
2466 }
2467
2468 #[test]
2469 fn a_staged_harness_candidate_is_stale_at_73_hours_and_not_at_71() {
2470 let home = home("harness-stale");
2471 harness_candidate(&home, "hc-old", "2026-08-11T11:00:00Z", "staged");
2473 harness_candidate(&home, "hc-done", "2026-08-01T00:00:00Z", "rejected");
2475 let findings = examine(&home, utc(NOW));
2476 let harness = of(&findings, "harness");
2477 assert_eq!(harness.len(), 1, "{findings:#?}");
2478 assert_eq!(harness[0].severity, Severity::Attention);
2479 assert!(harness[0].summary.contains("staged for more than 72h"));
2480 assert!(
2481 harness[0].detail.contains("hc-old"),
2482 "{}",
2483 harness[0].detail
2484 );
2485 assert_eq!(
2486 harness[0].remedy.as_ref().unwrap().argv,
2487 vec!["mecha", "harness", "list"],
2488 "the remedy is the review surface, never accept"
2489 );
2490
2491 let _ = std::fs::remove_dir_all(home.join("learning"));
2493 harness_candidate(&home, "hc-new", "2026-08-11T13:00:00Z", "staged");
2494 let findings = examine(&home, utc(NOW));
2495 assert!(of(&findings, "harness").is_empty(), "{findings:#?}");
2496
2497 let _ = std::fs::remove_dir_all(&home);
2498 }
2499
2500 #[test]
2501 fn a_failed_extraction_is_broken_at_any_age() {
2502 let home = home("frontdoor-failed");
2503 request(&home, 12, crate::frontdoor::EXTRACTION_FAILED, NOW);
2504
2505 let findings = examine(&home, utc(NOW));
2506 let front = of(&findings, "frontdoor");
2507 assert_eq!(front.len(), 1, "{findings:#?}");
2508 assert_eq!(front[0].severity, Severity::Broken);
2509 assert!(front[0].summary.contains("12"), "{}", front[0].summary);
2510 assert_eq!(
2511 front[0].remedy.as_ref().unwrap().argv,
2512 vec!["mecha", "frontdoor", "list"]
2513 );
2514
2515 let _ = std::fs::remove_dir_all(&home);
2516 }
2517
2518 #[test]
2519 fn a_request_waiting_on_me_is_stale_at_73_hours_and_not_at_71() {
2520 let home = home("frontdoor-stale");
2521 request(
2523 &home,
2524 1,
2525 crate::frontdoor::AWAITING_ME,
2526 "2026-08-11T11:00:00Z",
2527 );
2528 let findings = examine(&home, utc(NOW));
2529 let front = of(&findings, "frontdoor");
2530 assert_eq!(front.len(), 1, "{findings:#?}");
2531 assert_eq!(front[0].severity, Severity::Attention);
2532 assert!(front[0].summary.contains("waiting on you"));
2533
2534 let _ = std::fs::remove_dir_all(home.join("requests"));
2536 request(
2537 &home,
2538 2,
2539 crate::frontdoor::AWAITING_ME,
2540 "2026-08-11T13:00:00Z",
2541 );
2542 let findings = examine(&home, utc(NOW));
2543 assert!(of(&findings, "frontdoor").is_empty(), "{findings:#?}");
2544
2545 let _ = std::fs::remove_dir_all(home.join("requests"));
2547 request(
2548 &home,
2549 3,
2550 crate::frontdoor::NEEDS_INFO,
2551 "2026-08-01T00:00:00Z",
2552 );
2553 let findings = examine(&home, utc(NOW));
2554 assert!(of(&findings, "frontdoor").is_empty(), "{findings:#?}");
2555
2556 let _ = std::fs::remove_dir_all(&home);
2557 }
2558
2559 #[test]
2563 fn a_triaged_request_nothing_will_revisit_goes_stale() {
2564 let home = home("frontdoor-triaged");
2565 request(&home, 4, crate::frontdoor::TRIAGED, "2026-08-11T11:00:00Z");
2567 request(
2569 &home,
2570 5,
2571 crate::frontdoor::NEEDS_INFO,
2572 "2026-08-01T00:00:00Z",
2573 );
2574
2575 let findings = examine(&home, utc(NOW));
2576 let front = of(&findings, "frontdoor");
2577 assert_eq!(front.len(), 1, "{findings:#?}");
2578 assert_eq!(front[0].severity, Severity::Attention);
2579 assert!(front[0].detail.contains("triaged"), "{}", front[0].detail);
2580 assert!(
2581 !front[0].detail.contains("needs_info"),
2582 "needs_info waits on the requester: {}",
2583 front[0].detail
2584 );
2585
2586 let _ = std::fs::remove_dir_all(&home);
2587 }
2588
2589 #[test]
2590 fn a_trigger_whose_last_run_failed_is_flagged_with_the_manual_probe() {
2591 let home = home("trigger-failed");
2592 trigger_file(&home, "morning", "");
2593 ledger_row(
2594 &home,
2595 &json!({
2596 "trigger": "morning",
2597 "slot": "2026-08-13T07:00:00Z",
2598 "started_at": "2026-08-13T07:00:01Z",
2599 "status": "ok",
2600 "summary": "fine",
2601 }),
2602 );
2603 ledger_row(
2604 &home,
2605 &json!({
2606 "trigger": "morning",
2607 "slot": "2026-08-14T07:00:00Z",
2608 "started_at": "2026-08-14T07:00:01Z",
2609 "status": "error",
2610 "error": "provider unreachable",
2611 }),
2612 );
2613
2614 let findings = examine(&home, utc(NOW));
2615 let triggers = of(&findings, "triggers");
2616 assert_eq!(triggers.len(), 1, "{findings:#?}");
2617 assert_eq!(triggers[0].severity, Severity::Attention);
2618 assert!(triggers[0].summary.contains("morning"));
2619 assert!(triggers[0].detail.contains("provider unreachable"));
2620 assert_eq!(
2621 triggers[0].remedy.as_ref().unwrap().argv,
2622 vec!["mecha", "trigger", "run", "morning"],
2623 "a manual run is the safe probe: it never advances the schedule"
2624 );
2625
2626 let _ = std::fs::remove_dir_all(&home);
2627 }
2628
2629 #[test]
2633 fn a_skip_row_after_a_failed_run_does_not_hide_the_failure() {
2634 let home = home("trigger-skip-hides-error");
2635 trigger_file(&home, "morning", "");
2636 ledger_row(
2637 &home,
2638 &json!({
2639 "trigger": "morning",
2640 "slot": "2026-08-13T07:00:00Z",
2641 "started_at": "2026-08-13T07:00:01Z",
2642 "status": "error",
2643 "error": "provider unreachable",
2644 }),
2645 );
2646 ledger_row(
2647 &home,
2648 &json!({
2649 "trigger": "morning",
2650 "slot": "2026-08-14T07:00:00Z",
2651 "started_at": "2026-08-14T07:00:01Z",
2652 "status": "skipped-stale",
2653 }),
2654 );
2655
2656 let findings = examine(&home, utc(NOW));
2657 let triggers = of(&findings, "triggers");
2658 assert_eq!(triggers.len(), 1, "{findings:#?}");
2659 assert!(
2660 triggers[0].summary.contains("most recent run failed"),
2661 "{}",
2662 triggers[0].summary
2663 );
2664 assert!(triggers[0].detail.contains("provider unreachable"));
2665
2666 let _ = std::fs::remove_dir_all(&home);
2667 }
2668
2669 #[test]
2670 fn an_ok_run_followed_by_a_skip_is_healthy() {
2671 let home = home("trigger-ok-then-skip");
2672 trigger_file(&home, "morning", "");
2673 ledger_row(
2674 &home,
2675 &json!({
2676 "trigger": "morning",
2677 "slot": "2026-08-13T07:00:00Z",
2678 "started_at": "2026-08-13T07:00:01Z",
2679 "status": "ok",
2680 }),
2681 );
2682 ledger_row(
2683 &home,
2684 &json!({
2685 "trigger": "morning",
2686 "slot": "2026-08-14T07:00:00Z",
2687 "started_at": "2026-08-14T07:00:01Z",
2688 "status": "skipped-overlap",
2689 }),
2690 );
2691
2692 let findings = examine(&home, utc(NOW));
2693 assert!(of(&findings, "triggers").is_empty(), "{findings:#?}");
2694
2695 let _ = std::fs::remove_dir_all(&home);
2696 }
2697
2698 #[test]
2699 fn a_trigger_quietly_failing_a_third_of_its_calls_is_reported() {
2700 let home = home("trigger-tool-errors");
2703 trigger_file(&home, "morning", "");
2704 for day in 10..15 {
2705 ledger_row(
2706 &home,
2707 &json!({
2708 "trigger": "morning",
2709 "slot": format!("2026-08-{day}T07:00:00Z"),
2710 "started_at": format!("2026-08-{day}T07:00:01Z"),
2711 "status": "ok",
2712 "summary": "briefed",
2713 "tool_calls": 6,
2714 "tool_errors": 3,
2715 }),
2716 );
2717 }
2718
2719 let findings = examine(&home, utc(NOW));
2720 let triggers = of(&findings, "triggers");
2721 assert_eq!(triggers.len(), 1, "{findings:#?}");
2722 assert_eq!(triggers[0].severity, Severity::Attention);
2723 assert!(
2724 triggers[0].summary.contains("15 of 30"),
2725 "{}",
2726 triggers[0].summary
2727 );
2728 assert_eq!(
2729 triggers[0].remedy.as_ref().unwrap().argv,
2730 vec!["mecha", "trigger", "show", "morning"],
2731 "reading is the remedy — what to change is in the transcript"
2732 );
2733
2734 let _ = std::fs::remove_dir_all(&home);
2735 }
2736
2737 #[test]
2738 fn a_handful_of_failed_calls_is_not_a_trend() {
2739 let home = home("trigger-tool-errors-quiet");
2744 trigger_file(&home, "morning", "");
2745 ledger_row(
2747 &home,
2748 &json!({
2749 "trigger": "morning",
2750 "slot": "2026-08-14T07:00:00Z",
2751 "started_at": "2026-08-14T07:00:01Z",
2752 "status": "ok",
2753 "tool_calls": 3,
2754 "tool_errors": 3,
2755 }),
2756 );
2757 assert!(of(&examine(&home, utc(NOW)), "triggers").is_empty());
2758
2759 ledger_row(
2761 &home,
2762 &json!({
2763 "trigger": "morning",
2764 "slot": "2026-08-15T07:00:00Z",
2765 "started_at": "2026-08-15T07:00:01Z",
2766 "status": "ok",
2767 "tool_calls": 40,
2768 "tool_errors": 4,
2769 }),
2770 );
2771 assert!(of(&examine(&home, utc(NOW)), "triggers").is_empty());
2772
2773 let _ = std::fs::remove_dir_all(&home);
2774 }
2775
2776 #[test]
2777 fn a_trigger_that_stopped_doing_anything_is_reported() {
2778 let home = home("trigger-stopped-working");
2781 trigger_file(&home, "morning", "");
2782 for day in 10..14 {
2783 ledger_row(
2784 &home,
2785 &json!({
2786 "trigger": "morning",
2787 "slot": format!("2026-08-{day}T07:00:00Z"),
2788 "started_at": format!("2026-08-{day}T07:00:01Z"),
2789 "status": "ok",
2790 "tool_calls": 8,
2791 "tool_errors": 0,
2792 }),
2793 );
2794 }
2795 ledger_row(
2796 &home,
2797 &json!({
2798 "trigger": "morning",
2799 "slot": "2026-08-14T07:00:00Z",
2800 "started_at": "2026-08-14T07:00:01Z",
2801 "status": "ok",
2802 "summary": "nothing to report",
2803 "tool_calls": 0,
2804 "tool_errors": 0,
2805 }),
2806 );
2807
2808 let findings = examine(&home, utc(NOW));
2809 let triggers = of(&findings, "triggers");
2810 assert_eq!(triggers.len(), 1, "{findings:#?}");
2811 assert!(
2812 triggers[0].summary.contains("did no work"),
2813 "{}",
2814 triggers[0].summary
2815 );
2816 assert!(triggers[0].detail.contains("made 32"));
2817
2818 let _ = std::fs::remove_dir_all(&home);
2819 }
2820
2821 #[test]
2822 fn a_trigger_that_never_needed_tools_is_not_broken_for_not_using_them() {
2823 let home = home("trigger-never-used-tools");
2828 trigger_file(&home, "haiku", "");
2829 for day in 10..15 {
2830 ledger_row(
2831 &home,
2832 &json!({
2833 "trigger": "haiku",
2834 "slot": format!("2026-08-{day}T07:00:00Z"),
2835 "started_at": format!("2026-08-{day}T07:00:01Z"),
2836 "status": "ok",
2837 "tool_calls": 0,
2838 "tool_errors": 0,
2839 }),
2840 );
2841 }
2842 assert!(of(&examine(&home, utc(NOW)), "triggers").is_empty());
2843
2844 let _ = std::fs::remove_dir_all(&home);
2845 }
2846
2847 #[test]
2848 fn a_failed_run_that_did_no_work_is_reported_once_not_twice() {
2849 let home = home("trigger-failed-no-work");
2854 trigger_file(&home, "morning", "");
2855 for day in 10..14 {
2856 ledger_row(
2857 &home,
2858 &json!({
2859 "trigger": "morning",
2860 "slot": format!("2026-08-{day}T07:00:00Z"),
2861 "started_at": format!("2026-08-{day}T07:00:01Z"),
2862 "status": "ok",
2863 "tool_calls": 8,
2864 "tool_errors": 0,
2865 }),
2866 );
2867 }
2868 ledger_row(
2869 &home,
2870 &json!({
2871 "trigger": "morning",
2872 "slot": "2026-08-14T07:00:00Z",
2873 "started_at": "2026-08-14T07:00:01Z",
2874 "status": "error",
2875 "error": "provider unreachable",
2876 "tool_calls": 0,
2877 "tool_errors": 0,
2878 }),
2879 );
2880
2881 let triggers = of(&examine(&home, utc(NOW)), "triggers")
2882 .into_iter()
2883 .cloned()
2884 .collect::<Vec<_>>();
2885 assert_eq!(triggers.len(), 1, "{triggers:#?}");
2886 assert!(triggers[0].detail.contains("provider unreachable"));
2887
2888 let _ = std::fs::remove_dir_all(&home);
2889 }
2890
2891 #[test]
2897 fn an_unreadable_transcript_is_a_finding_not_an_empty_queue() {
2898 let home = home("runs-unreadable");
2899 let dir = home.join("sessions");
2900 std::fs::create_dir_all(&dir).unwrap();
2901 std::fs::write(dir.join("20260828T000000-torn.jsonl"), "not json\n").unwrap();
2902
2903 let all = examine(&home, utc(NOW));
2904 let findings = of(&all, "runs");
2905 assert_eq!(findings.len(), 1, "{findings:#?}");
2906 assert!(
2907 findings[0].summary.contains("unreadable") && findings[0].summary.contains('1'),
2908 "{}",
2909 findings[0].summary
2910 );
2911
2912 let _ = std::fs::remove_dir_all(&home);
2913 }
2914
2915 fn runs_in(
2918 home: &Path,
2919 model: &str,
2920 n: usize,
2921 stats: impl Fn(usize) -> crate::session::RunStats,
2922 ) {
2923 let dir = home.join("sessions");
2924 std::fs::create_dir_all(&dir).unwrap();
2925 for i in 0..n {
2926 let session = crate::session::Session::create(
2927 &dir,
2928 crate::session::SessionMeta {
2929 id: format!("2026080{}T00000{i:03}-{model}", 1 + i % 9),
2934 created_at: utc(NOW),
2935 provider: "local".into(),
2936 model: model.to_string(),
2937 workspace: std::path::PathBuf::from("/tmp"),
2938 title: None,
2939 },
2940 )
2941 .unwrap();
2942 session
2943 .append(&crate::session::Record::Outcome(stats(i)))
2944 .unwrap();
2945 }
2946 }
2947
2948 fn run_stats(
2949 calls: u32,
2950 errors: u32,
2951 ended_failed: bool,
2952 cause: crate::agent::StopCause,
2953 ) -> crate::session::RunStats {
2954 crate::session::RunStats {
2955 tool_calls: calls,
2956 tool_errors: errors,
2957 ended_on_failed_call: ended_failed,
2958 stop_cause: Some(cause),
2959 ..Default::default()
2960 }
2961 }
2962
2963 #[test]
2964 fn a_model_that_keeps_finishing_over_failures_is_reported() {
2965 use crate::agent::StopCause;
2966 let home = home("runs-ended-on-failure");
2967 runs_in(&home, "tiny-local", 30, |i| {
2969 run_stats(6, 0, i % 3 == 0, StopCause::Completed)
2970 });
2971
2972 let all = examine(&home, utc(NOW));
2973 let findings = of(&all, "runs");
2974 assert_eq!(findings.len(), 1, "{findings:#?}");
2975 assert!(
2976 findings[0].summary.contains("tiny-local"),
2977 "{}",
2978 findings[0].summary
2979 );
2980 assert!(
2981 findings[0].summary.contains("33%"),
2982 "{}",
2983 findings[0].summary
2984 );
2985 assert_eq!(
2986 findings[0].remedy.as_ref().unwrap().argv,
2987 vec!["mecha", "sessions", "health", "--days", "30"],
2988 "reading is the remedy; doctor never decides what to change"
2989 );
2990
2991 let _ = std::fs::remove_dir_all(&home);
2992 }
2993
2994 #[test]
2995 fn a_cancelled_run_is_not_the_harness_cutting_it_short() {
2996 use crate::agent::StopCause;
2997 let home = home("runs-interrupted");
3000 runs_in(&home, "tiny-local", 30, |_| {
3001 run_stats(6, 0, false, StopCause::Interrupted)
3002 });
3003 let findings = examine(&home, utc(NOW));
3004 assert!(of(&findings, "runs").is_empty());
3005 let _ = std::fs::remove_dir_all(&home);
3006 }
3007
3008 #[test]
3009 fn a_turn_ceiling_stopping_a_quarter_of_runs_is_a_finding() {
3010 use crate::agent::StopCause;
3011 let home = home("runs-max-turns");
3012 runs_in(&home, "tiny-local", 30, |_| {
3013 run_stats(6, 0, false, StopCause::MaxTurns)
3014 });
3015 let all = examine(&home, utc(NOW));
3016 let findings = of(&all, "runs");
3017 assert_eq!(findings.len(), 1, "{findings:#?}");
3018 assert!(
3019 findings[0].summary.contains("cut"),
3020 "{}",
3021 findings[0].summary
3022 );
3023 let _ = std::fs::remove_dir_all(&home);
3024 }
3025
3026 #[test]
3027 fn a_thin_sample_of_one_model_says_nothing_about_it() {
3028 use crate::agent::StopCause;
3029 let home = home("runs-thin");
3032 runs_in(&home, "tiny-local", 19, |_| {
3033 run_stats(6, 6, true, StopCause::MaxTurns)
3034 });
3035 let all = examine(&home, utc(NOW));
3036 assert!(of(&all, "runs").is_empty());
3037 let _ = std::fs::remove_dir_all(&home);
3038 }
3039
3040 #[test]
3041 fn a_bad_model_does_not_drag_a_good_one_into_a_finding() {
3042 use crate::agent::StopCause;
3043 let home = home("runs-two-models");
3046 runs_in(&home, "steady", 25, |_| {
3047 run_stats(10, 0, false, StopCause::Completed)
3048 });
3049 runs_in(&home, "flaky", 25, |_| {
3050 run_stats(10, 9, false, StopCause::Completed)
3051 });
3052
3053 let all = examine(&home, utc(NOW));
3054 let findings = of(&all, "runs");
3055 assert_eq!(findings.len(), 1, "{findings:#?}");
3056 assert!(
3057 findings[0].summary.contains("flaky"),
3058 "{}",
3059 findings[0].summary
3060 );
3061 assert!(
3062 !findings[0].summary.contains("steady"),
3063 "the healthy model was named in a finding about the other one"
3064 );
3065 let _ = std::fs::remove_dir_all(&home);
3066 }
3067
3068 #[test]
3069 fn a_ledger_written_before_the_counts_existed_reports_nothing() {
3070 let home = home("trigger-tool-errors-bare");
3073 trigger_file(&home, "morning", "");
3074 ledger_row(
3075 &home,
3076 &json!({
3077 "trigger": "morning",
3078 "slot": "2026-08-14T07:00:00Z",
3079 "started_at": "2026-08-14T07:00:01Z",
3080 "status": "ok",
3081 }),
3082 );
3083 assert!(of(&examine(&home, utc(NOW)), "triggers").is_empty());
3084
3085 let _ = std::fs::remove_dir_all(&home);
3086 }
3087
3088 #[test]
3089 fn a_disabled_trigger_is_nobody_s_emergency() {
3090 let home = home("trigger-disabled");
3091 trigger_file(&home, "morning", "enabled = false\n");
3092 ledger_row(
3093 &home,
3094 &json!({
3095 "trigger": "morning",
3096 "started_at": "2026-08-14T07:00:01Z",
3097 "status": "error",
3098 "error": "boom",
3099 }),
3100 );
3101 let findings = examine(&home, utc(NOW));
3102 assert!(of(&findings, "triggers").is_empty(), "{findings:#?}");
3103 let _ = std::fs::remove_dir_all(&home);
3104 }
3105
3106 #[test]
3107 fn a_catch_up_trigger_whose_slots_stopped_advancing_names_the_daemon() {
3108 let home = home("trigger-stale");
3109 trigger_file(&home, "morning", "");
3110 ledger_row(
3113 &home,
3114 &json!({
3115 "trigger": "morning",
3116 "slot": "2026-08-09T07:00:00Z",
3117 "started_at": "2026-08-09T07:00:01Z",
3118 "status": "ok",
3119 }),
3120 );
3121
3122 let findings = examine(&home, utc(NOW));
3123 let triggers = of(&findings, "triggers");
3124 assert_eq!(triggers.len(), 1, "{findings:#?}");
3125 assert_eq!(triggers[0].severity, Severity::Attention);
3126 assert!(triggers[0].summary.contains("missed more than two slots"));
3127 assert!(
3128 triggers[0].detail.contains("daemon"),
3129 "{}",
3130 triggers[0].detail
3131 );
3132 assert!(
3133 triggers[0].remedy.is_none(),
3134 "running the trigger would not restart the scheduler"
3135 );
3136
3137 ledger_row(
3139 &home,
3140 &json!({
3141 "trigger": "morning",
3142 "slot": "2026-08-14T07:00:00Z",
3143 "started_at": "2026-08-14T07:00:01Z",
3144 "status": "ok",
3145 }),
3146 );
3147 let findings = examine(&home, utc(NOW));
3148 assert!(of(&findings, "triggers").is_empty(), "{findings:#?}");
3149
3150 let _ = std::fs::remove_dir_all(&home);
3151 }
3152
3153 #[cfg(unix)]
3156 #[test]
3157 fn one_poisoned_store_does_not_suppress_the_others() {
3158 use std::os::unix::fs::PermissionsExt;
3159 if unsafe { libc::geteuid() } == 0 {
3162 return;
3163 }
3164
3165 let home = home("poisoned");
3166 write_marker(&home, "personal", &valid_marker());
3167 let outbox = home.join("outbox");
3168 std::fs::create_dir_all(&outbox).unwrap();
3169 std::fs::set_permissions(&outbox, std::fs::Permissions::from_mode(0o000)).unwrap();
3170
3171 let findings = examine(&home, utc(NOW));
3172
3173 std::fs::set_permissions(&outbox, std::fs::Permissions::from_mode(0o700)).unwrap();
3175
3176 let mail = of(&findings, "mail");
3177 assert_eq!(mail.len(), 1, "the mail finding survived: {findings:#?}");
3178 assert_eq!(mail[0].severity, Severity::Broken);
3179 let broken_store = of(&findings, "outbox");
3180 assert_eq!(broken_store.len(), 1, "{findings:#?}");
3181 assert!(
3182 broken_store[0].summary.starts_with("store unreadable:"),
3183 "{}",
3184 broken_store[0].summary
3185 );
3186
3187 let _ = std::fs::remove_dir_all(&home);
3188 }
3189
3190 #[test]
3197 fn the_golden_marker_literal_parses_into_the_dead_auth_finding() {
3198 const GOLDEN: &str = r#"{
3199 "at": "2026-08-11T09:00:00Z",
3200 "message": "account `personal`: refresh token expired or revoked — run `mecha-mail auth personal --provider google` (invalid_grant: Token has been revoked.)"
3201}"#;
3202 let home = home("golden-marker");
3203 write_marker(&home, "personal", GOLDEN);
3204
3205 let findings = examine(&home, utc(NOW));
3206 let mail = of(&findings, "mail");
3207 assert_eq!(mail.len(), 1, "{findings:#?}");
3208 assert_eq!(mail[0].severity, Severity::Broken);
3209 assert!(
3210 mail[0].detail.contains("since 2026-08-11T09:00:00Z"),
3211 "the marker's `at` must reach the detail: {}",
3212 mail[0].detail
3213 );
3214 assert!(
3215 mail[0]
3216 .detail
3217 .contains("run `mecha-mail auth personal --provider google`"),
3218 "the marker's `message` must reach the detail: {}",
3219 mail[0].detail
3220 );
3221
3222 let _ = std::fs::remove_dir_all(&home);
3223 }
3224
3225 #[test]
3226 fn findings_sort_broken_first() {
3227 let mut findings = vec![
3228 Finding {
3229 component: "outbox".into(),
3230 severity: Severity::Attention,
3231 summary: "stale".into(),
3232 detail: String::new(),
3233 remedy: None,
3234 },
3235 Finding {
3236 component: "mail".into(),
3237 severity: Severity::Broken,
3238 summary: "dead".into(),
3239 detail: String::new(),
3240 remedy: None,
3241 },
3242 ];
3243 sort(&mut findings);
3244 assert_eq!(findings[0].severity, Severity::Broken);
3245 }
3246
3247 #[test]
3248 fn an_empty_home_is_healthy() {
3249 let home = home("empty");
3250 assert!(examine(&home, utc(NOW)).is_empty());
3251 let _ = std::fs::remove_dir_all(&home);
3252 }
3253
3254 fn graph_store(name: &str) -> PathBuf {
3259 let store = home(name).join(".mecha-graph");
3260 std::fs::create_dir_all(store.join("logs")).unwrap();
3261 store
3262 }
3263
3264 fn nightly_log(store: &Path, file: &str) {
3265 std::fs::write(store.join("logs").join(file), "ran\n").unwrap();
3266 }
3267
3268 #[test]
3272 fn a_graph_nightly_that_stopped_writing_logs_is_a_finding() {
3273 let store = graph_store("graph-stale");
3274 nightly_log(&store, "nightly-20260812.log");
3275 let findings = check_graph_nightly(&store, utc(NOW));
3276 assert_eq!(findings.len(), 1);
3277 assert_eq!(findings[0].component, "graph");
3278 assert_eq!(findings[0].severity, Severity::Attention);
3279 assert!(
3280 findings[0].summary.contains("2 days"),
3281 "{}",
3282 findings[0].summary
3283 );
3284 assert!(
3285 findings[0].detail.contains("nightly-20260812.log"),
3286 "{}",
3287 findings[0].detail
3288 );
3289 }
3290
3291 #[test]
3292 fn yesterdays_log_is_healthy_because_todays_slot_may_not_have_fired() {
3293 let store = graph_store("graph-yesterday");
3294 nightly_log(&store, "nightly-20260813.log");
3295 nightly_log(&store, "mecha-nightly-20260813.log");
3296 assert!(check_graph_nightly(&store, utc(NOW)).is_empty());
3297 }
3298
3299 #[test]
3303 fn each_nightly_family_is_judged_alone() {
3304 let store = graph_store("graph-split");
3305 nightly_log(&store, "nightly-20260814.log");
3306 nightly_log(&store, "mecha-nightly-20260811.log");
3307 let findings = check_graph_nightly(&store, utc(NOW));
3308 assert_eq!(findings.len(), 1);
3309 assert!(
3310 findings[0].summary.contains("mecha-nightly"),
3311 "{}",
3312 findings[0].summary
3313 );
3314 }
3315
3316 #[test]
3319 fn the_shorter_prefix_does_not_claim_the_longer_familys_logs() {
3320 let store = graph_store("graph-prefix");
3321 nightly_log(&store, "mecha-nightly-20260814.log");
3322 nightly_log(&store, "nightly-20260810.log");
3323 let findings = check_graph_nightly(&store, utc(NOW));
3324 assert_eq!(findings.len(), 1);
3325 assert!(
3326 findings[0].detail.contains("nightly-20260810.log"),
3327 "{}",
3328 findings[0].detail
3329 );
3330 }
3331
3332 #[test]
3335 fn a_graph_that_never_ran_is_not_a_finding() {
3336 let missing = home("graph-missing").join(".mecha-graph");
3337 assert!(check_graph_nightly(&missing, utc(NOW)).is_empty());
3338
3339 let empty = graph_store("graph-empty");
3340 assert!(check_graph_nightly(&empty, utc(NOW)).is_empty());
3341
3342 let odd = graph_store("graph-odd-names");
3343 nightly_log(&odd, "nightly-garbage.log");
3344 nightly_log(&odd, "gossip-20260812.jsonl");
3345 assert!(check_graph_nightly(&odd, utc(NOW)).is_empty());
3346 }
3347
3348 #[test]
3350 fn examine_reads_the_graph_store_beside_the_home() {
3351 let scratch = home("graph-sibling");
3352 let mecha_home = scratch.join(".mecha");
3353 std::fs::create_dir_all(&mecha_home).unwrap();
3354 let store = scratch.join(".mecha-graph");
3355 std::fs::create_dir_all(store.join("logs")).unwrap();
3356 nightly_log(&store, "nightly-20260810.log");
3357 let findings = examine(&mecha_home, utc(NOW));
3358 assert_eq!(findings.len(), 1);
3359 assert_eq!(findings[0].component, "graph");
3360 let _ = std::fs::remove_dir_all(&scratch);
3361 }
3362
3363 #[test]
3364 fn a_malformed_charter_is_broken_and_names_the_remedy() {
3365 let home = home("charter-broken");
3366 std::fs::write(
3367 home.join("charter.toml"),
3368 "[[line]]\nid = \"a\"\ntext = \"one\"\n[[line]]\nid = \"a\"\ntext = \"two\"\n",
3369 )
3370 .unwrap();
3371
3372 let findings = check_charter(&home.join("charter.toml"));
3373 assert_eq!(findings.len(), 1, "{findings:#?}");
3374 assert_eq!(findings[0].severity, Severity::Broken);
3375 assert!(
3376 findings[0].detail.contains("used more than once"),
3377 "{}",
3378 findings[0].detail
3379 );
3380 assert_eq!(
3381 findings[0].remedy.as_ref().unwrap().argv,
3382 vec!["mecha", "charter"]
3383 );
3384
3385 let _ = std::fs::remove_dir_all(&home);
3386 }
3387
3388 #[test]
3389 fn a_charter_over_budget_is_attention_not_broken_and_still_named_loaded() {
3390 let home = home("charter-over-budget");
3391 let long = "x".repeat(3000);
3392 std::fs::write(
3393 home.join("charter.toml"),
3394 format!("[[line]]\nid = \"only\"\ntext = \"{long}\"\n"),
3395 )
3396 .unwrap();
3397
3398 let findings = check_charter(&home.join("charter.toml"));
3399 assert_eq!(findings.len(), 1, "{findings:#?}");
3400 assert_eq!(findings[0].severity, Severity::Attention);
3403 assert!(
3404 findings[0].summary.contains("budget"),
3405 "{}",
3406 findings[0].summary
3407 );
3408
3409 let _ = std::fs::remove_dir_all(&home);
3410 }
3411
3412 #[test]
3413 fn a_healthy_charter_and_a_missing_one_are_both_silent() {
3414 let home = home("charter-healthy");
3415 assert!(
3416 check_charter(&home.join("charter.toml")).is_empty(),
3417 "no file at all"
3418 );
3419
3420 std::fs::write(
3421 home.join("charter.toml"),
3422 "[[line]]\nid = \"a\"\ntext = \"protect the owner\"\n",
3423 )
3424 .unwrap();
3425 assert!(check_charter(&home.join("charter.toml")).is_empty());
3426
3427 let _ = std::fs::remove_dir_all(&home);
3428 }
3429
3430 #[test]
3431 fn a_genuinely_empty_charter_file_is_flagged_not_silent() {
3432 let home = home("charter-empty-comment");
3437 std::fs::write(home.join("charter.toml"), "# no priorities written yet\n").unwrap();
3438
3439 let findings = check_charter(&home.join("charter.toml"));
3440 assert_eq!(findings.len(), 1, "{findings:#?}");
3441 assert_eq!(findings[0].severity, Severity::Attention);
3442 assert!(
3443 findings[0].summary.contains("no lines"),
3444 "{}",
3445 findings[0].summary
3446 );
3447
3448 let _ = std::fs::remove_dir_all(&home);
3449 }
3450
3451 #[test]
3452 fn a_directory_at_the_charter_path_is_broken_not_silently_absent() {
3453 let home = home("charter-is-a-directory");
3458 std::fs::create_dir_all(home.join("charter.toml")).unwrap();
3459
3460 let findings = check_charter(&home.join("charter.toml"));
3461 assert_eq!(findings.len(), 1, "{findings:#?}");
3462 assert_eq!(findings[0].severity, Severity::Broken);
3463
3464 let _ = std::fs::remove_dir_all(&home);
3465 }
3466
3467 #[test]
3468 fn a_typo_d_table_name_beside_a_real_line_is_broken_not_silently_short() {
3469 let home = home("charter-typo-table");
3470 std::fs::write(
3471 home.join("charter.toml"),
3472 "[[line]]\nid = \"a\"\ntext = \"one\"\n\n[[lines]]\nid = \"b\"\ntext = \"two\"\n",
3473 )
3474 .unwrap();
3475
3476 let findings = check_charter(&home.join("charter.toml"));
3477 assert_eq!(findings.len(), 1, "{findings:#?}");
3478 assert_eq!(findings[0].severity, Severity::Broken);
3479
3480 let _ = std::fs::remove_dir_all(&home);
3481 }
3482}