Skip to main content

atm_core/doctor/
mod.rs

1pub mod health;
2pub mod report;
3
4use std::collections::BTreeSet;
5use std::fs::{self, OpenOptions};
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use crate::boundary::{ConfigDoctor, MailStoreDoctor, RosterEntry, RosterStoreDoctor};
10use crate::config;
11use crate::error_codes::AtmErrorCode;
12use crate::observability::ObservabilityPort;
13use crate::roles::ROLE_TEAM_LEAD;
14use crate::schema::{AgentMember, HomeDirPath, canonical_home_dir};
15use crate::service_runtime::{LocalServiceRuntime, RetainedServiceRuntime};
16use crate::service_runtime_store::default_runtime;
17use crate::team_admin::{MembersList, ordered_roster_member_summaries};
18use crate::types::{AgentName, TeamName};
19use std::sync::Arc;
20
21pub use report::{
22    BootstrapAutoStartOutcome, BootstrapConnectOutcome, BootstrapLaunchGateOutcome,
23    BootstrapTraceReport, DaemonRuntimeDoctorReport, DoctorEnvironmentVisibility,
24    DoctorExecutionContext, DoctorFinding, DoctorReport, DoctorSeverity, DoctorStatus,
25    DoctorSummary, PostSendDoctorReport, PostSendHookRuleIndex, PostSendHookRuleReport,
26    RecipientDeliveryPath, RecipientDeliveryPathReport,
27};
28
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct DoctorQuery {
31    pub home_dir: PathBuf,
32    pub current_dir: PathBuf,
33    pub team_override: Option<TeamName>,
34}
35
36#[derive(Clone)]
37pub struct RuntimeDoctorPorts {
38    pub config_doctor: Arc<dyn ConfigDoctor + Send + Sync>,
39    pub mail_store_doctor: Arc<dyn MailStoreDoctor + Send + Sync>,
40    pub roster_store_doctor: Arc<dyn RosterStoreDoctor + Send + Sync>,
41}
42
43impl std::fmt::Debug for RuntimeDoctorPorts {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("RuntimeDoctorPorts")
46            .field("config_doctor", &"dyn ConfigDoctor")
47            .field("mail_store_doctor", &"dyn MailStoreDoctor")
48            .field("roster_store_doctor", &"dyn RosterStoreDoctor")
49            .finish()
50    }
51}
52
53/// Run the ATM doctor checks for config, roster, and observability health.
54///
55/// # Errors
56///
57/// Returns [`crate::error::AtmError`] when loading `.atm.toml` fails before the
58/// doctor report can be assembled.
59pub fn run_doctor(
60    query: DoctorQuery,
61    observability: &dyn ObservabilityPort,
62) -> Result<DoctorReport, crate::error::AtmError> {
63    let runtime = default_runtime()?;
64    run_doctor_with_runtime(query, observability, &runtime)
65}
66
67pub fn run_doctor_with_runtime(
68    query: DoctorQuery,
69    observability: &dyn ObservabilityPort,
70    runtime: &LocalServiceRuntime,
71) -> Result<DoctorReport, crate::error::AtmError> {
72    let config = runtime.load_config(&query.current_dir)?;
73    let home_dir = query.home_dir.clone();
74    let initial_lock_snapshot = snapshot_mailbox_lock_paths(&home_dir);
75    let resolved_team = resolved_doctor_team(&query, config.as_ref());
76    let environment = health::environment_visibility(query.home_dir, query.team_override);
77    let (observability_health, finding) = doctor_observability_status(observability);
78    let mut findings = Vec::new();
79    if config
80        .as_ref()
81        .is_some_and(|config| config.obsolete_identity.is_some())
82    {
83        findings.push(DoctorFinding {
84            severity: DoctorSeverity::Warning,
85            code: AtmErrorCode::WarningIdentityDrift,
86            message: "obsolete config identity is still present in .atm.toml (`[atm].identity` or legacy top-level `identity`); ATM no longer uses config identity as a runtime fallback.".to_string(),
87            remediation: Some(
88                "Remove `[atm].identity` or the legacy top-level `identity` key from `.atm.toml` and set `ATM_IDENTITY` in the active agent environment instead."
89                    .to_string(),
90            ),
91        });
92    }
93    let member_roster = resolved_team.as_ref().and_then(|team| {
94        load_member_roster(
95            runtime,
96            &home_dir,
97            team,
98            config.as_ref(),
99            environment.atm_identity.as_ref(),
100            Some(query.current_dir.as_path()),
101            &mut findings,
102        )
103    });
104    push_stale_mailbox_lock_findings(
105        &initial_lock_snapshot,
106        &snapshot_mailbox_lock_paths(&home_dir),
107        &mut findings,
108    );
109    findings.push(finding);
110    let summary = summarize_doctor_findings(&findings);
111    let recommendations = findings
112        .iter()
113        .filter_map(|finding| finding.remediation.clone())
114        .collect::<Vec<_>>();
115
116    Ok(DoctorReport {
117        summary,
118        findings,
119        recommendations,
120        environment: environment.clone(),
121        client_context: report::DoctorExecutionContext {
122            team: environment.atm_team.clone(),
123            identity: environment.atm_identity.clone(),
124            version: Some(crate::protocol::ReleaseVersion::current()),
125        },
126        daemon_context: None,
127        member_roster,
128        observability: observability_health,
129        post_send: PostSendDoctorReport::default(),
130        config: crate::boundary::ConfigDoctorReport::default(),
131        mail_store: crate::boundary::MailStoreDoctorReport::default(),
132        roster_store: crate::boundary::RosterStoreDoctorReport::default(),
133        daemon_runtime: None,
134        drift_findings: Vec::new(),
135        runtime_status: None,
136        bootstrap_trace: None,
137    })
138}
139
140pub fn run_doctor_with_runtime_ports(
141    query: DoctorQuery,
142    observability: &dyn ObservabilityPort,
143    runtime: &LocalServiceRuntime,
144    runtime_doctors: &RuntimeDoctorPorts,
145    daemon_runtime: Option<report::DaemonRuntimeDoctorReport>,
146) -> Result<DoctorReport, crate::error::AtmError> {
147    let config = runtime.load_config(&query.current_dir)?;
148    let home_dir = query.home_dir.clone();
149    let initial_lock_snapshot = snapshot_mailbox_lock_paths(&home_dir);
150    let resolved_team = resolved_doctor_team(&query, config.as_ref());
151    let environment = health::environment_visibility(query.home_dir, query.team_override);
152    let (observability_health, observability_finding) = doctor_observability_status(observability);
153    let mut general_findings = Vec::new();
154    let mut drift_findings = Vec::new();
155    let mut reports = inspect_runtime_doctor_sections(runtime_doctors, &mut general_findings);
156    push_obsolete_identity_finding(config.as_ref(), &mut reports.config);
157    let member_roster = resolved_team.as_ref().and_then(|team| {
158        load_member_roster(
159            runtime,
160            &home_dir,
161            team,
162            config.as_ref(),
163            environment.atm_identity.as_ref(),
164            Some(query.current_dir.as_path()),
165            &mut drift_findings,
166        )
167    });
168    push_stale_mailbox_lock_findings(
169        &initial_lock_snapshot,
170        &snapshot_mailbox_lock_paths(&home_dir),
171        &mut drift_findings,
172    );
173    let findings = collect_doctor_findings(
174        &reports,
175        &drift_findings,
176        &general_findings,
177        observability_finding,
178        daemon_runtime.as_ref(),
179    );
180    let summary = summarize_doctor_findings(&findings);
181    let recommendations = collect_recommendations(&findings);
182    let post_send = post_send_doctor_report(
183        config.as_ref(),
184        member_roster.as_ref(),
185        runtime,
186        resolved_team.as_ref(),
187    );
188
189    Ok(DoctorReport {
190        summary,
191        findings,
192        recommendations,
193        environment: environment.clone(),
194        client_context: report::DoctorExecutionContext {
195            team: environment.atm_team.clone(),
196            identity: environment.atm_identity.clone(),
197            version: Some(crate::protocol::ReleaseVersion::current()),
198        },
199        daemon_context: None,
200        member_roster,
201        observability: observability_health,
202        post_send,
203        config: reports.config,
204        mail_store: reports.mail_store,
205        roster_store: reports.roster_store,
206        daemon_runtime,
207        drift_findings,
208        runtime_status: None,
209        bootstrap_trace: None,
210    })
211}
212
213fn post_send_doctor_report(
214    config: Option<&crate::config::AtmConfig>,
215    member_roster: Option<&MembersList>,
216    runtime: &LocalServiceRuntime,
217    team: Option<&TeamName>,
218) -> PostSendDoctorReport {
219    let Some(config) = config else {
220        return PostSendDoctorReport::default();
221    };
222    let external_rules = config
223        .post_send_hooks
224        .iter()
225        .filter_map(|rule| {
226            let (program, argv) = rule.command.split_first()?;
227            Some(PostSendHookRuleReport {
228                recipient_matcher: rule.recipient.to_string(),
229                executable: std::path::PathBuf::from(program),
230                argv: argv.to_vec(),
231                config_root: config.config_root.clone(),
232            })
233        })
234        .collect::<Vec<_>>();
235    // A disabled built-in delivery template is a team-level delivery policy.
236    // Report it before external matching so doctor describes the path that will
237    // actually be emitted, without exposing rendered message content.
238    let built_in_delivery_disabled = team
239        .and_then(|team| {
240            runtime
241                .load_nudge_template_override(
242                    team,
243                    crate::boundary::BuiltInNudgeTemplateKind::Delivery,
244                )
245                .ok()
246                .flatten()
247        })
248        .is_some_and(|row| {
249            matches!(
250                row.mode,
251                crate::boundary::TeamNudgeTemplateOverrideMode::Disabled
252            )
253        });
254    let recipient_paths = member_roster
255        .into_iter()
256        .flat_map(|roster| roster.members.iter())
257        .map(|member| {
258            let path = if built_in_delivery_disabled {
259                RecipientDeliveryPath::Disabled
260            } else {
261                config
262                    .post_send_hooks
263                    .iter()
264                    .position(|rule| rule.recipient.matches(&member.name))
265                    .and_then(|index| u32::try_from(index).ok())
266                    .map(|rule| RecipientDeliveryPath::ExternalOverride {
267                        rule: PostSendHookRuleIndex(rule),
268                    })
269                    .unwrap_or(RecipientDeliveryPath::BuiltIn)
270            };
271            RecipientDeliveryPathReport {
272                recipient: member.name.clone(),
273                path,
274            }
275        })
276        .collect();
277    PostSendDoctorReport {
278        config_root: config.config_root.clone(),
279        external_rules,
280        recipient_paths,
281    }
282}
283
284struct DoctorSectionReports {
285    config: crate::boundary::ConfigDoctorReport,
286    mail_store: crate::boundary::MailStoreDoctorReport,
287    roster_store: crate::boundary::RosterStoreDoctorReport,
288}
289
290fn inspect_runtime_doctor_sections(
291    runtime_doctors: &RuntimeDoctorPorts,
292    findings: &mut Vec<DoctorFinding>,
293) -> DoctorSectionReports {
294    DoctorSectionReports {
295        config: inspect_doctor_section(runtime_doctors.config_doctor.inspect_config(), findings),
296        mail_store: inspect_doctor_section(
297            runtime_doctors.mail_store_doctor.inspect_mail_store(),
298            findings,
299        ),
300        roster_store: inspect_doctor_section(
301            runtime_doctors.roster_store_doctor.inspect_roster_store(),
302            findings,
303        ),
304    }
305}
306
307fn push_obsolete_identity_finding(
308    config: Option<&config::AtmConfig>,
309    config_report: &mut crate::boundary::ConfigDoctorReport,
310) {
311    if config.is_some_and(|config| config.obsolete_identity.is_some()) {
312        config_report.findings.push(DoctorFinding {
313            severity: DoctorSeverity::Warning,
314            code: AtmErrorCode::WarningIdentityDrift,
315            message: "obsolete config identity is still present in .atm.toml (`[atm].identity` or legacy top-level `identity`); ATM no longer uses config identity as a runtime fallback.".to_string(),
316            remediation: Some(
317                "Remove `[atm].identity` or the legacy top-level `identity` key from `.atm.toml` and set `ATM_IDENTITY` in the active agent environment instead."
318                    .to_string(),
319            ),
320        });
321    }
322}
323
324fn collect_doctor_findings(
325    reports: &DoctorSectionReports,
326    drift_findings: &[DoctorFinding],
327    general_findings: &[DoctorFinding],
328    observability_finding: DoctorFinding,
329    daemon_runtime: Option<&report::DaemonRuntimeDoctorReport>,
330) -> Vec<DoctorFinding> {
331    let mut findings = Vec::new();
332    findings.extend(reports.config.findings.iter().cloned());
333    findings.extend(reports.mail_store.findings.iter().cloned());
334    findings.extend(reports.roster_store.findings.iter().cloned());
335    findings.extend(drift_findings.iter().cloned());
336    findings.extend(general_findings.iter().cloned());
337    findings.push(observability_finding);
338    if let Some(runtime_report) = daemon_runtime {
339        findings.extend(runtime_report.findings.iter().cloned());
340    }
341    findings
342}
343
344fn collect_recommendations(findings: &[DoctorFinding]) -> Vec<String> {
345    findings
346        .iter()
347        .filter_map(|finding| finding.remediation.clone())
348        .collect()
349}
350
351fn inspect_doctor_section<T>(
352    result: Result<T, crate::error::AtmError>,
353    findings: &mut Vec<DoctorFinding>,
354) -> T
355where
356    T: Default,
357{
358    match result {
359        Ok(report) => report,
360        Err(error) => {
361            push_doctor_error(findings, DoctorSeverity::Error, error);
362            T::default()
363        }
364    }
365}
366
367fn resolved_doctor_team(
368    query: &DoctorQuery,
369    config: Option<&config::AtmConfig>,
370) -> Option<TeamName> {
371    query
372        .team_override
373        .clone()
374        .or_else(|| config::resolve_team(None, config))
375}
376
377fn doctor_observability_status(
378    observability: &dyn ObservabilityPort,
379) -> (crate::observability::AtmObservabilityHealth, DoctorFinding) {
380    match observability.health() {
381        Ok(health) => {
382            let finding = health::observability_finding(&health);
383            (health, finding)
384        }
385        Err(error) => {
386            let snapshot = health::unavailable_snapshot(error.to_string());
387            let finding = health::observability_finding_from_error(&error);
388            (snapshot, finding)
389        }
390    }
391}
392
393fn summarize_doctor_findings(findings: &[DoctorFinding]) -> DoctorSummary {
394    let status = health::status_from_findings(findings);
395    let (info_count, warning_count, error_count) = findings.iter().fold(
396        (0usize, 0usize, 0usize),
397        |(info, warning, error), finding| match finding.severity {
398            DoctorSeverity::Info => (info + 1, warning, error),
399            DoctorSeverity::Warning => (info, warning + 1, error),
400            DoctorSeverity::Error => (info, warning, error + 1),
401        },
402    );
403    let message = match status {
404        DoctorStatus::Healthy => "ATM doctor completed with healthy findings only",
405        DoctorStatus::Warning => "ATM doctor completed with warnings",
406        DoctorStatus::Error => "ATM doctor found critical issues",
407    };
408    DoctorSummary {
409        status,
410        message: message.to_string(),
411        info_count,
412        warning_count,
413        error_count,
414    }
415}
416
417fn load_member_roster(
418    runtime: &impl RetainedServiceRuntime,
419    home_dir: &Path,
420    team: &TeamName,
421    config: Option<&config::AtmConfig>,
422    caller_identity: Option<&AgentName>,
423    live_cwd: Option<&Path>,
424    findings: &mut Vec<DoctorFinding>,
425) -> Option<MembersList> {
426    let team_dir = resolve_doctor_team_dir(runtime, home_dir, team, findings)?;
427    check_restore_marker(team, &team_dir, findings);
428    let (team_config, atm_roster) =
429        load_doctor_roster_compare_inputs(runtime, team, &team_dir, findings)?;
430    let baseline = config
431        .map(|config| config.team_members.as_slice())
432        .unwrap_or(&[]);
433    check_inbox_directory(team, &team_dir.join("inboxes"), findings);
434    record_doctor_roster_drift(team, &team_config, atm_roster.as_deref(), findings);
435
436    let members = match atm_roster {
437        Some(ref roster) => ordered_roster_member_summaries(roster, caller_identity, live_cwd),
438        None => ordered_member_summaries(&team_config.members, baseline, caller_identity, live_cwd),
439    };
440
441    Some(MembersList {
442        team: team.clone(),
443        members,
444    })
445}
446
447fn resolve_doctor_team_dir(
448    runtime: &impl RetainedServiceRuntime,
449    home_dir: &Path,
450    team: &TeamName,
451    findings: &mut Vec<DoctorFinding>,
452) -> Option<PathBuf> {
453    let team_dir = match runtime.team_dir(home_dir, team) {
454        Ok(team_dir) => team_dir,
455        Err(error) => {
456            push_doctor_error(findings, DoctorSeverity::Error, error);
457            return None;
458        }
459    };
460    if !team_dir.is_dir() {
461        findings.push(DoctorFinding {
462            severity: DoctorSeverity::Error,
463            code: AtmErrorCode::TeamNotFound,
464            message: format!(
465                "team directory is missing at {} for '{}'",
466                team_dir.display(),
467                team
468            ),
469            remediation: Some(format!(
470                "Create .claude/teams/{team} or correct ATM_HOME / --team before rerunning `atm doctor`."
471            )),
472        });
473        return None;
474    }
475    Some(team_dir)
476}
477
478fn load_doctor_roster_compare_inputs(
479    runtime: &impl RetainedServiceRuntime,
480    team: &TeamName,
481    team_dir: &Path,
482    findings: &mut Vec<DoctorFinding>,
483) -> Option<(crate::schema::TeamConfig, Option<Vec<RosterEntry>>)> {
484    let team_config = match runtime.load_team_config_for_doctor_compare(team_dir) {
485        Ok(team_config) => team_config,
486        Err(error) => {
487            push_doctor_error(findings, DoctorSeverity::Error, error);
488            return None;
489        }
490    };
491    let atm_roster = match runtime.load_team_roster(team) {
492        Ok(roster) => Some(roster),
493        Err(error) => {
494            push_doctor_error(findings, DoctorSeverity::Error, error);
495            None
496        }
497    };
498    Some((team_config, atm_roster))
499}
500
501fn record_doctor_roster_drift(
502    team: &TeamName,
503    team_config: &crate::schema::TeamConfig,
504    atm_roster: Option<&[RosterEntry]>,
505    findings: &mut Vec<DoctorFinding>,
506) {
507    let present = team_config
508        .members
509        .iter()
510        .map(|member| member.name.clone())
511        .collect::<BTreeSet<_>>();
512    let Some(atm_roster) = atm_roster else {
513        return;
514    };
515    let atm_members = atm_roster
516        .iter()
517        .map(|member| member.agent_name.clone())
518        .collect::<BTreeSet<_>>();
519
520    record_roster_membership_drift(team, &present, &atm_members, findings);
521    record_roster_member_metadata_drift(team, &team_config.members, atm_roster, findings);
522}
523
524fn record_roster_membership_drift(
525    team: &TeamName,
526    config_members: &BTreeSet<AgentName>,
527    roster_members: &BTreeSet<AgentName>,
528    findings: &mut Vec<DoctorFinding>,
529) {
530    for member in roster_members.difference(config_members) {
531        push_missing_config_member_finding(team, member.as_str(), findings);
532    }
533
534    for member in config_members.difference(roster_members) {
535        push_missing_roster_member_finding(team, member.as_str(), findings);
536    }
537}
538
539fn record_roster_member_metadata_drift(
540    team: &TeamName,
541    config_members: &[crate::schema::AgentMember],
542    atm_roster: &[RosterEntry],
543    findings: &mut Vec<DoctorFinding>,
544) {
545    for config_member in config_members {
546        let Some(roster_member) = atm_roster
547            .iter()
548            .find(|member| member.agent_name == config_member.name)
549        else {
550            continue;
551        };
552
553        record_member_metadata_drift(team, config_member, roster_member, findings);
554    }
555}
556
557fn push_missing_config_member_finding(
558    team: &TeamName,
559    member: &str,
560    findings: &mut Vec<DoctorFinding>,
561) {
562    findings.push(DoctorFinding {
563        severity: DoctorSeverity::Warning,
564        code: AtmErrorCode::WarningRosterDrift,
565        message: format!("ATM roster member '{member}' is missing from team config.json for '{team}'"),
566        remediation: Some(format!(
567            "Project the ATM roster back into .claude/teams/{team}/config.json or restore the missing Claude team member before rerunning `atm doctor`."
568        )),
569    });
570}
571
572fn push_missing_roster_member_finding(
573    team: &TeamName,
574    member: &str,
575    findings: &mut Vec<DoctorFinding>,
576) {
577    findings.push(DoctorFinding {
578        severity: DoctorSeverity::Warning,
579        code: AtmErrorCode::WarningRosterDrift,
580        message: format!("Claude team member '{member}' is missing from ATM roster truth for '{team}'"),
581        remediation: Some(format!(
582            "Import the Claude team member into the ATM roster or remove it from .claude/teams/{team}/config.json before rerunning `atm doctor`."
583        )),
584    });
585}
586
587fn record_member_metadata_drift(
588    team: &TeamName,
589    config_member: &crate::schema::AgentMember,
590    roster_member: &RosterEntry,
591    findings: &mut Vec<DoctorFinding>,
592) {
593    if roster_member.recipient_pane_id != config_member.tmux_pane_id {
594        findings.push(DoctorFinding {
595            severity: DoctorSeverity::Warning,
596            code: AtmErrorCode::WarningRosterDrift,
597            message: format!(
598                "member '{}' has pane drift for '{}': ATM roster has {:?}, config.json has {:?}",
599                config_member.name, team, roster_member.recipient_pane_id, config_member.tmux_pane_id
600            ),
601            remediation: Some(format!(
602                "Repair the authoritative pane id with `atm teams update-member {team} {} --pane-id <pane>` and rerun `atm doctor`.",
603                config_member.name
604            )),
605        });
606    }
607
608    let roster_home_dir = roster_member_home_dir(roster_member);
609    let config_home_dir = config_member_home_dir(config_member);
610    if roster_home_dir != config_home_dir {
611        findings.push(DoctorFinding {
612            severity: DoctorSeverity::Warning,
613            code: AtmErrorCode::WarningRosterDrift,
614            message: format!(
615                "member '{}' has home-dir drift for '{}': ATM roster has {:?}, config.json has {:?}",
616                config_member.name, team, roster_home_dir, config_home_dir
617            ),
618            remediation: Some(format!(
619                "Repair the authoritative member home with `atm teams update-member {team} {} --home-dir <path>` and rerun `atm doctor`.",
620                config_member.name
621            )),
622        });
623    }
624}
625
626fn push_doctor_error(
627    findings: &mut Vec<DoctorFinding>,
628    severity: DoctorSeverity,
629    error: crate::error::AtmError,
630) {
631    let remediation = error.primary_recovery().map(str::to_owned);
632    findings.push(DoctorFinding {
633        severity,
634        code: error.code,
635        message: error.message,
636        remediation,
637    });
638}
639
640fn check_inbox_directory(team: &TeamName, inboxes_dir: &Path, findings: &mut Vec<DoctorFinding>) {
641    if !inboxes_dir.is_dir() {
642        findings.push(DoctorFinding {
643            severity: DoctorSeverity::Error,
644            code: AtmErrorCode::MailboxWriteFailed,
645            message: format!(
646                "inbox directory is missing at {} for '{}'",
647                inboxes_dir.display(),
648                team
649            ),
650            remediation: Some(format!(
651                "Create .claude/teams/{team}/inboxes and ensure ATM can write inbox files before rerunning `atm doctor`."
652            )),
653        });
654        return;
655    }
656
657    if let Err(error) = probe_directory_writable(inboxes_dir) {
658        findings.push(DoctorFinding {
659            severity: DoctorSeverity::Error,
660            code: AtmErrorCode::MailboxWriteFailed,
661            message: format!(
662                "inbox directory is not writable at {}: {error}",
663                inboxes_dir.display()
664            ),
665            remediation: Some(
666                "Check inbox directory permissions and ensure ATM can create and remove inbox files before rerunning `atm doctor`."
667                    .to_string(),
668            ),
669        });
670    }
671}
672
673fn check_restore_marker(team: &TeamName, team_dir: &Path, findings: &mut Vec<DoctorFinding>) {
674    let marker = team_dir.join(".restore-in-progress");
675    if !marker.is_file() {
676        return;
677    }
678
679    findings.push(DoctorFinding {
680        severity: DoctorSeverity::Warning,
681        code: AtmErrorCode::WarningRestoreInProgress,
682        message: format!(
683            "stale restore marker is present at {} for '{}'; a prior `atm teams restore` may have been interrupted",
684            marker.display(),
685            team
686        ),
687        remediation: Some(format!(
688            "Inspect {} for partial restore state, rerun `atm teams restore {team}`, then remove the marker once recovery is complete.",
689            team_dir.display()
690        )),
691    });
692}
693
694fn snapshot_mailbox_lock_paths(home_dir: &Path) -> BTreeSet<PathBuf> {
695    let teams_root = home_dir.join(".claude").join("teams");
696    let Ok(team_entries) = fs::read_dir(&teams_root) else {
697        return BTreeSet::new();
698    };
699
700    let mut locks = BTreeSet::new();
701    for team_entry in team_entries.filter_map(Result::ok) {
702        let inboxes_dir = team_entry.path().join("inboxes");
703        let Ok(lock_entries) = fs::read_dir(inboxes_dir) else {
704            continue;
705        };
706        for lock_entry in lock_entries.filter_map(Result::ok) {
707            let path = lock_entry.path();
708            let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
709                continue;
710            };
711            if !(file_name.ends_with(".lock") || file_name.contains(".lock.")) {
712                continue;
713            }
714            if !lock_entry
715                .file_type()
716                .is_ok_and(|file_type| file_type.is_file())
717            {
718                continue;
719            }
720            locks.insert(path);
721        }
722    }
723
724    locks
725}
726
727fn push_stale_mailbox_lock_findings(
728    initial: &BTreeSet<PathBuf>,
729    final_snapshot: &BTreeSet<PathBuf>,
730    findings: &mut Vec<DoctorFinding>,
731) {
732    for path in initial.intersection(final_snapshot) {
733        findings.push(DoctorFinding {
734            severity: DoctorSeverity::Warning,
735            code: AtmErrorCode::WarningStaleMailboxLock,
736            message: format!(
737                "mailbox lock sentinel persisted for the full doctor run at {}; the lock is likely stale",
738                path.display()
739            ),
740            remediation: Some(format!(
741                "Confirm no live ATM process still owns the mailbox, then remove the stale sentinel with `rm -f {}`.",
742                path.display()
743            )),
744        });
745    }
746}
747
748fn probe_directory_writable(directory: &Path) -> Result<(), std::io::Error> {
749    let nonce = SystemTime::now()
750        .duration_since(UNIX_EPOCH)
751        .unwrap_or_default()
752        .as_nanos();
753    let probe_path = directory.join(format!(
754        ".atm-doctor-write-probe-{}-{nonce}",
755        std::process::id()
756    ));
757    let file = OpenOptions::new()
758        .create_new(true)
759        .write(true)
760        .open(&probe_path)?;
761    drop(file);
762    fs::remove_file(&probe_path)?;
763    Ok(())
764}
765
766fn ordered_member_summaries(
767    members: &[AgentMember],
768    baseline: &[TeamName],
769    caller_identity: Option<&AgentName>,
770    live_cwd: Option<&Path>,
771) -> Vec<crate::team_admin::MemberSummary> {
772    let mut ordered = Vec::new();
773    let mut included = BTreeSet::new();
774
775    if baseline
776        .iter()
777        .any(|member| member.as_str() == ROLE_TEAM_LEAD)
778        && let Some(team_lead) = members.iter().find(|member| member.name == ROLE_TEAM_LEAD)
779    {
780        ordered.push(member_summary(team_lead, caller_identity, live_cwd));
781        included.insert(team_lead.name.clone());
782    }
783
784    for baseline_member in baseline {
785        if baseline_member.as_str() == ROLE_TEAM_LEAD {
786            continue;
787        }
788        if let Some(member) = members
789            .iter()
790            .find(|member| member.name == baseline_member.as_str())
791        {
792            ordered.push(member_summary(member, caller_identity, live_cwd));
793            included.insert(member.name.clone());
794        }
795    }
796
797    for member in members {
798        if included.insert(member.name.clone()) {
799            ordered.push(member_summary(member, caller_identity, live_cwd));
800        }
801    }
802
803    ordered
804}
805
806fn member_summary(
807    member: &AgentMember,
808    caller_identity: Option<&AgentName>,
809    live_cwd: Option<&Path>,
810) -> crate::team_admin::MemberSummary {
811    crate::team_admin::MemberSummary {
812        name: AgentName::from_validated(member.name.clone()),
813        agent_id: member.agent_id.to_string(),
814        agent_type: member.agent_type.to_string(),
815        model: member.model.clone(),
816        joined_at: member.joined_at,
817        tmux_pane_id: member.tmux_pane_id.clone(),
818        home_dir: member.home_dir.clone(),
819        live_cwd: match (caller_identity, live_cwd) {
820            (Some(identity), Some(path)) if member.name == identity.as_str() => {
821                Some(path.display().to_string())
822            }
823            _ => None,
824        },
825        extra: member.extra.clone(),
826    }
827}
828
829fn roster_member_home_dir(member: &RosterEntry) -> Option<HomeDirPath> {
830    canonical_home_dir(&member.metadata_json)
831}
832
833fn config_member_home_dir(member: &AgentMember) -> Option<HomeDirPath> {
834    (!member.home_dir.is_empty()).then(|| member.home_dir.clone())
835}
836
837#[cfg(test)]
838mod tests {
839    use std::path::{Path, PathBuf};
840    use std::sync::Arc;
841
842    use super::ordered_member_summaries;
843    use crate::config::AtmConfig;
844    use crate::config::types::{HookRecipient, PostSendHookRule};
845    use crate::doctor::{
846        DoctorQuery, DoctorReport, DoctorSeverity, DoctorStatus, run_doctor_with_runtime,
847    };
848    use crate::error::AtmError;
849    use crate::error_codes::AtmErrorCode;
850    use crate::observability::{
851        AtmLogQuery, AtmLogSnapshot, AtmObservabilityHealth, AtmObservabilityHealthState,
852        LogTailSession, ObservabilityPort,
853    };
854    use crate::roles::ROLE_TEAM_LEAD;
855    use crate::schema::{AgentMember, HOME_DIR_METADATA_KEY, TeamConfig};
856    use crate::service_runtime::LocalServiceRuntime;
857    use crate::team_admin::MembersList;
858    use crate::test_support::{TEST_SENDER, TEST_TEAM};
859    use crate::types::{AgentName, TeamName};
860
861    enum StubHealth {
862        Ok(AtmObservabilityHealth),
863        Err(AtmError),
864    }
865
866    struct StubObservability {
867        health: StubHealth,
868    }
869
870    impl crate::boundary::sealed::Sealed for StubObservability {}
871
872    impl ObservabilityPort for StubObservability {
873        fn emit(&self, _event: crate::observability::CommandEvent) -> Result<(), AtmError> {
874            Ok(())
875        }
876
877        fn query(&self, _req: AtmLogQuery) -> Result<AtmLogSnapshot, AtmError> {
878            Ok(AtmLogSnapshot::default())
879        }
880
881        fn follow(&self, _req: AtmLogQuery) -> Result<LogTailSession, AtmError> {
882            Ok(LogTailSession::empty())
883        }
884
885        fn health(&self) -> Result<AtmObservabilityHealth, AtmError> {
886            match &self.health {
887                StubHealth::Ok(health) => Ok(health.clone()),
888                StubHealth::Err(error) => Err(AtmError::new_with_code(
889                    error.code,
890                    error.kind,
891                    error.message.clone(),
892                )),
893            }
894        }
895    }
896
897    struct UnusedMailStore;
898    struct TestRosterStore {
899        members: Vec<atm_storage::RosterMember>,
900    }
901    struct NoopNudgeTemplateOverrideStore;
902
903    #[allow(
904        deprecated,
905        reason = "doctor tests intentionally exercise the transitional shared storage traits"
906    )]
907    impl atm_storage::MessageStore for UnusedMailStore {
908        fn save_message(&self, _message: &atm_storage::Message) -> Result<(), AtmError> {
909            unreachable!("doctor tests do not touch the mail store boundary")
910        }
911
912        fn load_message(
913            &self,
914            _message_key: &atm_storage::MessageKey,
915        ) -> Result<Option<atm_storage::Message>, AtmError> {
916            unreachable!("doctor tests do not touch the mail store boundary")
917        }
918
919        fn list_messages(
920            &self,
921            _query: &atm_storage::MessageQuery,
922        ) -> Result<Vec<atm_storage::Message>, AtmError> {
923            unreachable!("doctor tests do not touch the mail store boundary")
924        }
925
926        fn delete_message(&self, _key: &atm_storage::MessageKey) -> Result<(), AtmError> {
927            unreachable!("doctor tests do not touch the mail store boundary")
928        }
929    }
930
931    #[allow(
932        deprecated,
933        reason = "doctor tests intentionally exercise the transitional shared storage traits"
934    )]
935    impl atm_storage::RosterStore for TestRosterStore {
936        fn load_roster(&self, team: &TeamName) -> Result<atm_storage::RosterSnapshot, AtmError> {
937            Ok(atm_storage::RosterSnapshot {
938                team_name: team.clone(),
939                members: self.members.clone(),
940                refreshed_at: None,
941            })
942        }
943
944        fn save_roster(&self, _roster: &atm_storage::RosterSnapshot) -> Result<(), AtmError> {
945            unreachable!("doctor tests do not touch the roster store boundary")
946        }
947
948        fn list_teams(&self) -> Result<Vec<TeamName>, AtmError> {
949            unreachable!("doctor tests do not touch the roster store boundary")
950        }
951    }
952
953    impl atm_storage::contract::sealed::Sealed for NoopNudgeTemplateOverrideStore {}
954
955    impl crate::boundary::NudgeTemplateOverrideStore for NoopNudgeTemplateOverrideStore {
956        fn load_template_override(
957            &self,
958            _team: &TeamName,
959            _kind: crate::boundary::BuiltInNudgeTemplateKind,
960        ) -> Result<Option<crate::boundary::TeamNudgeTemplateOverrideRow>, AtmError> {
961            Ok(None)
962        }
963
964        fn save_template_override(
965            &self,
966            _team: &TeamName,
967            _kind: crate::boundary::BuiltInNudgeTemplateKind,
968            _template_body: &str,
969        ) -> Result<crate::boundary::TeamNudgeTemplateOverrideRow, AtmError> {
970            unreachable!("doctor tests do not touch the override-store boundary")
971        }
972
973        fn disable_template_override(
974            &self,
975            _team: &TeamName,
976            _kind: crate::boundary::BuiltInNudgeTemplateKind,
977        ) -> Result<crate::boundary::TeamNudgeTemplateOverrideRow, AtmError> {
978            unreachable!("doctor tests do not touch the override-store boundary")
979        }
980
981        fn clear_template_override(
982            &self,
983            _team: &TeamName,
984            _kind: crate::boundary::BuiltInNudgeTemplateKind,
985        ) -> Result<bool, AtmError> {
986            unreachable!("doctor tests do not touch the override-store boundary")
987        }
988    }
989
990    fn roster_store(members: &[&str]) -> TestRosterStore {
991        TestRosterStore {
992            members: members
993                .iter()
994                .map(|member| atm_storage::RosterMember {
995                    team_name: TEST_TEAM.parse().expect("team"),
996                    agent_name: AgentName::from_validated(*member),
997                    member_kind: atm_storage::RosterMemberKind::Permanent,
998                    harness: atm_storage::RosterHarness::ClaudeCode,
999                    agent_type: atm_storage::contract::AgentType::default(),
1000                    model: atm_storage::ModelName::default(),
1001                    recipient_pane_id: None,
1002                    metadata_json: serde_json::Map::new(),
1003                })
1004                .collect(),
1005        }
1006    }
1007
1008    #[test]
1009    fn post_send_report_projects_external_override_without_message_content() {
1010        let config = AtmConfig {
1011            config_root: PathBuf::from("/workspace"),
1012            post_send_hooks: vec![PostSendHookRule {
1013                recipient: HookRecipient::Named(TEST_SENDER.parse().expect("recipient")),
1014                command: vec!["hooks/nudge".to_string(), "--quiet".to_string()],
1015            }],
1016            ..AtmConfig::default()
1017        };
1018        let roster = MembersList {
1019            team: TEST_TEAM.parse().expect("team"),
1020            members: vec![crate::team_admin::MemberSummary {
1021                name: TEST_SENDER.parse().expect("member"),
1022                agent_id: TEST_SENDER.to_string(),
1023                agent_type: "general".to_string(),
1024                model: Default::default(),
1025                joined_at: None,
1026                tmux_pane_id: None,
1027                home_dir: PathBuf::from("/workspace").into(),
1028                live_cwd: None,
1029                extra: serde_json::Map::new(),
1030            }],
1031        };
1032
1033        let runtime = test_runtime_with_roster(&[TEST_SENDER]);
1034        let report = super::post_send_doctor_report(
1035            Some(&config),
1036            Some(&roster),
1037            &runtime,
1038            Some(&roster.team),
1039        );
1040
1041        assert_eq!(report.external_rules.len(), 1);
1042        assert_eq!(
1043            report.external_rules[0].executable,
1044            PathBuf::from("hooks/nudge")
1045        );
1046        assert_eq!(report.external_rules[0].argv, ["--quiet"]);
1047        assert!(matches!(
1048            report.recipient_paths.as_slice(),
1049            [crate::doctor::RecipientDeliveryPathReport {
1050                path: crate::doctor::RecipientDeliveryPath::ExternalOverride {
1051                    rule: crate::doctor::PostSendHookRuleIndex(0),
1052                },
1053                ..
1054            }]
1055        ));
1056        assert!(
1057            !serde_json::to_string(&report)
1058                .expect("serialize report")
1059                .contains("message-body-must-never-appear")
1060        );
1061    }
1062
1063    fn test_runtime_with_roster(members: &[&str]) -> LocalServiceRuntime {
1064        LocalServiceRuntime::new_with_delivery_boundaries(
1065            Arc::new(UnusedMailStore),
1066            Arc::new(roster_store(members)),
1067            Arc::new(NoopNudgeTemplateOverrideStore),
1068            Arc::new(crate::LocalFileNonClaudeOutbound::new()),
1069        )
1070    }
1071
1072    fn test_runtime(paths: &TestPaths) -> LocalServiceRuntime {
1073        let _ = paths;
1074        test_runtime_with_roster(&[TEST_SENDER])
1075    }
1076
1077    fn run_doctor(
1078        paths: &TestPaths,
1079        query: DoctorQuery,
1080        observability: &dyn ObservabilityPort,
1081    ) -> Result<DoctorReport, AtmError> {
1082        let runtime = test_runtime(paths);
1083        run_doctor_with_runtime(query, observability, &runtime)
1084    }
1085
1086    struct TestPaths {
1087        _tempdir: tempfile::TempDir,
1088        home_dir: PathBuf,
1089        current_dir: PathBuf,
1090        active_log_path: PathBuf,
1091    }
1092
1093    impl TestPaths {
1094        fn new() -> Self {
1095            let tempdir = tempfile::tempdir().expect("tempdir");
1096            let root = tempdir.path().to_path_buf();
1097            let home_dir = root.join("atm-home");
1098            let current_dir = root.join("workspace");
1099            std::fs::write(root.join(".atm.toml"), "[atm]\n").expect("root sentinel");
1100            std::fs::create_dir_all(&home_dir).expect("home dir");
1101            std::fs::create_dir_all(&current_dir).expect("workspace dir");
1102            Self {
1103                _tempdir: tempdir,
1104                home_dir,
1105                current_dir,
1106                active_log_path: root.join("atm.log.jsonl"),
1107            }
1108        }
1109
1110        fn team_dir(&self) -> PathBuf {
1111            self.home_dir.join(".claude").join("teams").join(TEST_TEAM)
1112        }
1113
1114        fn write_team_layout(&self, members: &[&str]) {
1115            let team_dir = self.team_dir();
1116            std::fs::create_dir_all(team_dir.join("inboxes")).expect("inboxes dir");
1117            let config = TeamConfig {
1118                members: members
1119                    .iter()
1120                    .map(|member| AgentMember::with_name(AgentName::from_validated(*member)))
1121                    .collect(),
1122                ..Default::default()
1123            };
1124            std::fs::write(
1125                team_dir.join("config.json"),
1126                serde_json::to_vec(&config).expect("team config"),
1127            )
1128            .expect("write team config");
1129        }
1130
1131        fn write_raw_team_config(&self, raw: &str) {
1132            let team_dir = self.team_dir();
1133            std::fs::create_dir_all(&team_dir).expect("team dir");
1134            std::fs::write(team_dir.join("config.json"), raw).expect("write raw team config");
1135        }
1136    }
1137
1138    fn query(paths: &TestPaths) -> DoctorQuery {
1139        DoctorQuery {
1140            home_dir: paths.home_dir.clone(),
1141            current_dir: paths.current_dir.clone(),
1142            team_override: Some(TEST_TEAM.parse().expect("team")),
1143        }
1144    }
1145
1146    #[test]
1147    fn run_doctor_reports_healthy_observability() {
1148        let paths = TestPaths::new();
1149        paths.write_team_layout(&[TEST_SENDER]);
1150        let report = run_doctor(
1151            &paths,
1152            query(&paths),
1153            &StubObservability {
1154                health: StubHealth::Ok(AtmObservabilityHealth {
1155                    active_log_path: Some(paths.active_log_path.clone()),
1156                    logging_state: AtmObservabilityHealthState::Healthy,
1157                    query_state: Some(AtmObservabilityHealthState::Healthy),
1158                    maintenance: None,
1159                    diagnostic: None,
1160                    detail: None,
1161                }),
1162            },
1163        )
1164        .expect("doctor report");
1165
1166        assert_eq!(report.summary.status, DoctorStatus::Healthy);
1167        assert_eq!(report.findings[0].severity, DoctorSeverity::Info);
1168        assert_eq!(report.findings[0].code, AtmErrorCode::ObservabilityHealthOk);
1169    }
1170
1171    #[test]
1172    fn run_doctor_reports_invalid_team_override_as_address_error() {
1173        let paths = TestPaths::new();
1174        let report = run_doctor(
1175            &paths,
1176            DoctorQuery {
1177                home_dir: paths.home_dir.clone(),
1178                current_dir: paths.current_dir.clone(),
1179                team_override: Some(crate::types::TeamName::from_validated("../evil")),
1180            },
1181            &StubObservability {
1182                health: StubHealth::Ok(AtmObservabilityHealth {
1183                    active_log_path: Some(paths.active_log_path.clone()),
1184                    logging_state: AtmObservabilityHealthState::Healthy,
1185                    query_state: Some(AtmObservabilityHealthState::Healthy),
1186                    maintenance: None,
1187                    diagnostic: None,
1188                    detail: None,
1189                }),
1190            },
1191        )
1192        .expect("doctor report");
1193
1194        assert!(report.member_roster.is_none());
1195        assert!(
1196            report
1197                .findings
1198                .iter()
1199                .any(|finding| finding.code == AtmErrorCode::AddressParseFailed),
1200            "{report:#?}"
1201        );
1202    }
1203
1204    #[test]
1205    fn run_doctor_reports_obsolete_identity_drift_as_warning() {
1206        let paths = TestPaths::new();
1207        paths.write_team_layout(&[TEST_SENDER]);
1208        std::fs::write(
1209            paths.current_dir.join(".atm.toml"),
1210            format!("[atm]\nidentity = \"{TEST_SENDER}\"\n"),
1211        )
1212        .expect("config");
1213        let report = run_doctor(
1214            &paths,
1215            query(&paths),
1216            &StubObservability {
1217                health: StubHealth::Ok(AtmObservabilityHealth {
1218                    active_log_path: Some(paths.active_log_path.clone()),
1219                    logging_state: AtmObservabilityHealthState::Healthy,
1220                    query_state: Some(AtmObservabilityHealthState::Healthy),
1221                    maintenance: None,
1222                    diagnostic: None,
1223                    detail: None,
1224                }),
1225            },
1226        )
1227        .expect("doctor report");
1228
1229        assert_eq!(report.summary.status, DoctorStatus::Warning);
1230        assert_eq!(report.findings[0].severity, DoctorSeverity::Warning);
1231        assert_eq!(report.findings[0].code, AtmErrorCode::WarningIdentityDrift);
1232        assert!(
1233            report.findings[0]
1234                .message
1235                .contains("obsolete config identity")
1236        );
1237        assert_eq!(report.findings[1].code, AtmErrorCode::ObservabilityHealthOk);
1238    }
1239
1240    #[test]
1241    fn run_doctor_reports_degraded_observability_as_warning() {
1242        let paths = TestPaths::new();
1243        paths.write_team_layout(&[TEST_SENDER]);
1244        let report = run_doctor(
1245            &paths,
1246            query(&paths),
1247            &StubObservability {
1248                health: StubHealth::Ok(AtmObservabilityHealth {
1249                    active_log_path: Some(paths.active_log_path.clone()),
1250                    logging_state: AtmObservabilityHealthState::Degraded,
1251                    query_state: Some(AtmObservabilityHealthState::Degraded),
1252                    maintenance: None,
1253                    diagnostic: None,
1254                    detail: Some("query backlog".to_string()),
1255                }),
1256            },
1257        )
1258        .expect("doctor report");
1259
1260        assert_eq!(report.summary.status, DoctorStatus::Warning);
1261        assert_eq!(report.findings[0].severity, DoctorSeverity::Warning);
1262        assert_eq!(
1263            report.findings[0].code,
1264            AtmErrorCode::WarningObservabilityHealthDegraded
1265        );
1266    }
1267
1268    #[test]
1269    fn run_doctor_reports_unavailable_observability_as_error() {
1270        let paths = TestPaths::new();
1271        paths.write_team_layout(&[TEST_SENDER]);
1272        let report = run_doctor(
1273            &paths,
1274            query(&paths),
1275            &StubObservability {
1276                health: StubHealth::Ok(AtmObservabilityHealth {
1277                    active_log_path: None,
1278                    logging_state: AtmObservabilityHealthState::Unavailable,
1279                    query_state: Some(AtmObservabilityHealthState::Unavailable),
1280                    maintenance: None,
1281                    diagnostic: None,
1282                    detail: Some("logger unavailable".to_string()),
1283                }),
1284            },
1285        )
1286        .expect("doctor report");
1287
1288        assert_eq!(report.summary.status, DoctorStatus::Error);
1289        assert_eq!(report.findings[0].severity, DoctorSeverity::Error);
1290        assert_eq!(
1291            report.findings[0].code,
1292            AtmErrorCode::ObservabilityHealthFailed
1293        );
1294    }
1295
1296    #[test]
1297    fn run_doctor_reports_observability_health_errors() {
1298        let paths = TestPaths::new();
1299        paths.write_team_layout(&[TEST_SENDER]);
1300        let report = run_doctor(
1301            &paths,
1302            query(&paths),
1303            &StubObservability {
1304                health: StubHealth::Err(AtmError::observability_health(
1305                    "health check transport failed",
1306                )),
1307            },
1308        )
1309        .expect("doctor report");
1310
1311        assert_eq!(report.summary.status, DoctorStatus::Error);
1312        assert_eq!(report.findings[0].severity, DoctorSeverity::Error);
1313        assert_eq!(
1314            report.findings[0].code,
1315            AtmErrorCode::ObservabilityHealthFailed
1316        );
1317        assert_eq!(
1318            report.observability.logging_state,
1319            AtmObservabilityHealthState::Unavailable
1320        );
1321        assert!(
1322            report.findings[0]
1323                .message
1324                .contains("health check transport failed")
1325        );
1326    }
1327
1328    #[test]
1329    fn run_doctor_reports_missing_team_directory_as_error() {
1330        let paths = TestPaths::new();
1331        let report = run_doctor(
1332            &paths,
1333            query(&paths),
1334            &StubObservability {
1335                health: StubHealth::Ok(AtmObservabilityHealth {
1336                    active_log_path: Some(paths.active_log_path.clone()),
1337                    logging_state: AtmObservabilityHealthState::Healthy,
1338                    query_state: Some(AtmObservabilityHealthState::Healthy),
1339                    maintenance: None,
1340                    diagnostic: None,
1341                    detail: None,
1342                }),
1343            },
1344        )
1345        .expect("doctor report");
1346
1347        assert_eq!(report.summary.status, DoctorStatus::Error);
1348        assert!(
1349            report
1350                .findings
1351                .iter()
1352                .any(|finding| finding.code == AtmErrorCode::TeamNotFound),
1353            "{report:#?}"
1354        );
1355    }
1356
1357    #[test]
1358    fn run_doctor_reports_team_config_parse_failure_as_error() {
1359        let paths = TestPaths::new();
1360        paths.write_raw_team_config("{\"members\":");
1361        let report = run_doctor(
1362            &paths,
1363            query(&paths),
1364            &StubObservability {
1365                health: StubHealth::Ok(AtmObservabilityHealth {
1366                    active_log_path: Some(paths.active_log_path.clone()),
1367                    logging_state: AtmObservabilityHealthState::Healthy,
1368                    query_state: Some(AtmObservabilityHealthState::Healthy),
1369                    maintenance: None,
1370                    diagnostic: None,
1371                    detail: None,
1372                }),
1373            },
1374        )
1375        .expect("doctor report");
1376
1377        assert_eq!(report.summary.status, DoctorStatus::Error);
1378        assert!(
1379            report
1380                .findings
1381                .iter()
1382                .any(|finding| finding.code == AtmErrorCode::ConfigTeamParseFailed),
1383            "{report:#?}"
1384        );
1385    }
1386
1387    #[test]
1388    fn run_doctor_reports_missing_inboxes_directory_as_error() {
1389        let paths = TestPaths::new();
1390        paths.write_raw_team_config(&format!(r#"{{"members":[{{"name":"{TEST_SENDER}"}}]}}"#));
1391        let report = run_doctor(
1392            &paths,
1393            query(&paths),
1394            &StubObservability {
1395                health: StubHealth::Ok(AtmObservabilityHealth {
1396                    active_log_path: Some(paths.active_log_path.clone()),
1397                    logging_state: AtmObservabilityHealthState::Healthy,
1398                    query_state: Some(AtmObservabilityHealthState::Healthy),
1399                    maintenance: None,
1400                    diagnostic: None,
1401                    detail: None,
1402                }),
1403            },
1404        )
1405        .expect("doctor report");
1406
1407        assert_eq!(report.summary.status, DoctorStatus::Error);
1408        assert!(
1409            report
1410                .findings
1411                .iter()
1412                .any(|finding| finding.code == AtmErrorCode::MailboxWriteFailed),
1413            "{report:#?}"
1414        );
1415    }
1416
1417    #[test]
1418    fn run_doctor_reports_atm_roster_and_claude_roster_drift_as_warning() {
1419        let paths = TestPaths::new();
1420        paths.write_team_layout(&[TEST_SENDER]);
1421        let runtime = test_runtime_with_roster(&[TEST_SENDER, ROLE_TEAM_LEAD]);
1422        let report = run_doctor_with_runtime(
1423            query(&paths),
1424            &StubObservability {
1425                health: StubHealth::Ok(AtmObservabilityHealth {
1426                    active_log_path: Some(paths.active_log_path.clone()),
1427                    logging_state: AtmObservabilityHealthState::Healthy,
1428                    query_state: Some(AtmObservabilityHealthState::Healthy),
1429                    maintenance: None,
1430                    diagnostic: None,
1431                    detail: None,
1432                }),
1433            },
1434            &runtime,
1435        )
1436        .expect("doctor report");
1437
1438        assert_eq!(report.summary.status, DoctorStatus::Warning);
1439        assert!(
1440            report.findings.iter().any(|finding| {
1441                finding.code == AtmErrorCode::WarningRosterDrift
1442                    && finding.message.contains(&format!(
1443                        "ATM roster member '{}' is missing from team config.json",
1444                        ROLE_TEAM_LEAD
1445                    ))
1446            }),
1447            "{report:#?}"
1448        );
1449    }
1450
1451    #[test]
1452    fn run_doctor_reports_pane_and_home_dir_drift_from_roster_truth() {
1453        let paths = TestPaths::new();
1454        paths.write_team_layout(&[TEST_SENDER]);
1455        paths.write_raw_team_config(&format!(
1456            r#"{{"members":[{{"name":"{TEST_SENDER}","home_dir":"/repo/config"}}]}}"#
1457        ));
1458        let mut roster_member = atm_storage::RosterMember {
1459            team_name: TEST_TEAM.parse().expect("team"),
1460            agent_name: AgentName::from_validated(TEST_SENDER),
1461            member_kind: atm_storage::RosterMemberKind::Permanent,
1462            harness: atm_storage::RosterHarness::ClaudeCode,
1463            agent_type: atm_storage::contract::AgentType::default(),
1464            model: atm_storage::ModelName::default(),
1465            recipient_pane_id: Some(crate::types::PaneId::from_cli("%9").expect("pane")),
1466            metadata_json: serde_json::Map::new(),
1467        };
1468        roster_member.metadata_json.insert(
1469            HOME_DIR_METADATA_KEY.to_string(),
1470            serde_json::json!("/repo/roster"),
1471        );
1472        let runtime = LocalServiceRuntime::new_with_delivery_boundaries(
1473            Arc::new(UnusedMailStore),
1474            Arc::new(TestRosterStore {
1475                members: vec![roster_member],
1476            }),
1477            Arc::new(NoopNudgeTemplateOverrideStore),
1478            Arc::new(crate::LocalFileNonClaudeOutbound::new()),
1479        );
1480
1481        let report = run_doctor_with_runtime(
1482            query(&paths),
1483            &StubObservability {
1484                health: StubHealth::Ok(AtmObservabilityHealth {
1485                    active_log_path: Some(paths.active_log_path.clone()),
1486                    logging_state: AtmObservabilityHealthState::Healthy,
1487                    query_state: Some(AtmObservabilityHealthState::Healthy),
1488                    maintenance: None,
1489                    diagnostic: None,
1490                    detail: None,
1491                }),
1492            },
1493            &runtime,
1494        )
1495        .expect("doctor report");
1496
1497        assert_eq!(report.summary.status, DoctorStatus::Warning);
1498        assert!(
1499            report
1500                .findings
1501                .iter()
1502                .any(|finding| finding.message.contains("pane drift")),
1503            "{report:#?}"
1504        );
1505        assert!(
1506            report
1507                .findings
1508                .iter()
1509                .any(|finding| finding.message.contains("home-dir drift")),
1510            "{report:#?}"
1511        );
1512        let member_roster = report.member_roster.expect("member roster");
1513        assert_eq!(member_roster.members[0].tmux_pane_id.as_deref(), Some("%9"));
1514        assert_eq!(
1515            member_roster.members[0].home_dir.as_path(),
1516            Path::new("/repo/roster")
1517        );
1518    }
1519
1520    #[test]
1521    fn ordered_member_summaries_overlay_live_cwd_for_calling_member_only() {
1522        let members = vec![
1523            AgentMember::with_name(AgentName::from_validated(ROLE_TEAM_LEAD)),
1524            AgentMember::with_name(AgentName::from_validated(TEST_SENDER)),
1525        ];
1526        let caller_identity = AgentName::from_validated(TEST_SENDER);
1527
1528        let summaries = ordered_member_summaries(
1529            &members,
1530            &[],
1531            Some(&caller_identity),
1532            Some(Path::new("/repo/live")),
1533        );
1534
1535        assert_eq!(summaries.len(), 2);
1536        assert_eq!(summaries[0].name.as_str(), ROLE_TEAM_LEAD);
1537        assert_eq!(summaries[0].live_cwd, None);
1538        assert_eq!(summaries[1].name.as_str(), TEST_SENDER);
1539        assert_eq!(summaries[1].live_cwd.as_deref(), Some("/repo/live"));
1540    }
1541
1542    #[test]
1543    fn run_doctor_reports_stale_mailbox_lock_as_warning() {
1544        let paths = TestPaths::new();
1545        paths.write_team_layout(&[TEST_SENDER]);
1546        let stale_lock = paths
1547            .team_dir()
1548            .join("inboxes")
1549            .join(format!("{TEST_SENDER}.json.lock"));
1550        std::fs::write(&stale_lock, u32::MAX.to_string()).expect("stale lock");
1551        let report = run_doctor(
1552            &paths,
1553            query(&paths),
1554            &StubObservability {
1555                health: StubHealth::Ok(AtmObservabilityHealth {
1556                    active_log_path: Some(paths.active_log_path.clone()),
1557                    logging_state: AtmObservabilityHealthState::Healthy,
1558                    query_state: Some(AtmObservabilityHealthState::Healthy),
1559                    maintenance: None,
1560                    diagnostic: None,
1561                    detail: None,
1562                }),
1563            },
1564        )
1565        .expect("doctor report");
1566
1567        assert_eq!(report.summary.status, DoctorStatus::Warning);
1568        assert!(
1569            report.findings.iter().any(|finding| {
1570                finding.code == AtmErrorCode::WarningStaleMailboxLock
1571                    && finding.message.contains(&stale_lock.display().to_string())
1572            }),
1573            "{report:#?}"
1574        );
1575    }
1576}