diff --git a/crates/tracedecay-cli/src/commands.rs b/crates/tracedecay-cli/src/commands.rs index 90fcbe6d25..fab3136c25 100644 --- a/crates/tracedecay-cli/src/commands.rs +++ b/crates/tracedecay-cli/src/commands.rs @@ -25,5 +25,5 @@ pub(crate) use settings::{ }; pub(crate) use storage::{ ProfileOfflineAuthority, annotate_reset_required, handle_list, handle_wipe, - join_outcome_and_restore, take_profile_offline, try_admit_profile_registry, + join_outcome_and_restore, process_error_text, take_profile_offline, try_admit_profile_registry, }; diff --git a/crates/tracedecay-cli/src/commands/index.rs b/crates/tracedecay-cli/src/commands/index.rs index 644617a64d..9ffa701fd4 100644 --- a/crates/tracedecay-cli/src/commands/index.rs +++ b/crates/tracedecay-cli/src/commands/index.rs @@ -425,38 +425,34 @@ mod init_bootstrap_tests { use super::daemon_precondition_tests::SocketEnvGuard; - /// Init's "daemon code-index reconciliation requested" must describe a - /// request that actually crossed the wire: admission first, then the - /// explicit `tracedecay_admin_sync` reconcile. Without the second call the - /// first index only starts if the background full-server upgrade survives - /// long enough to demand it, which a daemon restart silently discards. - #[tokio::test] - async fn brokered_init_requests_a_real_code_index_reconciliation() { + static SOCKET_ENV_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + + type RecordedRequests = std::sync::Arc>>; + + /// A daemon on `socket` that authenticates `connections` requests and + /// answers each with `respond(tool_name, request_id)`. + fn spawn_fixture_daemon( + temp: &Path, + socket: &Path, + connections: usize, + respond: fn(&str, serde_json::Value) -> serde_json::Value, + ) -> (tokio::task::JoinHandle<()>, RecordedRequests) { use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; - static SOCKET_ENV_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - let _serialize = SOCKET_ENV_TEST_LOCK.lock().await; - let temp = tempfile::TempDir::new().unwrap(); - let project = temp.path().join("project"); - let profile = temp.path().join("profile"); - std::fs::create_dir_all(&project).unwrap(); - let socket = temp.path().join("daemon.sock"); let authority = tracedecay_daemon_identity::authority::DaemonAuthority::acquire( - temp.path(), - &tracedecay_daemon_protocol::DaemonEndpoint::Unix(socket.clone()), + temp, + &tracedecay_daemon_protocol::DaemonEndpoint::Unix(socket.to_path_buf()), env!("CARGO_PKG_VERSION"), ) .expect("publish the fixture daemon's authority record"); let auth_token = authority.auth_token().to_owned(); - let listener = tokio::net::UnixListener::bind(&socket).unwrap(); - let _socket_env = SocketEnvGuard::set(&socket); - - let recorded: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let listener = tokio::net::UnixListener::bind(socket).unwrap(); + let recorded = RecordedRequests::default(); let responder = { let recorded = std::sync::Arc::clone(&recorded); tokio::spawn(async move { - for _ in 0..2 { + let _authority = authority; + for _ in 0..connections { let (stream, _addr) = listener.accept().await.unwrap(); let (reader, mut writer) = stream.into_split(); let mut lines = tokio::io::BufReader::new(reader).lines(); @@ -470,18 +466,15 @@ mod init_bootstrap_tests { let _handshake_line = lines.next_line().await.unwrap().unwrap(); let request_line = lines.next_line().await.unwrap().unwrap(); let request: serde_json::Value = serde_json::from_str(&request_line).unwrap(); - recorded.lock().unwrap().push(( - request["params"]["name"] - .as_str() - .unwrap_or_default() - .to_owned(), - request["params"]["arguments"].clone(), - )); - let response = serde_json::json!({ - "jsonrpc": "2.0", - "id": request["id"], - "result": { "content": [] }, - }); + let name = request["params"]["name"] + .as_str() + .unwrap_or_default() + .to_owned(); + recorded + .lock() + .unwrap() + .push((name.clone(), request["params"]["arguments"].clone())); + let response = respond(&name, request["id"].clone()); writer .write_all(serde_json::to_string(&response).unwrap().as_bytes()) .await @@ -491,6 +484,33 @@ mod init_bootstrap_tests { } }) }; + (responder, recorded) + } + + fn empty_tool_result(_tool: &str, id: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "content": [] }, + }) + } + + /// Init's "daemon code-index reconciliation requested" must describe a + /// request that actually crossed the wire: admission first, then the + /// explicit `tracedecay_admin_sync` reconcile. Without the second call the + /// first index only starts if the background full-server upgrade survives + /// long enough to demand it, which a daemon restart silently discards. + #[tokio::test] + async fn brokered_init_requests_a_real_code_index_reconciliation() { + let _serialize = SOCKET_ENV_TEST_LOCK.lock().await; + let temp = tempfile::TempDir::new().unwrap(); + let project = temp.path().join("project"); + let profile = temp.path().join("profile"); + std::fs::create_dir_all(&project).unwrap(); + let socket = temp.path().join("daemon.sock"); + let (responder, recorded) = + spawn_fixture_daemon(temp.path(), &socket, 2, empty_tool_result); + let _socket_env = SocketEnvGuard::set(&socket); let handshake = test_handshake(&project, &profile); brokered_init(&project, &[], &[], &handshake) @@ -515,6 +535,57 @@ mod init_bootstrap_tests { ); } + /// A worktree parked on a corrupt publication authority refuses the + /// reconcile; the operator reads its cause and remedy as whole fields, + /// not folded into one sentence cut at the diagnostic bound. + #[tokio::test] + async fn brokered_init_prints_a_parked_refusal_as_fields() { + fn refuse_sync(tool: &str, id: serde_json::Value) -> serde_json::Value { + if tool != "tracedecay_admin_sync" { + return empty_tool_result(tool, id); + } + let parked = tracedecay_contracts::code_index_freshness::CodeIndexConvergenceParkedV1 { + reason: format!("the publication authority is corrupt: {}", "x".repeat(600)), + blocked_reason: None, + remediation: "run `tracedecay daemon restart`".to_owned(), + parked_at_micros: 1, + observed_passes: 1, + retries_on_wake: false, + }; + serde_json::to_value(tracedecay_mcp::tool_error_response( + id, + tool, + &parked.publication_authority_corrupt_error(), + )) + .unwrap() + } + + let _serialize = SOCKET_ENV_TEST_LOCK.lock().await; + let temp = tempfile::TempDir::new().unwrap(); + let project = temp.path().join("project"); + let profile = temp.path().join("profile"); + std::fs::create_dir_all(&project).unwrap(); + let socket = temp.path().join("daemon.sock"); + let (responder, _recorded) = spawn_fixture_daemon(temp.path(), &socket, 2, refuse_sync); + let _socket_env = SocketEnvGuard::set(&socket); + + let error = brokered_init(&project, &[], &[], &test_handshake(&project, &profile)) + .await + .expect_err("a parked worktree refuses the reconcile"); + responder.await.expect("fixture daemon task"); + + assert_eq!( + crate::commands::process_error_text(error), + format!( + "project route error (code_index_publication_authority_corrupt)\n\ + Parked cause: the publication authority is corrupt: {}\n\ + Parked remedy: run `tracedecay daemon restart`\n\ + Retries on wake: false", + "x".repeat(600) + ) + ); + } + #[tokio::test(flavor = "current_thread")] async fn brokered_init_retains_folder_option_error_before_sending_request() { let temp = tempfile::TempDir::new().unwrap(); diff --git a/crates/tracedecay-cli/src/commands/storage.rs b/crates/tracedecay-cli/src/commands/storage.rs index a95ee8e4ef..a88ef57f02 100644 --- a/crates/tracedecay-cli/src/commands/storage.rs +++ b/crates/tracedecay-cli/src/commands/storage.rs @@ -504,6 +504,24 @@ pub(crate) fn annotate_reset_required( } } +/// The text the process boundary prints after `Error: `. A route refusal +/// with typed detail prints its reason code and one line per detail field, so +/// a parked index shows its whole cause and remedy rather than the bounded +/// sentence its message folds them into. +pub(crate) fn process_error_text(error: tracedecay_domain::errors::TraceDecayError) -> String { + if let (Some((reason_code, _, _)), Some(detail)) = ( + error.project_route_context(), + error.project_route_typed_detail(), + ) { + let mut text = format!("project route error ({reason_code})"); + for (label, value) in detail.labelled_fields() { + text.push_str(&format!("\n{label}: {value}")); + } + return text; + } + annotate_reset_required(error, None).to_string() +} + /// Combines a destructive command's outcome with the daemon-restore outcome /// so neither failure can shadow the other. pub(crate) fn join_outcome_and_restore( diff --git a/crates/tracedecay-cli/src/main.rs b/crates/tracedecay-cli/src/main.rs index 43322b1723..ceb0d07938 100644 --- a/crates/tracedecay-cli/src/main.rs +++ b/crates/tracedecay-cli/src/main.rs @@ -633,8 +633,9 @@ fn main() -> ExitCode { ExitCode::FAILURE }; // A typed reset refusal from any command ends with the refused - // authority and the exact command that resets it. - eprintln!("Error: {}", commands::annotate_reset_required(e, None)); + // authority and the exact command that resets it; a typed route + // detail prints as fields. + eprintln!("Error: {}", commands::process_error_text(e)); code } } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs index cb9c58ef6f..2cf2d1e6b7 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs @@ -4,7 +4,6 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::time::Instant; -use tracedecay_contracts::code_index_freshness::CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT; use tracedecay_contracts::code_index_freshness::{ CodeGraphServingReadinessV1, CodeIndexWorktreeFreshnessV1, }; @@ -45,11 +44,9 @@ pub(super) fn branch_refresh_admission_error( ) -> Option { match admission { CodeIndexDemandAdmissionV1::Queued | CodeIndexDemandAdmissionV1::NotApplicable => None, - CodeIndexDemandAdmissionV1::Terminal(parked) => Some(TraceDecayError::project_route( - CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT, - false, - format!("{}; {}", parked.reason, parked.remediation), - )), + CodeIndexDemandAdmissionV1::Terminal(parked) => { + Some(parked.clone().publication_authority_corrupt_error()) + } CodeIndexDemandAdmissionV1::RefusedByPolicy | CodeIndexDemandAdmissionV1::Unavailable(_) => Some(TraceDecayError::project_route( CODE_INDEX_SCHEDULER_UNAVAILABLE, @@ -491,11 +488,7 @@ impl BranchPublicationContextV1 { .publication_authority_corruption(canonical_worktree_root) .await { - return Err(TraceDecayError::project_route( - CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT, - false, - format!("{}; {}", parked.reason, parked.remediation), - )); + return Err(parked.publication_authority_corrupt_error()); } let scope = schedulers .serving_code_scope(canonical_worktree_root) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs index 6fda1ebde1..76da2b869b 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs @@ -312,12 +312,17 @@ async fn mid_wait_branch_publication_surfaces_terminal_publication_park() { tracedecay_contracts::code_index_freshness::CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT ); assert!(!context.1, "terminal corruption is non-retryable"); - assert!( - context - .2 - .contains("mid-wait injected publication corruption"), - "detail must retain the parked reason: {}", - context.2 + assert_eq!( + error.project_route_typed_detail(), + Some(&tracedecay_domain::ApplicationProblemDetailV1::Parked { + cause: "mid-wait injected publication corruption".to_owned(), + remedy: "the derived code-index publication was deleted and rebuilt once in this \ + daemon and is corrupt again; run `tracedecay daemon restart` for one more \ + automatic rebuild, and report the daemon log's \ + code_index_publication_authority_* events if it recurs" + .to_owned(), + retries_on_wake: false, + }) ); drop(held); registry.shutdown().await; diff --git a/crates/tracedecay-contracts/src/code_index_freshness.rs b/crates/tracedecay-contracts/src/code_index_freshness.rs index ed6d760dbf..26f57dd85a 100644 --- a/crates/tracedecay-contracts/src/code_index_freshness.rs +++ b/crates/tracedecay-contracts/src/code_index_freshness.rs @@ -11,6 +11,8 @@ use std::sync::Arc; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use tracedecay_domain::ApplicationProblemDetailV1; +use tracedecay_domain::errors::TraceDecayError; /// The durable build phase whose committed boundary the dashboard is reading. /// @@ -144,6 +146,23 @@ pub struct CodeIndexConvergenceParkedV1 { pub retries_on_wake: bool, } +impl CodeIndexConvergenceParkedV1 { + /// The route refusal of a worktree parked on a corrupt publication + /// authority. The park travels as its typed detail, so every adapter + /// renders `cause` and `remedy` as fields rather than one bounded sentence. + pub fn publication_authority_corrupt_error(self) -> TraceDecayError { + TraceDecayError::project_route_with_detail( + CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT, + false, + ApplicationProblemDetailV1::Parked { + cause: self.reason, + remedy: self.remediation, + retries_on_wake: self.retries_on_wake, + }, + ) + } +} + /// Recovery state for a durable generation sealed under a different production /// owner configuration. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] diff --git a/crates/tracedecay-mcp/src/tool_errors.rs b/crates/tracedecay-mcp/src/tool_errors.rs index 47d99fbfe6..a002c7fbb7 100644 --- a/crates/tracedecay-mcp/src/tool_errors.rs +++ b/crates/tracedecay-mcp/src/tool_errors.rs @@ -1,6 +1,7 @@ //! Semantic tool-failure classification and JSON-RPC error-response mapping. use serde_json::{Value, json}; +use tracedecay_contracts::ApplicationProblem; use tracedecay_domain::errors::{PROFILE_RESET_COMMAND, TraceDecayError}; use tracedecay_sessions::admission::HostAdmissionStatus; @@ -153,15 +154,16 @@ pub fn project_route_problem(tool_name: &str, error: &TraceDecayError) -> Option "retryable": retryable, "detail": detail, }); + let mut kind = project_route_problem_kind(reason_code).map(|kind| json!(kind)); if let (Some(typed_detail), Some(object)) = (error.project_route_typed_detail(), data.as_object_mut()) { object.insert("detail".to_string(), json!(typed_detail)); + kind = Some(json!( + ApplicationProblem::from_detail(typed_detail.clone()).kind() + )); } - if let (Some(kind), Some(object)) = ( - project_route_problem_kind(reason_code), - data.as_object_mut(), - ) { + if let (Some(kind), Some(object)) = (kind, data.as_object_mut()) { object.insert("kind".to_string(), json!(kind)); object.insert("code".to_string(), json!(reason_code)); } @@ -596,16 +598,19 @@ mod tests { let wire = serde_json::to_value(response).expect("JSON-RPC wire response"); assert_eq!( - wire["error"]["data"]["reason_code"], - "code_index_publication_authority_corrupt" - ); - assert_eq!( - wire["error"]["data"]["detail"], + wire["error"]["data"], json!({ - "kind": "parked", - "cause": "source mode is not owner-private", - "remedy": "restore mode 0600", - "retries_on_wake": true, + "tool": "tracedecay_sync", + "kind": "unavailable", + "code": "code_index_publication_authority_corrupt", + "reason_code": "code_index_publication_authority_corrupt", + "retryable": false, + "detail": { + "kind": "parked", + "cause": "source mode is not owner-private", + "remedy": "restore mode 0600", + "retries_on_wake": true, + }, }) ); } diff --git a/crates/tracedecay/src/mcp/server.rs b/crates/tracedecay/src/mcp/server.rs index a4edd45e26..06edc38a9c 100644 --- a/crates/tracedecay/src/mcp/server.rs +++ b/crates/tracedecay/src/mcp/server.rs @@ -15,9 +15,7 @@ use crate::mcp::project_route::{ use tracedecay_code_index_runtime::code_index_scheduler::{ CodeIndexDemandAdmissionV1, CodeIndexDemandUnavailableV1, CodeIndexDemandV1, }; -use tracedecay_contracts::code_index_freshness::{ - CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT, CodeIndexConvergenceParkedV1, -}; +use tracedecay_contracts::code_index_freshness::CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT; use tracedecay_contracts::request_identity::{ McpConnectionIdentityAuthority, mcp_connection_request_key as application_surface_request_id, }; @@ -142,20 +140,6 @@ pub(crate) const CODE_INDEX_NO_PROVEN_CHANGE: &str = "code_index_no_proven_chang pub(crate) const CODE_INDEX_NOT_APPLICABLE: &str = "code_index_not_applicable"; pub(crate) const CODE_INDEX_IDENTITY_UNRESOLVED: &str = "code_index_identity_unresolved"; -pub(crate) fn code_index_publication_corrupt( - parked: CodeIndexConvergenceParkedV1, -) -> TraceDecayError { - TraceDecayError::project_route_with_detail( - CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT, - false, - tracedecay_domain::ApplicationProblemDetailV1::Parked { - cause: parked.reason, - remedy: parked.remediation, - retries_on_wake: parked.retries_on_wake, - }, - ) -} - pub(crate) fn code_index_linked_worktree_disabled() -> TraceDecayError { TraceDecayError::project_route( CODE_INDEX_LINKED_WORKTREE_DISABLED, diff --git a/crates/tracedecay/src/mcp/server/background_refresh_writer_tests.rs b/crates/tracedecay/src/mcp/server/background_refresh_writer_tests.rs index dac3ff94a6..d7552430a8 100644 --- a/crates/tracedecay/src/mcp/server/background_refresh_writer_tests.rs +++ b/crates/tracedecay/src/mcp/server/background_refresh_writer_tests.rs @@ -296,3 +296,94 @@ async fn concurrent_startup_catchups_use_injected_writer_authority() { first.shutdown().await; second.shutdown().await; } + +/// A freshness probe refused by a parked worktree logs the park's cause and +/// remedy as fields; the route message would cut a long cause at its bound. +#[tokio::test] +async fn parked_freshness_probe_logs_cause_and_remedy_as_fields() { + let (cg, _dir, _authority) = init_indexed_repo().await; + let log_dir = tempfile::tempdir().expect("log directory"); + let log_path = log_dir.path().join("refresh.log"); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::WARN) + .with_ansi(false) + .with_writer(Arc::new( + std::fs::File::create(&log_path).expect("log file"), + )) + .finish(); + // This current-thread runtime also polls the spawned refresh task. + let _subscriber = tracing::subscriber::set_default(subscriber); + let cause = format!("publication manifest is corrupt: {}", "x".repeat(600)); + let armed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let refresh_writer: BackgroundRefreshWriter = { + let cause = cause.clone(); + let armed = Arc::clone(&armed); + Arc::new(move |mut request: BackgroundRefreshRequest| { + let cause = cause.clone(); + let armed = armed.load(Ordering::Acquire); + Box::pin(async move { + if !armed { + return Ok(super::hook_writes::BackgroundRefreshOutcome::Admitted(None)); + } + request.freshness_probe_sink = Some(Arc::new(move |_root| { + let cause = cause.clone(); + Box::pin(async move { + super::CodeIndexDemandAdmissionV1::Terminal( + tracedecay_contracts::code_index_freshness::CodeIndexConvergenceParkedV1 { + reason: cause, + blocked_reason: None, + remediation: "run `tracedecay daemon restart`".to_owned(), + parked_at_micros: 1, + observed_passes: 1, + retries_on_wake: false, + }, + ) + }) + })); + super::hook_writes::execute_background_refresh_direct(request).await + }) + }) + }; + let server = McpServer::new_with_context( + McpServerConstructionContext::direct(cg, None) + .with_background_refresh_writer(refresh_writer), + ) + .await; + assert!( + server + .wait_for_startup_catch_up(Duration::from_secs(5)) + .await, + "startup catch-up settles before the probe" + ); + armed.store(true, Ordering::Release); + let snapshot = server.cg_snapshot().await; + server + .background_refresh_running + .store(true, Ordering::Release); + + server.spawn_read_refresh_task(&snapshot); + tokio::time::timeout(Duration::from_secs(5), async { + while server.background_refresh_running.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("refused probe settles"); + server.shutdown().await; + + let log = std::fs::read_to_string(&log_path).expect("read log"); + let line = log + .lines() + .find(|line| line.contains("background read reconciliation was not admitted")) + .unwrap_or_else(|| panic!("probe refusal must be logged: {log}")); + let (_, fields) = line + .split_once("was not admitted ") + .unwrap_or_else(|| panic!("structured fields follow the message: {line}")); + assert_eq!( + fields, + format!( + "reason_code=\"code_index_publication_authority_corrupt\" cause=\"{cause}\" \ + remedy=\"run `tracedecay daemon restart`\" retries_on_wake=false" + ) + ); +} diff --git a/crates/tracedecay/src/mcp/server/freshness_tests.rs b/crates/tracedecay/src/mcp/server/freshness_tests.rs index e767fe750b..b4d556189c 100644 --- a/crates/tracedecay/src/mcp/server/freshness_tests.rs +++ b/crates/tracedecay/src/mcp/server/freshness_tests.rs @@ -326,14 +326,15 @@ fn project_route_error_messages_keep_retry_authority_when_clients_hide_error_dat #[test] fn publication_corruption_keeps_park_facts_typed_at_the_rpc_boundary() { - let error = super::code_index_publication_corrupt(CodeIndexConvergenceParkedV1 { + let error = CodeIndexConvergenceParkedV1 { reason: "source mode is not owner-private".to_owned(), blocked_reason: None, remediation: "restore mode 0600".to_owned(), parked_at_micros: 1, observed_passes: 2, retries_on_wake: true, - }); + } + .publication_authority_corrupt_error(); let response = tool_error_response(serde_json::json!(10), "tracedecay_sync", &error); let data = response .error diff --git a/crates/tracedecay/src/mcp/server/hook_writes.rs b/crates/tracedecay/src/mcp/server/hook_writes.rs index e4c2df5e1c..7ad9a3affc 100644 --- a/crates/tracedecay/src/mcp/server/hook_writes.rs +++ b/crates/tracedecay/src/mcp/server/hook_writes.rs @@ -101,7 +101,7 @@ pub(crate) async fn execute_background_refresh_direct( Ok(BackgroundRefreshOutcome::LinkedWorktreeDisabled) } CodeIndexDemandAdmissionV1::Terminal(parked) => { - Err(super::code_index_publication_corrupt(parked)) + Err(parked.publication_authority_corrupt_error()) } CodeIndexDemandAdmissionV1::Unavailable(cause) => { Err(super::code_index_unavailable_error(cause)) diff --git a/crates/tracedecay/src/mcp/server/lifecycle.rs b/crates/tracedecay/src/mcp/server/lifecycle.rs index b0ea39e664..e5272765a2 100644 --- a/crates/tracedecay/src/mcp/server/lifecycle.rs +++ b/crates/tracedecay/src/mcp/server/lifecycle.rs @@ -2,10 +2,30 @@ //! sync-on-read, branch-drift reopen, and version-update checks. use super::*; +use tracedecay_domain::ApplicationProblemDetailV1; /// Cache duration for version checks (15 minutes). const VERSION_CHECK_INTERVAL: Duration = Duration::from_mins(15); +/// A parked index logs its cause and remedy as fields; the bounded route +/// message would cut the cause. +fn warn_reconcile_not_admitted(error: &TraceDecayError, message: &str) { + match ( + error.project_route_context(), + error.project_route_typed_detail(), + ) { + ( + Some((reason_code, _, _)), + Some(ApplicationProblemDetailV1::Parked { + cause, + remedy, + retries_on_wake, + }), + ) => tracing::warn!(reason_code, cause, remedy, retries_on_wake, "{message}"), + _ => tracing::warn!(error = %error, "{message}"), + } +} + struct ReadRefreshRunningGuard(Arc); impl Drop for ReadRefreshRunningGuard { @@ -278,7 +298,7 @@ impl McpServer { ); } Err(e) => { - tracing::warn!(error = %e, "startup catch-up admission failed"); + warn_reconcile_not_admitted(&e, "startup catch-up admission failed"); self.startup_catch_up.settle(); return; } @@ -470,9 +490,9 @@ impl McpServer { ); } Err(e) => { - tracing::warn!( - error = %e, - "background read reconciliation was not admitted" + warn_reconcile_not_admitted( + &e, + "background read reconciliation was not admitted", ); } } diff --git a/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs b/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs index aa06359c62..9c04422c82 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs @@ -575,8 +575,9 @@ fn settle_graph_tool_result( } /// The graph-tool owner reports handler argument errors as invalid requests, -/// a lock that missed its deadline as its typed detail, and every other -/// refusal under its own reason code. +/// a typed route detail or a lock that missed its deadline as that detail, a +/// route refusal as unavailable under its own reason code, and any other +/// handler failure as an internal execution failure. pub(crate) fn graph_tool_error_problem( error: &TraceDecayError, ) -> tracedecay_contracts::ApplicationProblem { @@ -601,7 +602,15 @@ pub(crate) fn graph_tool_error_problem( detail, .. } => graph_tool_unavailable(reason_code, *retryable, detail), - error => graph_tool_unavailable("graph_tool.failed", false, &error.to_string()), + error => tracedecay_contracts::ApplicationProblem::ExecutionFailed { + classification: tracedecay_contracts::ApplicationExecutionFailureClassV1::Permanent, + diagnostic: tracedecay_contracts::SafeDiagnostic { + code: "graph_tool.failed".to_owned(), + message: safe_diagnostic_message(&error.to_string()), + }, + retry: tracedecay_contracts::RetryDirective::Never, + legal_actions: vec![tracedecay_contracts::LegalAction::ContactAdministrator], + }, } } @@ -656,6 +665,7 @@ mod tests { ResolvedScope, ResultContractRef, }; use tracedecay_daemon_protocol::RequestedOutputFormat; + use tracedecay_domain::errors::TraceDecayError; use tracedecay_domain::{ProjectId, RepositoryId, UtcMicros, WorktreeId}; use tracedecay_mcp::tools::response_trailers::account_tool_result; use tracedecay_tool_catalog::{ApplicationSurfaceOperation, BindingId, SchemaId}; @@ -889,4 +899,62 @@ mod tests { expected ); } + + /// Every handler error the owner maps carries a kind of its own, so the + /// surface renders the whole record as an `isError` result. + #[test] + fn every_graph_tool_handler_error_renders_as_a_kinded_problem() { + let operation = ApplicationSurfaceOperation::Signature; + let binding = BindingId::new("binding.mcp.signature.v1").unwrap(); + for (error, kind, code, retry, legal_actions) in [ + ( + TraceDecayError::Config { + message: "invalid arguments for tracedecay_signature: missing field `symbol`" + .to_owned(), + }, + "invalid_request", + "application.surface.invalid_request", + "never", + json!([]), + ), + ( + TraceDecayError::project_route( + "code-graph-unavailable", + true, + "the verified code graph is not ready", + ), + "unavailable", + "code-graph-unavailable", + "after_delay", + json!(["retry"]), + ), + ( + TraceDecayError::Io(std::io::Error::other("generation file vanished")), + "execution_failed", + "graph_tool.failed", + "never", + json!(["contact_administrator"]), + ), + ] { + let problem = tracedecay_contracts::ApplicationProblemEnvelope::new( + ResultContractRef::new(SchemaId::new("schema.test.graph-problem.v1").unwrap(), 1) + .unwrap(), + RequestId::new("request.mcp.graph-problem").unwrap(), + super::graph_tool_error_problem(&error), + ) + .unwrap(); + let refusal = settle_graph_tool_result(operation, binding.clone(), Err(problem)) + .unwrap() + .expect_err("a handler error is a refusal record"); + let mut rendered = refusal.render(None, &json!({"format": "json"})).unwrap(); + tracedecay_mcp::tool_errors::mark_semantic_tool_error(&mut rendered); + + assert_eq!(rendered.value["isError"], true, "{error}"); + let problem = &rendered.value["problem"]; + assert_eq!(problem["kind"], kind, "{error}"); + assert_eq!(problem["code"], code, "{error}"); + assert_eq!(problem["retry"], retry, "{error}"); + assert_eq!(problem["legal_actions"], legal_actions, "{error}"); + } + } } diff --git a/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs b/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs index 4e6bdb001f..a8e3a05b39 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs @@ -2102,24 +2102,25 @@ async fn admin_sync_reports_terminal_publication_corruption_without_queueing() { .await .expect_err("terminal publication corruption must refuse sync"); - let (reason_code, retryable, detail) = - error.project_route_context().expect("typed project route"); + let wire = tracedecay_mcp::tool_error_response(json!(1), "tracedecay_admin_sync", &error) + .error + .expect("JSON-RPC refusal") + .data; assert_eq!( - reason_code, - tracedecay_contracts::code_index_freshness::CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT - ); - assert!(!retryable, "publication corruption requires reset"); - assert!( - detail.contains("injected sync refusal"), - "sync must report the terminal cause: {detail}" - ); - assert_eq!( - error.project_route_typed_detail(), - Some(&tracedecay_domain::ApplicationProblemDetailV1::Parked { - cause: "the publication authority is corrupt and requires an index reset: injected sync refusal".to_owned(), - remedy: "reset the code-index publication authority".to_owned(), - retries_on_wake: false, - }) + wire, + Some(json!({ + "tool": "tracedecay_admin_sync", + "kind": "unavailable", + "code": "code_index_publication_authority_corrupt", + "reason_code": "code_index_publication_authority_corrupt", + "retryable": false, + "detail": { + "kind": "parked", + "cause": "the publication authority is corrupt and requires an index reset: injected sync refusal", + "remedy": "reset the code-index publication authority", + "retries_on_wake": false, + }, + })) ); cg.close(); } diff --git a/crates/tracedecay/src/mcp/tools/handlers/info/status.rs b/crates/tracedecay/src/mcp/tools/handlers/info/status.rs index 2b2d11d263..f02c7fc7a9 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/info/status.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/info/status.rs @@ -32,7 +32,7 @@ pub(crate) async fn handle_admin_sync( CodeIndexDemandAdmissionV1::Queued => "queued", CodeIndexDemandAdmissionV1::NotApplicable => "not_applicable", CodeIndexDemandAdmissionV1::Terminal(parked) => { - return Err(crate::mcp::server::code_index_publication_corrupt(parked)); + return Err(parked.publication_authority_corrupt_error()); } CodeIndexDemandAdmissionV1::RefusedByPolicy => { return Err(crate::mcp::server::code_index_linked_worktree_disabled()); diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/admin_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/admin_test.rs index 6a3caf5cee..2fcb05e334 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/admin_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/admin_test.rs @@ -272,7 +272,7 @@ async fn project_context_surfaces_registry_read_failure_as_tool_error() { assert_eq!( crate::support::tool_refusal(&response), json!({ - "kind": "unavailable", + "kind": "execution_failed", "code": "graph_tool.failed", "message": "database error: SQLite prepare query failed: no such table: project_aliases (operation: resolve project identity alias)", }) @@ -334,7 +334,7 @@ async fn project_search_surfaces_registry_read_failure_as_tool_error() { assert_eq!( crate::support::tool_refusal(&response), json!({ - "kind": "unavailable", + "kind": "execution_failed", "code": "graph_tool.failed", "message": "database error: SQLite prepare query failed: no such table: project_aliases (operation: search registered code projects)", }) diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_context_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_context_test.rs index eac9e46e64..05cb5fdcad 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_context_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_context_test.rs @@ -334,7 +334,7 @@ async fn project_context_reports_a_broken_registry_read_as_a_tool_error() { assert_eq!( crate::support::tool_refusal(&response), json!({ - "kind": "unavailable", + "kind": "execution_failed", "code": "graph_tool.failed", "message": "database error: SQLite prepare query failed: no such table: project_aliases (operation: resolve project identity alias)", }) diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_list_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_list_test.rs index 6b1b8e3ad8..577df74bdb 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_list_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_list_test.rs @@ -367,7 +367,7 @@ async fn project_list_reports_a_broken_registry_as_a_tool_error() { assert_eq!( crate::support::tool_refusal(&response), json!({ - "kind": "unavailable", + "kind": "execution_failed", "code": "graph_tool.failed", "message": "database error: SQLite prepare query failed: no such table: project_aliases (operation: resolve project identity alias)", })