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
53pub 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 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(¤t_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}