From 0163a611f580295275acf3eb409a692c67d99096 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 30 Sep 2026 05:30:57 +0000 Subject: [PATCH] fix(doctor): open the registered project a lone doctor asks about On a fresh daemon the Doctor probe only looked the project server up in the open-server cache. With nothing else opening the project, the core answered doctor_report_owner_warming forever and doctor printed "mounting" until another request mounted it. The probe on both brokers (Unix engine and portable) now schedules the same admitted background open an MCP initialize schedules when no server is cached, and the doctor client polls a warming owner within its existing warm-up window like missing telemetry. The acceptance helper that re-ran doctor until it stopped reporting mounting is deleted. Stored index-path reads that cannot run (a reset-required or mounting store) are now warnings rather than issues: the store state is already reported, and an unrunnable diagnostic is not an issue. Fixes #2713 --- .../src/daemon/connection_serving.rs | 41 ++++++++--- crates/tracedecay/src/daemon/engine.rs | 6 +- .../src/daemon/project_open_orchestration.rs | 4 +- .../tracedecay/src/daemon/tests/bootstrap.rs | 6 +- crates/tracedecay/src/doctor.rs | 19 +++-- .../stale_sessions_store_reset.rs | 69 +++++++++++-------- 6 files changed, 97 insertions(+), 48 deletions(-) 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()