diff --git a/crates/tracedecay/src/daemon/connection_serving.rs b/crates/tracedecay/src/daemon/connection_serving.rs index 8f06a42bc9..e7afe3e0b2 100644 --- a/crates/tracedecay/src/daemon/connection_serving.rs +++ b/crates/tracedecay/src/daemon/connection_serving.rs @@ -1277,10 +1277,17 @@ fn serve_broker_socket_client_inner( &first_request, || { Box::pin(async { - Ok(engine - .cached_project_server(&handshake) - .await? - .is_some_and(|server| server.doctor_report_ready())) + if let Some(server) = engine.cached_project_server(&handshake).await? { + return Ok(server.doctor_report_ready()); + } + // Only the project owner publishes the report, so a + // Doctor asking about an unopened project admits + // and opens it the way an initialize would. + Box::pin( + engine.schedule_project_server_warmup(handshake.clone(), None), + ) + .await?; + Ok(false) }) }, )) @@ -1444,7 +1451,7 @@ fn serve_broker_socket_client_inner( { Box::pin(engine.schedule_project_server_warmup( handshake.clone(), - request.clone(), + Some(request.clone()), )) .await .err() @@ -1795,14 +1802,32 @@ pub(super) async fn serve_windows_broker_client_with_class_and_invocation( || async { let (canonical_project_path, _) = project_route_for_handshake(&handshake, store_administration.owner_home()?)?; - Ok(Box::pin(portable_cached_project_server( + if let Some(server) = Box::pin(portable_cached_project_server( &store_administration, &canonical_project_path, &handshake, ProjectServerRequirement::Core, )) .await? - .is_some_and(|server| server.doctor_report_ready())) + { + return Ok(server.doctor_report_ready()); + } + // Only the project owner publishes the report, so a Doctor asking + // about an unopened project admits and opens it the way an + // initialize would. + Box::pin(schedule_portable_project_server_warmup( + lifecycle.clone(), + store_administration.clone(), + Arc::clone(&project_open_gates), + invocation.clone(), + http_application_registry.clone(), + handshake.clone(), + None, + #[cfg(test)] + project_open_attempts.clone(), + )) + .await?; + Ok(false) }, )) .await? @@ -1983,7 +2008,7 @@ pub(super) async fn serve_windows_broker_client_with_class_and_invocation( invocation.clone(), http_application_registry.clone(), handshake.clone(), - request.clone(), + Some(request.clone()), #[cfg(test)] project_open_attempts.clone(), )) diff --git a/crates/tracedecay/src/daemon/engine.rs b/crates/tracedecay/src/daemon/engine.rs index cbfd75586d..99b47383c1 100644 --- a/crates/tracedecay/src/daemon/engine.rs +++ b/crates/tracedecay/src/daemon/engine.rs @@ -640,7 +640,7 @@ impl DaemonEngine { pub(super) async fn schedule_project_server_warmup( &self, handshake: DaemonHandshake, - initialize_request: JsonRpcRequest, + initialize_request: Option, ) -> Result<()> { self.schedule_project_server_warmup_inner(handshake, initialize_request) .await @@ -649,7 +649,7 @@ impl DaemonEngine { fn schedule_project_server_warmup_inner( &self, handshake: DaemonHandshake, - initialize_request: JsonRpcRequest, + initialize_request: Option, ) -> std::pin::Pin> + Send + '_>> { // Erase the deeply nested future before it reaches the measured // wrapper so every profiling feature can compute its layout. @@ -657,7 +657,7 @@ impl DaemonEngine { if self.cached_project_server(&handshake).await?.is_some() { return Ok(()); } - match Box::pin(self.begin_project_open(handshake, Some(initialize_request))).await? { + match Box::pin(self.begin_project_open(handshake, initialize_request)).await? { ProjectOpenTaskClaim::InFlight(_) => Ok(()), ProjectOpenTaskClaim::Failed(failure) => Err(failure.to_error()), ProjectOpenTaskClaim::Saturated => Err(project_open_task_capacity_error()), diff --git a/crates/tracedecay/src/daemon/project_open_orchestration.rs b/crates/tracedecay/src/daemon/project_open_orchestration.rs index a857a8eb02..6ba5e5e569 100644 --- a/crates/tracedecay/src/daemon/project_open_orchestration.rs +++ b/crates/tracedecay/src/daemon/project_open_orchestration.rs @@ -423,7 +423,7 @@ pub(super) async fn schedule_portable_project_server_warmup( invocation: DaemonInvocationState, http_application_registry: http_application::DaemonHttpApplicationRegistry, handshake: DaemonHandshake, - initialize_request: JsonRpcRequest, + initialize_request: Option, #[cfg(test)] project_open_attempts: Option>, ) -> Result<()> { let (canonical_project_path, route) = @@ -448,7 +448,7 @@ pub(super) async fn schedule_portable_project_server_warmup( handshake, canonical_project_path, route, - Some(initialize_request), + initialize_request, #[cfg(test)] project_open_attempts, )) diff --git a/crates/tracedecay/src/daemon/tests/bootstrap.rs b/crates/tracedecay/src/daemon/tests/bootstrap.rs index 7c957165f0..6d77311354 100644 --- a/crates/tracedecay/src/daemon/tests/bootstrap.rs +++ b/crates/tracedecay/src/daemon/tests/bootstrap.rs @@ -3126,7 +3126,7 @@ async fn project_server_warmup_drops_lifecycle_activity_on_draining() { client_identity, ..test_handshake_defaults() }; - let initialize_request = serde_json::from_value(json!({ + let initialize_request: tracedecay_mcp::JsonRpcRequest = serde_json::from_value(json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", @@ -3148,7 +3148,7 @@ async fn project_server_warmup_drops_lifecycle_activity_on_draining() { }); writer_held.notified().await; - Box::pin(engine.schedule_project_server_warmup(handshake, initialize_request)) + Box::pin(engine.schedule_project_server_warmup(handshake, Some(initialize_request))) .await .expect("schedule project warmup"); engine.lifecycle.begin_draining(); @@ -3257,7 +3257,7 @@ async fn portable_project_warmup_rejects_after_shutdown_snapshot() { super::super::DaemonInvocationState::default(), super::super::http_application::DaemonHttpApplicationRegistry::default(), handshake, - initialize_request, + Some(initialize_request), Some(Arc::clone(&attempts)), )) .await diff --git a/crates/tracedecay/src/doctor.rs b/crates/tracedecay/src/doctor.rs index 2c8c661753..6ef58016e1 100644 --- a/crates/tracedecay/src/doctor.rs +++ b/crates/tracedecay/src/doctor.rs @@ -591,10 +591,19 @@ async fn daemon_project_status( Some(deadline), ) .await?; + let warmed_up = tokio::time::Instant::now() >= warmup_deadline; match daemon_runtime_status(runtime)? { - Some(status) => return Ok(Some(status)), - None if tokio::time::Instant::now() >= warmup_deadline => return Ok(None), - None => tokio::time::sleep(RUNTIME_TELEMETRY_POLL).await, + // The first Doctor call starts the project open, so a warming + // owner is polled like missing telemetry until the report lands. + Some(status) + if warmed_up + || canonical_daemon_doctor_report(&status)? + != CanonicalDoctorReport::Mounting => + { + return Ok(Some(status)); + } + None if warmed_up => return Ok(None), + Some(_) | None => tokio::time::sleep(RUNTIME_TELEMETRY_POLL).await, } } } @@ -1089,7 +1098,9 @@ async fn check_project_index_paths( } } } - Err(error) => dc.fail(&format!("{key} could not be read: {error}")), + // A reset-required or still-mounting store is already reported + // as its own state; a read that could not run is not an issue. + Err(error) => dc.warn(&format!("{key} could not be read: {error}")), } } } diff --git a/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance/stale_sessions_store_reset.rs b/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance/stale_sessions_store_reset.rs index b56072d7a6..0efcff64e1 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance/stale_sessions_store_reset.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance/stale_sessions_store_reset.rs @@ -517,7 +517,7 @@ fn refused_session_stores_serve_code_until_their_scoped_reset(refusal: &SessionS !doctor_text.contains("Stalled"), "doctor must not report project open stalled on a session store:\n{doctor_text}" ); - let (exit, report, stderr) = mounted_doctor_report(&home_path, &project_path); + let (exit, report, stderr) = run_doctor_json(&home_path, &project_path); assert_eq!( (exit, &report["daemon_findings"]["state"], &report["issues"]), (Some(75), &json!("observed"), &json!(0)), @@ -715,36 +715,49 @@ fn seed_cursor_identity_collision_refusal(db_path: &Path, project_id: &str) { .expect("seed the refusal"); } -/// `tracedecay doctor --json` exit code, document, and stderr once the -/// canonical Doctor report of the project has mounted. -fn mounted_doctor_report(home: &Path, project: &Path) -> (Option, Value, String) { - let started = Instant::now(); - loop { - let doctor = tracedecay_command_with_home(home) - .args(["doctor", "--json"]) - .current_dir(project) - .stdin(Stdio::null()) - .output() - .expect("run doctor"); - let stderr = String::from_utf8_lossy(&doctor.stderr).into_owned(); - let report: Value = serde_json::from_slice(&doctor.stdout).unwrap_or_else(|error| { - panic!("doctor --json printed no document ({error}):\n{stderr}") - }); - if report["daemon_findings"]["state"] != "mounting" { - return (doctor.status.code(), report, stderr); - } - assert!( - started.elapsed() < SERVE_TIMEOUT, - "the canonical Doctor report never mounted within {SERVE_TIMEOUT:?}:\n{stderr}" - ); - std::thread::sleep(Duration::from_millis(500)); - } +/// One `tracedecay doctor --json` run: exit code, document, and stderr. +fn run_doctor_json(home: &Path, project: &Path) -> (Option, Value, String) { + let doctor = tracedecay_command_with_home(home) + .args(["doctor", "--json"]) + .current_dir(project) + .stdin(Stdio::null()) + .output() + .expect("run doctor"); + let stderr = String::from_utf8_lossy(&doctor.stderr).into_owned(); + let report: Value = serde_json::from_slice(&doctor.stdout) + .unwrap_or_else(|error| panic!("doctor --json printed no document ({error}):\n{stderr}")); + (doctor.status.code(), report, stderr) +} + +/// A restarted daemon has no project open until something asks for one. A +/// lone `doctor` must open the registered project itself and report the +/// canonical findings, not a mount that never starts. +#[test] +fn doctor_alone_on_a_fresh_daemon_opens_the_project_and_reports_findings() { + let home = tempfile::TempDir::new().expect("isolated home"); + let home_path = canonical_existing_path(home.path()); + let project = tempfile::TempDir::new().expect("project"); + let project_path = canonical_existing_path(project.path()); + + let mut daemon = spawn_tracedecay_daemon_with(&home_path, |_| {}); + super::initialize_project(&home_path, &project_path, "cold-doctor"); + daemon + .kill_and_wait() + .expect("stop the daemon that registered the project"); + + let mut daemon = spawn_tracedecay_daemon_with(&home_path, |_| {}); + let (_, report, stderr) = run_doctor_json(&home_path, &project_path); + assert_eq!( + report["daemon_findings"]["state"], "observed", + "the first doctor on a fresh daemon must reach the canonical report:\n{stderr}" + ); + + let _ = daemon.kill_and_wait(); } -/// `tracedecay doctor --json` exit code and its ingest-coverage finding, once -/// the project runtime that owns the canonical report has mounted. +/// `tracedecay doctor --json` exit code and its ingest-coverage finding. fn doctor_ingest_coverage(home: &Path, project: &Path) -> (Option, Value, String) { - let (exit, report, stderr) = mounted_doctor_report(home, project); + let (exit, report, stderr) = run_doctor_json(home, project); let finding = report["daemon_findings"]["payload"]["entries"] .as_array() .into_iter()