From fed1efeca7ae542a74f9e7eb7429f44cd7a5f95a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:56:45 +0000 Subject: [PATCH 1/9] chore(deps): update dependency lucide-react to v1.61.0 --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8cf9feb9e..de60f2ab45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,7 +210,7 @@ importers: version: 0.10.1(graphology-types@0.24.8) lucide-react: specifier: ^1.0.0 - version: 1.60.0(@types/react@19.3.0)(react@19.3.1) + version: 1.61.0(@types/react@19.3.0)(react@19.3.1) react: specifier: ^19.0.0 version: 19.3.1 @@ -2258,8 +2258,8 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} - lucide-react@1.60.0: - resolution: {integrity: sha512-UqxOW0FgAfDyb9VmjKb6/BkOzW6yfhf/QNboKtS2NDsscnNRW2CDzlyhbMKBQ1ontmcxuIDqHJ2AVgFR3NI+GA==} + lucide-react@1.61.0: + resolution: {integrity: sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==} peerDependencies: '@types/react': '*' react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -4518,7 +4518,7 @@ snapshots: lru-cache@11.5.3: {} - lucide-react@1.60.0(@types/react@19.3.0)(react@19.3.1): + lucide-react@1.61.0(@types/react@19.3.0)(react@19.3.1): dependencies: react: 19.3.1 optionalDependencies: From e4486f199a670defa011950af46e4574a2917691 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:54:30 -0700 Subject: [PATCH 2/9] test(fixtures): synchronize reads and daemon store ownership --- crates/tracedecay/tests/common/mod.rs | 52 +++++---- .../affected_tests_behavior_test.rs | 102 ++++++++++++++++-- .../daemon_fault_harness_test.rs | 47 +++++++- .../typed_terminal_restart_acceptance.rs | 1 + 4 files changed, 167 insertions(+), 35 deletions(-) diff --git a/crates/tracedecay/tests/common/mod.rs b/crates/tracedecay/tests/common/mod.rs index 827b125218..51fe23fcd5 100644 --- a/crates/tracedecay/tests/common/mod.rs +++ b/crates/tracedecay/tests/common/mod.rs @@ -1201,6 +1201,29 @@ fn daemon_authority_lock_held(profile_root: &Path) -> bool { } } +/// Waits for a stopped daemon's inherited endpoint and authority handles to +/// close before a fixture starts a replacement or opens the store itself. +/// Reaping the leader does not reap descendants that still hold these handles. +pub fn wait_for_stopped_daemon(home: &Path) { + let profile_root = canonical_existing_path(home).join(".tracedecay"); + poll_until( + Instant::now() + PREDECESSOR_DAEMON_VACATE_TIMEOUT, + Duration::from_millis(25), + || { + let live = daemon_is_connectable(home); + #[cfg(unix)] + let live = live || daemon_authority_lock_held(&profile_root); + (!live).then_some(()) + }, + || { + format!( + "a stopped test daemon still owns an endpoint or authority lock in {}", + profile_root.display() + ) + }, + ); +} + fn spawn_tracedecay_daemon_process( home: &Path, binary: &Path, @@ -1237,34 +1260,7 @@ fn spawn_tracedecay_daemon_process( // endpoint go quiet; this wait only covers the kernel's leftover. The // authority lock has the same tail, and `stop_managed_daemon` unlinks the // socket, so only the lock shows such a predecessor's child is still alive. - poll_until( - Instant::now() + PREDECESSOR_DAEMON_VACATE_TIMEOUT, - Duration::from_millis(25), - || { - #[cfg(unix)] - let live = std::os::unix::net::UnixStream::connect(&socket_path).is_ok() - || daemon_authority_lock_held(&profile_root); - #[cfg(not(unix))] - let live = portable_daemon_connectable(); - (!live).then_some(()) - }, - || { - #[cfg(unix)] - { - format!( - "refusing to replace a live test daemon at {} or its authority lock", - socket_path.display() - ) - } - #[cfg(not(unix))] - { - format!( - "refusing to replace a live test daemon recorded at {}", - authority_path.display() - ) - } - }, - ); + wait_for_stopped_daemon(home); let mut command = Command::new(binary); apply_tracedecay_home_env(&mut command, home); diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs index 8101c21542..196d2f43f3 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs @@ -6,6 +6,8 @@ use std::fs; use std::path::Path; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::Duration; use serde_json::{Value, json}; use tracedecay_application::operation_stream::operation_event_authority; @@ -13,6 +15,8 @@ use tracedecay_contracts::request_identity::{GlobalRequestSurface, mint_global_r use tracedecay_contracts::{Deadline, OperationBudgetUsage, OperationReceipt}; use tracedecay_domain::UtcMicros; use tracedecay_mcp::ToolResult; +use tracing_subscriber::layer::{Context, Layer, SubscriberExt}; +use tracing_subscriber::registry::LookupSpan; use url::Url; use crate::support::{ @@ -24,6 +28,60 @@ const GREETING_TEST: &str = "tests::greeting_is_hello_world"; const FAILING_TEST: &str = "greeting_is_goodbye"; const KEPT_TEST: &str = "alpha_kept"; +#[derive(Default)] +struct ReadFlightOverlap { + pause: Mutex>>, + resume: Mutex>>, + leader_entered: tokio::sync::Notify, +} + +struct ObserveReadFlight(Arc); + +impl Layer for ObserveReadFlight +where + S: tracing::Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_enter(&self, id: &tracing::span::Id, ctx: Context<'_, S>) { + match ctx.span(id).unwrap().metadata().name() { + "mcp.dispatch.application" => { + let pause = self.0.pause.lock().unwrap().take(); + if let Some(pause) = pause { + self.0.leader_entered.notify_one(); + pause + .recv_timeout(Duration::from_secs(5)) + .expect("the identical read must join the held leader"); + } + } + "mcp.server.read_coalescing.follower_wait" => { + if let Some(resume) = self.0.resume.lock().unwrap().take() { + resume.send(()).expect("held read leader must still exist"); + } + } + _ => {} + } + } +} + +impl ReadFlightOverlap { + fn install() -> Arc { + // The subscriber must follow dispatch onto other threads without + // observing unrelated tests in the parent process. + assert!(crate::common::in_child_test()); + let overlap = Arc::new(Self::default()); + tracing::subscriber::set_global_default( + tracing_subscriber::registry().with(ObserveReadFlight(Arc::clone(&overlap))), + ) + .unwrap(); + overlap + } + + fn hold_next_read(&self) { + let (resume, pause) = mpsc::channel(); + *self.pause.lock().unwrap() = Some(pause); + *self.resume.lock().unwrap() = Some(resume); + } +} + fn write_affected_fixture(project: &Path) { fs::create_dir_all(project.join("src")).unwrap(); fs::create_dir_all(project.join("tests")).unwrap(); @@ -434,6 +492,14 @@ fn coalescing_counts(stats: &Value) -> (u64, u64) { /// Two identical concurrent reads still enter the identical-read coalescer. #[tokio::test] async fn identical_concurrent_test_runs_each_run_while_identical_reads_still_ride() { + if !crate::common::in_child_test() { + crate::common::rerun_test_in_child( + "mcp_handler_test::affected_tests_behavior_test::identical_concurrent_test_runs_each_run_while_identical_reads_still_ride", + &[], + ); + return; + } + let overlap = ReadFlightOverlap::install(); let fixture = production_composition_fixture_with_sources(write_affected_fixture).await; let server = fixture .harness @@ -466,21 +532,45 @@ async fn identical_concurrent_test_runs_each_run_while_identical_reads_still_rid "test runs never enter the identical-read coalescer" ); - let read = json!({"path": "src", "format": "json"}); + let fixture = Arc::new(fixture); + let leader_fixture = Arc::clone(&fixture); + let runtime = tokio::runtime::Handle::current(); + overlap.hold_next_read(); + // A join alone can run two successive leaders. Hold the dispatcher + // after its claim until the second request is waiting on that flight. + // The tracing barrier parks a blocking thread, never an async worker. + let first_read = tokio::task::spawn_blocking(move || { + runtime.block_on(call_tool( + &leader_fixture, + "tracedecay_files", + json!({"path": "src", "format": "json"}), + )) + }); + tokio::time::timeout(Duration::from_secs(5), overlap.leader_entered.notified()) + .await + .expect("the files read leader must enter application dispatch"); let (first_read, second_read) = tokio::join!( - call_tool(&fixture, "tracedecay_files", read.clone()), - call_tool(&fixture, "tracedecay_files", read.clone()) + first_read, + call_tool( + &fixture, + "tracedecay_files", + json!({"path": "src", "format": "json"}), + ) ); + let first_read = first_read.expect("held files read must finish"); + assert_eq!(parse_tool_json("tracedecay_files", &first_read)["count"], 2); assert_eq!( extract_text(&first_read.value), extract_text(&second_read.value) ); let after_reads = coalescing_counts(&server.server_stats_json().await); assert_eq!( - (after_reads.0 - after_runs.0) + (after_reads.1 - after_runs.1), - 2, - "both identical reads enter the identical-read coalescer" + (after_reads.0 - after_runs.0, after_reads.1 - after_runs.1), + (1, 1), + "one identical read leads and the other shares its in-flight result" ); + let fixture = Arc::try_unwrap(fixture) + .unwrap_or_else(|_| panic!("both read requests must release the fixture")); fixture.harness.shutdown().await; } diff --git a/crates/tracedecay/tests/transport_acceptance_suite/daemon_fault_harness_test.rs b/crates/tracedecay/tests/transport_acceptance_suite/daemon_fault_harness_test.rs index 34e38a0808..f6edc24c5a 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/daemon_fault_harness_test.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/daemon_fault_harness_test.rs @@ -1,9 +1,13 @@ use crate::common; use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(unix)] +use std::sync::mpsc; +#[cfg(unix)] +use std::time::Duration; #[cfg(all(unix, tracedecay_observation_fault_harness, feature = "test-transport"))] -use std::{path::Path, time::Duration}; +use std::path::Path; use rusqlite::Connection; #[cfg(all(unix, tracedecay_observation_fault_harness, feature = "test-transport"))] @@ -392,6 +396,47 @@ else: ); } +/// A stopped leader may leave an authority handle in a child that is still +/// closing. Store fixtures must cross the same ownership boundary as restart. +#[cfg(unix)] +#[test] +fn stopped_daemon_waits_for_authority_release_before_fixture_store_access() { + let home = tempdir_or_panic(); + let profile = home.path().join(".tracedecay"); + std::fs::create_dir_all(&profile).expect("isolated profile"); + let lock = std::fs::File::create( + profile.join(tracedecay_runtime_core::storage::DAEMON_AUTHORITY_LOCK_FILE), + ) + .expect("authority lock file"); + lock.lock().expect("hold the stopped daemon's authority"); + let (started, entering) = mpsc::channel(); + let (finished, completion) = mpsc::channel(); + let waiting_home = home.path().to_path_buf(); + let waiter = std::thread::spawn(move || { + started.send(()).expect("ownership wait observer"); + common::wait_for_stopped_daemon(&waiting_home); + finished.send(()).expect("ownership completion observer"); + }); + entering.recv().expect("ownership wait must start"); + let while_held = completion.recv_timeout(Duration::from_millis(100)); + lock.unlock() + .expect("release the last inherited authority handle"); + let after_release = if while_held.is_err() { + completion.recv_timeout(Duration::from_secs(5)) + } else { + Ok(()) + }; + waiter.join().expect("ownership wait must finish"); + assert!( + matches!(while_held, Err(mpsc::RecvTimeoutError::Timeout)), + "a fixture must not access the store while authority is still held" + ); + after_release.expect("releasing authority must unblock fixture access"); + lock.try_lock() + .expect("fixture may acquire the released store authority"); + lock.unlock().expect("release fixture authority"); +} + #[cfg(all(unix, tracedecay_observation_fault_harness, feature = "test-transport"))] async fn assert_daemon_crash_stage( barrier_stage: &str, diff --git a/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance.rs b/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance.rs index 9654af11b7..37e56c5f35 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance.rs @@ -106,6 +106,7 @@ fn initialize_project(home: &Path, project: &Path, marker: &str) { /// with an extra, unexpected table, so every later open sees a shape that is /// not the exact final shape this binary creates. fn make_store_reset_required(home: &Path, project: &Path) { + common::wait_for_stopped_daemon(home); let data_root = tracedecay_runtime_core::storage::profile_sharded_data_root( &home.join(".tracedecay"), &tracedecay_runtime_core::storage::default_profile_project_id(project), From 98f4c720e057bffa742f20680da20773a8b031db Mon Sep 17 00:00:00 2001 From: Zack Jackson Date: Wed, 21 Oct 2026 07:33:02 -0700 Subject: [PATCH 3/9] fix(mcp): coalesce reads across request identities --- .../src/server/read_coalescing.rs | 17 ++++++++++++----- .../affected_tests_behavior_test.rs | 1 - 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/tracedecay-mcp/src/server/read_coalescing.rs b/crates/tracedecay-mcp/src/server/read_coalescing.rs index 73befb8e2e..1ddf1754a9 100644 --- a/crates/tracedecay-mcp/src/server/read_coalescing.rs +++ b/crates/tracedecay-mcp/src/server/read_coalescing.rs @@ -233,7 +233,14 @@ fn read_flight_key( arguments: &Value, scope_prefix: Option<&str>, ) -> ReadFlightKey { - let arguments = serde_json::to_vec(arguments).unwrap_or_default(); + // Routing assigns correlation identities per connection/request. They + // control cancellation, not the read's inputs; including them prevents + // identical requests from ever sharing a flight across connections. + let mut arguments = arguments.clone(); + if let Some(arguments) = arguments.as_object_mut() { + arguments.remove("__mcp_request_id"); + } + let arguments = serde_json::to_vec(&arguments).unwrap_or_default(); let scope_present: &[u8] = if scope_prefix.is_some() { b"1" } else { b"0" }; let scope = scope_prefix.unwrap_or_default(); ReadFlightKey(canonical_framed_sha256_bytes( @@ -259,8 +266,8 @@ mod tests { let coalescer = IdenticalReadCoalescer::default(); let leader = match coalescer.claim( "graph-main", - "tracedecay_search", - &json!({"query": "DaemonEngine"}), + "tracedecay_files", + &json!({"path": "src", "__mcp_request_id": "connection-a:1"}), None, ) { ReadFlightClaim::Leader(leader) => leader, @@ -268,8 +275,8 @@ mod tests { }; let follower = match coalescer.claim( "graph-main", - "tracedecay_search", - &json!({"query": "DaemonEngine"}), + "tracedecay_files", + &json!({"path": "src", "__mcp_request_id": "connection-b:1"}), None, ) { ReadFlightClaim::Follower(follower) => follower, diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs index 196d2f43f3..e3c8ba23e4 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs @@ -538,7 +538,6 @@ async fn identical_concurrent_test_runs_each_run_while_identical_reads_still_rid overlap.hold_next_read(); // A join alone can run two successive leaders. Hold the dispatcher // after its claim until the second request is waiting on that flight. - // The tracing barrier parks a blocking thread, never an async worker. let first_read = tokio::task::spawn_blocking(move || { runtime.block_on(call_tool( &leader_fixture, From 66cd61661c72350d3b98af99c4d1e7da3dbe2df0 Mon Sep 17 00:00:00 2001 From: Zack Jackson Date: Wed, 21 Oct 2026 07:33:03 -0700 Subject: [PATCH 4/9] test(cli): align daemon authority refusal assertion --- .../tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs index c7c0ec57bd..8d52c526d8 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs @@ -1566,8 +1566,8 @@ fn a_replacement_daemon_waits_for_its_predecessors_authority_lock() { refusal.downcast_ref::().map(String::as_str), Some( format!( - "refusing to replace a live test daemon at {} or its authority lock", - common::daemon_socket_path(&home_path).display() + "a stopped test daemon still owns an endpoint or authority lock in {}", + home_path.join(".tracedecay").display() ) .as_str() ) From 5931062d31064f668909e8bf799993d840c8ca28 Mon Sep 17 00:00:00 2001 From: Zack Jackson Date: Wed, 21 Oct 2026 09:54:43 -0700 Subject: [PATCH 5/9] fix(mcp): preserve per-call cancellation outside graph reads --- .../src/server/read_coalescing.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/tracedecay-mcp/src/server/read_coalescing.rs b/crates/tracedecay-mcp/src/server/read_coalescing.rs index 1ddf1754a9..b1e72117e2 100644 --- a/crates/tracedecay-mcp/src/server/read_coalescing.rs +++ b/crates/tracedecay-mcp/src/server/read_coalescing.rs @@ -199,11 +199,12 @@ pub fn tool_allows_identical_read_coalescing( tool_name: &str, tool_is_read_only: impl FnOnce(&str) -> bool, ) -> bool { - // Every owner side effect runs per call; identical calls never share one. - if ApplicationSurfaceOperation::from_tool_name(tool_name) - .and_then(ApplicationSurfaceOperation::owner_side_effect) - .is_some() - { + // The key identifies graph data, not an application invocation's authority + // or cancellation. Every non-graph call must reach its own executor. + let Some(operation) = ApplicationSurfaceOperation::from_tool_name(tool_name) else { + return false; + }; + if !operation.is_graph_tool() || operation.owner_side_effect().is_some() { return false; } if matches!( @@ -428,6 +429,10 @@ mod tests { "tracedecay_dependency_depth", "tracedecay_health", "tracedecay_dsm", + "tracedecay_configuration_get", + "tracedecay_project_list", + "tracedecay_fact_store_search", + "tracedecay_source_outline", ] { assert!( !tool_allows_identical_read_coalescing(controlled_read, |_| true), @@ -435,7 +440,7 @@ mod tests { ); } assert!(tool_allows_identical_read_coalescing( - "tracedecay_source_outline", + "tracedecay_files", |_| true, )); assert!(!tool_allows_identical_read_coalescing( From 40f0ac9dd03f1c381023485ffb5a710b41a22b40 Mon Sep 17 00:00:00 2001 From: Zack Jackson Date: Wed, 21 Oct 2026 09:54:48 -0700 Subject: [PATCH 6/9] test(index): settle worker before injecting terminal park --- .../src/code_index_scheduler/tests/branch_publication_tests.rs | 2 ++ 1 file changed, 2 insertions(+) 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 11982cb6d1..0a60cac0fb 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 @@ -360,6 +360,8 @@ async fn shutdown_joins_a_worker_suppressing_a_terminal_publication_park() { let fixture = GitFixture::new(ALPHA_LIB_V1); let store = TempDir::new().expect("store root"); let registry = mounted_registry(&fixture, &store).await; + // Seating precedes the worker tail that can replace its park state. + super::settle_text_projection(®istry, fixture.path()).await; assert!( registry .plant_terminal_publication_authority_park_for_test( From 39a62c8ae3dd25a1d5c525b9575d1a884410f20b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:48:01 -0700 Subject: [PATCH 7/9] fix(mcp): preserve per-call effects and refusal identity --- .../src/server/read_coalescing.rs | 19 +++- .../affected_tests_behavior_test.rs | 106 ++++++++++++++++-- .../skill_view_behavior_test.rs | 62 ++++++++++ 3 files changed, 174 insertions(+), 13 deletions(-) diff --git a/crates/tracedecay-mcp/src/server/read_coalescing.rs b/crates/tracedecay-mcp/src/server/read_coalescing.rs index b1e72117e2..1fa5ccbba0 100644 --- a/crates/tracedecay-mcp/src/server/read_coalescing.rs +++ b/crates/tracedecay-mcp/src/server/read_coalescing.rs @@ -11,7 +11,7 @@ use serde_json::Value; use tracedecay_domain::canonical_text::canonical_framed_sha256_bytes; use tracedecay_tool_catalog::ApplicationSurfaceOperation; -use crate::ToolResult; +use crate::{ToolResult, tool_result_has_semantic_error}; use tracedecay_runtime_core::weak_registry::WeakRegistry; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -152,6 +152,11 @@ impl ReadFlight { impl ReadFlightLeader { pub fn complete(mut self, result: ToolResult) -> ToolResult { + // Refusals carry the caller's identity and invocation controls. Dropping + // this unfinished flight wakes followers to run their own dispatch. + if tool_result_has_semantic_error(&result) { + return result; + } self.finished = true; self.remove_registration(); // Deregistration closes this flight to new followers (a later @@ -207,6 +212,11 @@ pub fn tool_allows_identical_read_coalescing( if !operation.is_graph_tool() || operation.owner_side_effect().is_some() { return false; } + // A view records usage under this caller's request identity before reading + // the resulting summary, so every caller must execute the handler. + if operation == ApplicationSurfaceOperation::SkillView { + return false; + } if matches!( tool_name, "tracedecay_search" @@ -235,8 +245,8 @@ fn read_flight_key( scope_prefix: Option<&str>, ) -> ReadFlightKey { // Routing assigns correlation identities per connection/request. They - // control cancellation, not the read's inputs; including them prevents - // identical requests from ever sharing a flight across connections. + // control cancellation, not eligible reads' inputs. Only successful reads + // are shared; a refusal makes every caller dispatch with its own identity. let mut arguments = arguments.clone(); if let Some(arguments) = arguments.as_object_mut() { arguments.remove("__mcp_request_id"); @@ -433,10 +443,11 @@ mod tests { "tracedecay_project_list", "tracedecay_fact_store_search", "tracedecay_source_outline", + "tracedecay_skill_view", ] { assert!( !tool_allows_identical_read_coalescing(controlled_read, |_| true), - "{controlled_read} has caller-specific cancellation and deadline controls" + "{controlled_read} must execute with each caller's own identity and controls" ); } assert!(tool_allows_identical_read_coalescing( diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs index e3c8ba23e4..5df6ca96fe 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; use serde_json::{Value, json}; +use tracedecay::mcp::McpServer; use tracedecay_application::operation_stream::operation_event_authority; use tracedecay_contracts::request_identity::{GlobalRequestSurface, mint_global_request_id}; use tracedecay_contracts::{Deadline, OperationBudgetUsage, OperationReceipt}; @@ -19,6 +20,9 @@ use tracing_subscriber::layer::{Context, Layer, SubscriberExt}; use tracing_subscriber::registry::LookupSpan; use url::Url; +use crate::mcp_server_test::support::{ + jsonrpc_request, response_with_id, run_client_connection_with_messages, +}; use crate::support::{ ProductionCompositionFixture, call_tool_when_ready, extract_text, peer_production_composition, production_composition_fixture_with_sources, wait_for_current_graph, @@ -29,7 +33,7 @@ const FAILING_TEST: &str = "greeting_is_goodbye"; const KEPT_TEST: &str = "alpha_kept"; #[derive(Default)] -struct ReadFlightOverlap { +pub(super) struct ReadFlightOverlap { pause: Mutex>>, resume: Mutex>>, leader_entered: tokio::sync::Notify, @@ -50,12 +54,12 @@ where pause .recv_timeout(Duration::from_secs(5)) .expect("the identical read must join the held leader"); + } else { + self.0.release(); } } "mcp.server.read_coalescing.follower_wait" => { - if let Some(resume) = self.0.resume.lock().unwrap().take() { - resume.send(()).expect("held read leader must still exist"); - } + self.0.release(); } _ => {} } @@ -63,7 +67,7 @@ where } impl ReadFlightOverlap { - fn install() -> Arc { + pub(super) fn install() -> Arc { // The subscriber must follow dispatch onto other threads without // observing unrelated tests in the parent process. assert!(crate::common::in_child_test()); @@ -75,11 +79,23 @@ impl ReadFlightOverlap { overlap } - fn hold_next_read(&self) { + pub(super) fn hold_next_read(&self) { let (resume, pause) = mpsc::channel(); *self.pause.lock().unwrap() = Some(pause); *self.resume.lock().unwrap() = Some(resume); } + + pub(super) async fn wait_for_leader(&self) { + tokio::time::timeout(Duration::from_secs(5), self.leader_entered.notified()) + .await + .expect("the held read must enter application dispatch"); + } + + fn release(&self) { + if let Some(resume) = self.resume.lock().unwrap().take() { + resume.send(()).expect("held read leader must still exist"); + } + } } fn write_affected_fixture(project: &Path) { @@ -143,6 +159,21 @@ async fn call_tool( ) } +async fn refused_files_read(server: &Arc, id: i64) -> Value { + let request = jsonrpc_request( + json!(id), + "tools/call", + json!({ + "name": "tracedecay_files", + // The schema accepts this string; the owner refuses its invalid + // glob as a semantic result after dispatch, not an adapter error. + "arguments": {"pattern": "[", "format": "json"}, + }), + ); + let responses = run_client_connection_with_messages(Arc::clone(server), vec![request]).await; + response_with_id(&responses, json!(id)) +} + fn parse_tool_json(tool_name: &str, result: &ToolResult) -> Value { let text = extract_text(&result.value); serde_json::from_str(text) @@ -545,9 +576,7 @@ async fn identical_concurrent_test_runs_each_run_while_identical_reads_still_rid json!({"path": "src", "format": "json"}), )) }); - tokio::time::timeout(Duration::from_secs(5), overlap.leader_entered.notified()) - .await - .expect("the files read leader must enter application dispatch"); + overlap.wait_for_leader().await; let (first_read, second_read) = tokio::join!( first_read, call_tool( @@ -573,6 +602,65 @@ async fn identical_concurrent_test_runs_each_run_while_identical_reads_still_rid fixture.harness.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn concurrent_read_refusals_keep_each_callers_request_identity() { + if !crate::common::in_child_test() { + crate::common::rerun_test_in_child( + "mcp_handler_test::affected_tests_behavior_test::concurrent_read_refusals_keep_each_callers_request_identity", + &[], + ); + return; + } + let overlap = ReadFlightOverlap::install(); + let fixture = production_composition_fixture_with_sources(write_affected_fixture).await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production files server"); + wait_for_current_graph(&server).await; + let before = coalescing_counts(&server.server_stats_json().await); + + overlap.hold_next_read(); + let leader_server = Arc::clone(&server); + let first = tokio::spawn(async move { refused_files_read(&leader_server, 701).await }); + overlap.wait_for_leader().await; + let (first, second) = tokio::join!(first, refused_files_read(&server, 702)); + let first = first.expect("held files read must finish"); + + for (response, id) in [(&first, 701), (&second, 702)] { + assert_eq!(response["id"], id); + assert!(response["error"].is_null(), "{response}"); + let result = &response["result"]; + assert_eq!(result["isError"], true, "{response}"); + let problem = &result["structuredContent"]["problem"]; + assert_eq!(problem["kind"], "invalid_request", "{response}"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!( + problem["message"], + "invalid file glob '[': Pattern syntax error near position 0: invalid range pattern", + "{response}" + ); + let request_id = problem["request_id"].as_str().expect("refusal request id"); + assert!(request_id.starts_with("request.mcp."), "{response}"); + assert_eq!(problem["trace_id"], request_id, "{response}"); + let rendered: Value = serde_json::from_str(&extract_text(result)).expect("JSON refusal"); + assert_eq!(rendered["request_id"], request_id, "{response}"); + assert_eq!(rendered["problem"], *problem, "{response}"); + } + assert_ne!( + first["result"]["structuredContent"]["problem"]["request_id"], + second["result"]["structuredContent"]["problem"]["request_id"], + "an overlapping read must render its own refusal after the leader refuses" + ); + let after = coalescing_counts(&server.server_stats_json().await); + assert_eq!( + (after.0 - before.0, after.1 - before.1), + (1, 1), + "the second read must have joined the held flight before dispatching its refusal" + ); + fixture.harness.shutdown().await; +} + /// The operation-event authority is process-global, so every daemon /// composition in this test process shares it. A peer composition shutting /// down (the production `DaemonInvocationState::shutdown` path) between a diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skill_view_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skill_view_behavior_test.rs index 164272ba98..10751a1efa 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skill_view_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skill_view_behavior_test.rs @@ -20,6 +20,8 @@ use crate::mcp_server_test::support::{ use crate::support::{TestTraceDecay, open_active_project_scoped_runtime, refusal_problem}; use tracedecay_runtime_core::path_safety::plain_host_path; +use super::affected_tests_behavior_test::ReadFlightOverlap; + const PROBE_ID: &str = "probe-skill"; const OTHER_ID: &str = "other-skill"; const PROBE_BODY: &str = "Read the checklist, then stop."; @@ -370,6 +372,66 @@ async fn skill_view_denies_missing_id_and_unknown_skill() { assert_skill_view_refusal(&unknown, "managed skill 'no-such-skill' not found"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn concurrent_skill_views_each_record_usage_for_their_request() { + if !crate::common::in_child_test() { + crate::common::rerun_test_in_child( + "mcp_handler_test::skill_view_behavior_test::concurrent_skill_views_each_record_usage_for_their_request", + &[], + ); + return; + } + let overlap = ReadFlightOverlap::install(); + let fixture = open_skill_view_server().await; + create_managed_skill(&fixture.profile_root, probe_draft()) + .await + .expect("probe skill"); + let before = fixture.server.server_stats_json().await; + + overlap.hold_next_read(); + let leader_server = Arc::clone(&fixture.server); + let first = tokio::spawn(async move { + call_skill_view( + &leader_server, + 701, + json!({"id": PROBE_ID, "format": "json"}), + ) + .await + }); + overlap.wait_for_leader().await; + let (first, second) = tokio::join!( + first, + call_skill_view( + &fixture.server, + 702, + json!({"id": PROBE_ID, "format": "json"}), + ) + ); + let first = first.expect("held skill view must finish"); + for (response, id) in [(&first, 701), (&second, 702)] { + assert_eq!(response["id"], id); + let viewed = successful_json(response); + assert_eq!(viewed["status"], "ok", "{response}"); + assert_eq!(viewed["skill"]["metadata"]["id"], PROBE_ID); + } + let usage = load_skill_usage_record(&fixture.profile_root, PROBE_ID) + .await + .expect("probe usage ledger") + .expect("both views must persist usage"); + assert_eq!(usage.view_count, 2, "each caller records its own view"); + assert_eq!(usage.use_count, 0); + assert_eq!( + usage.imported_analytics_events.len(), + 2, + "each request retains its own analytics import identity" + ); + let after = fixture.server.server_stats_json().await; + assert_eq!( + after["identical_read_coalescing"], before["identical_read_coalescing"], + "skill views execute their per-call usage writes without joining a read flight" + ); +} + fn assert_skill_view_refusal(response: &Value, message: &str) { assert!( response.get("error").is_none() || response["error"].is_null(), From 750ec60590458f7da535eb3b472345a8e17b65d9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 21 Oct 2026 13:42:56 -0700 Subject: [PATCH 8/9] fix(mcp): remove needless borrow in refusal identity test --- .../mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs index 5df6ca96fe..67292ac6d9 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs @@ -643,7 +643,7 @@ async fn concurrent_read_refusals_keep_each_callers_request_identity() { let request_id = problem["request_id"].as_str().expect("refusal request id"); assert!(request_id.starts_with("request.mcp."), "{response}"); assert_eq!(problem["trace_id"], request_id, "{response}"); - let rendered: Value = serde_json::from_str(&extract_text(result)).expect("JSON refusal"); + let rendered: Value = serde_json::from_str(extract_text(result)).expect("JSON refusal"); assert_eq!(rendered["request_id"], request_id, "{response}"); assert_eq!(rendered["problem"], *problem, "{response}"); } From 093ee7474a5b11fd21d1004417066e96e1314bd0 Mon Sep 17 00:00:00 2001 From: Zack Jackson Date: Wed, 21 Oct 2026 15:27:53 -0700 Subject: [PATCH 9/9] test(cli): follow profile authority in host daemon journeys --- .../sweep_outcomes.rs | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/tracedecay-cli/tests/host_journeys_suite/host_lifecycle_cli_acceptance/sweep_outcomes.rs b/crates/tracedecay-cli/tests/host_journeys_suite/host_lifecycle_cli_acceptance/sweep_outcomes.rs index e447a37183..43d0713c74 100644 --- a/crates/tracedecay-cli/tests/host_journeys_suite/host_lifecycle_cli_acceptance/sweep_outcomes.rs +++ b/crates/tracedecay-cli/tests/host_journeys_suite/host_lifecycle_cli_acceptance/sweep_outcomes.rs @@ -7,6 +7,12 @@ use std::process::Output; use tracedecay_agent_hosts::agents::host_bundle::{HostComponentV1, HostKindV1}; #[cfg(unix)] +use tracedecay_daemon_identity::current_daemon_connection; +#[cfg(unix)] +use tracedecay_daemon_protocol::DaemonEndpoint; +#[cfg(target_os = "linux")] +use tracedecay_daemon_protocol::unix_socket_path_within_limit; +#[cfg(unix)] use tracedecay_runtime_core::test_executable::write_executable_script; #[cfg(unix)] @@ -387,13 +393,21 @@ impl ProfileDaemon { .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); let daemon = Self(command.spawn().unwrap()); - let socket = cli.profile.join("daemon.sock"); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); - while std::os::unix::net::UnixStream::connect(&socket).is_err() { + // Deep worker paths can move the socket outside the profile. Follow + // the same profile-owned authority that production clients discover. + while !current_daemon_connection(&cli.profile).is_ok_and(|connection| { + match connection.endpoint() { + DaemonEndpoint::Unix(socket) => { + std::os::unix::net::UnixStream::connect(socket).is_ok() + } + DaemonEndpoint::Loopback(address) => std::net::TcpStream::connect(address).is_ok(), + } + }) { assert!( std::time::Instant::now() < deadline, - "the profile daemon never listened at {}", - socket.display() + "the registered daemon never listened for profile {}", + cli.profile.display() ); std::thread::sleep(std::time::Duration::from_millis(25)); } @@ -864,7 +878,12 @@ fn doctor_observes_copilots_vscode_settings_without_failing_on_them() { #[cfg(target_os = "linux")] #[test] fn doctor_warns_on_detected_hosts_without_a_tracedecay_integration() { - let cli = IsolatedCli::new(); + let mut cli = IsolatedCli::new(); + cli.profile = cli.home.path().join("profile".repeat(20)); + assert!( + !unix_socket_path_within_limit(&cli.profile.join("daemon.sock")), + "this journey must exercise production's short endpoint for a deep profile" + ); install_cline(&cli); fs::create_dir_all(cli.home.path().join(".config/zed")).unwrap(); fs::create_dir_all(cli.home.path().join(".gemini/antigravity")).unwrap();