From b80dd58dfe560c2091346dd6f2140a174c531614 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 23:11:59 +0000 Subject: [PATCH 1/5] fix(code-index): seat text through retryable graph activation A retryable graph activation used to erase the prepared serving candidate, and an unfinished clone-fingerprint successor withheld the same seat after exact and lexical owners were ready. Keep the candidate in both cases so search can move off the predecessor while graph retries. Co-authored-by: Zack Jackson --- .../src/code_index_scheduler/registry.rs | 38 ++++++++++++++++ .../code_index_scheduler/registry/mount.rs | 26 ++++++++++- .../registry/seat_swap_tests.rs | 45 +++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/seat_swap_tests.rs diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs index fc9c8db791..1b5fdc207a 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs @@ -56,6 +56,8 @@ mod query_authority; mod reconcile_failure_isolation_tests; mod scope_identity; #[cfg(test)] +mod seat_swap_tests; +#[cfg(test)] mod serving_readiness_tests; mod serving_reads; @@ -352,6 +354,42 @@ impl ServingSwapOutcomeV1 { } } +/// The prepared generation id after a graph-activation failure. +/// +/// Retryable activation used to replace the prepared triple with +/// `Ok((Err, None, None))`, so the swap never ran and search kept the +/// predecessor for the whole backoff. Both retryable and terminal failures +/// now leave the sealed text generation in place; only graph readiness +/// retries or becomes unavailable. +pub(super) fn serving_generation_after_activation_failure<'a>( + prepared_generation: Option<&'a str>, + retryable: bool, + repeated_conflict: bool, +) -> Option<&'a str> { + if activation_failure_keeps_serving_candidate(retryable, repeated_conflict) { + prepared_generation + } else { + None + } +} + +fn activation_failure_keeps_serving_candidate(retryable: bool, repeated_conflict: bool) -> bool { + // `retryable && !repeated_conflict` used to wipe the candidate. Terminal + // failures already kept it. Both now keep it; the flags stay so a later + // change cannot drop only the retryable arm without this predicate. + let _ = (retryable, repeated_conflict); + true +} + +/// An unfinished text projection withholds the serving seat only when exact +/// or lexical owners are still missing. +/// +/// A clone-fingerprint successor keeps `text_projection_needs_work` after +/// those owners are ready. That is not `published_text_owner_unfinished`. +pub(super) fn text_projection_unfinished_withholds_seat(exact_and_lexical_ready: bool) -> bool { + !exact_and_lexical_ready +} + #[cfg(any(test, feature = "test-helpers"))] struct ColdMountFinalCommitGateV1 { project_root: PathBuf, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs index 3429b111e1..4bdf2be067 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs @@ -1638,6 +1638,11 @@ impl CodeIndexSchedulerRegistryV1 { // A conflict verdict identical to the previous // attempt's for this same generation is deterministic // and falls through to the terminal arm instead. + // + // The prepared text candidate stays. Wiping it to + // `Ok((Err, None, None))` skipped the serving swap, + // so search kept the predecessor while graph backoff + // ran. if error.is_retryable_activation() && !repeated_conflict { last_seat_conflict = error .activation_conflict_context() @@ -1654,7 +1659,7 @@ impl CodeIndexSchedulerRegistryV1 { retry_delay_micros = retry_delay.as_micros() as u64, error = %error, "graph activation failed retryably; the sealed generation \ - stays unseated until the scheduled retry" + still seats and the next pass retries native graph" ); hotpath::gauge!("daemon.code_index.graph_seat.retry_total") .inc(1_u64); @@ -1668,7 +1673,12 @@ impl CodeIndexSchedulerRegistryV1 { // The scheduled retry is the seat attempt, so it // must not be turned away as already attempted. graph_seat_attempted = None; - result = Ok((Err(error), None, None)); + if !super::activation_failure_keeps_serving_candidate( + error.is_retryable_activation(), + repeated_conflict, + ) { + result = Ok((Err(error), None, None)); + } } else { next_seat_attempt_at = None; seat_retry_backoff = ACTIVATION_RETRY_BACKOFF_FLOOR; @@ -1699,6 +1709,18 @@ impl CodeIndexSchedulerRegistryV1 { // and serving-swap boundary. Graph work above ran only when // the outcome was ready. if let Some(outcome) = published_text_projection_outcome.take() { + // A clone-fingerprint successor is still `Unfinished` work + // after exact and lexical owners are ready. That must not + // clear the prepared generation the way a missing owner does. + let owners_ready = exact_and_lexical_ready_for_graph(graph_text.as_ref()); + let outcome = match outcome { + PublishedTextProjectionOutcomeV1::Unfinished + if !super::text_projection_unfinished_withholds_seat(owners_ready) => + { + PublishedTextProjectionOutcomeV1::Finished + } + other => other, + }; match outcome { PublishedTextProjectionOutcomeV1::Finished => { // The seat needs only the ready exact/lexical diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/seat_swap_tests.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/seat_swap_tests.rs new file mode 100644 index 0000000000..150a741eac --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/seat_swap_tests.rs @@ -0,0 +1,45 @@ +use crate::code_index_scheduler::CodeIndexSchedulerErrorV1; + +use super::ServingSwapOutcomeV1; + +/// A retryable native-graph failure must not drop the generation search will +/// serve. The swap installs that same id; graph activation retries beside it. +#[test] +fn retryable_activation_keeps_the_serving_generation_matched() { + let error = CodeIndexSchedulerErrorV1::GraphActivation( + "graph runtime unavailable during activation".to_owned(), + ); + assert!( + error.is_retryable_activation(), + "GraphActivation is the retryable class that used to erase the seat candidate" + ); + let prepared = Some("generation.head"); + let seated = super::serving_generation_after_activation_failure( + prepared, + error.is_retryable_activation(), + false, + ); + assert_eq!( + seated, prepared, + "retryable graph activation must leave the prepared generation on the seat" + ); + let outcome = ServingSwapOutcomeV1::decide(true, true, seated.is_some()); + assert!( + outcome.installs(), + "the serving swap still writes the slot when the candidate survives: {outcome:?}" + ); +} + +/// Clone-fingerprint backfill is still unfinished after exact and lexical +/// owners are ready. That successor is not `published_text_owner_unfinished`. +#[test] +fn unfinished_clone_fingerprint_successor_is_not_text_projection_unfinished() { + assert!( + !super::text_projection_unfinished_withholds_seat(true), + "ready exact and lexical owners must still seat while the clone successor runs" + ); + assert!( + super::text_projection_unfinished_withholds_seat(false), + "missing exact or lexical owners still withhold the seat" + ); +} From 681defca04f32eeff8a368d51c964ce89ce5a51d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 00:46:05 +0000 Subject: [PATCH 2/5] test(transport): read rebuild readiness from an untruncated page The graph-rebuild receipt timed out for 90s against a generation that was already current: status reported `current` on the expected revision with the advertised generation matching what search served, and the seat never moved for the whole wait. `limit: 3` still rendered 18084 characters against the 15000-character response frame, so MCP replaced the body with a retrieval handle and moved `results` and `code_generation` inside `preview`. Every predicate read them as absent and the wait spun to its deadline. One candidate carries several KiB of ranking provenance, so the page has to be smaller than a guess at how many results fit. Ask for one result, and refuse a truncated envelope outright rather than reading it as a warming generation, so the next frame overflow reports itself instead of presenting as a deadline. Pace both waits as well: each `tracedecay_status` call runs the census ready-probe, a freshness read, and branch diagnostics, and a `yield_now` spin re-entered that path thousands of times a second on the runtime running the reconcile it waits for. Co-authored-by: Zack Jackson --- .../graph_rebuild_status_test.rs | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs b/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs index 5b02449a74..6f52e11104 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs @@ -25,6 +25,15 @@ use tracedecay_mcp::JsonRpcResponse; const RECEIPT_TIMEOUT: Duration = Duration::from_secs(90); +/// How often the waits below re-ask the public MCP surface. +/// +/// Each `tracedecay_status` call runs the generation census ready-probe, a +/// scheduler freshness read, and branch diagnostics — Git opens and +/// blocking-pool work on the runtime that is also running the reconcile these +/// waits are waiting for. A `yield_now` spin re-entered that path thousands of +/// times a second, so the observer competed with the publication it observes. +const READINESS_POLL_INTERVAL: Duration = Duration::from_millis(25); + fn git(project: &Path, args: &[&str]) { let output = Command::new("git") .args(["-c", "core.hooksPath=.git/no-hooks"]) @@ -116,16 +125,26 @@ async fn search( project: &Path, query: &str, ) -> Value { - // Keep the page tiny: a generation-scale refresh batch otherwise returns - // multi-dozen-KiB candidate bodies that MCP truncates into a handle, and - // the wait helpers never see top-level `results` / `code_generation`. - tool( + // One ranked candidate is all these journeys read, and the frame budget is + // why the page has to stay that small: every candidate carries several KiB + // of ranking provenance, so a three-result page rendered 18 084 characters + // against the 15 000-character response frame. + let payload = tool( harness, project, "tracedecay_search", - json!({"query": query, "limit": 3, "format": "json"}), + json!({"query": query, "limit": 1, "format": "json"}), ) - .await + .await; + // A truncated envelope moves `results` and `code_generation` inside + // `preview`, where every predicate below reads them as absent. That is a + // malformed observation, not a warming generation: the waits below would + // spin to their deadline against a generation that is already current. + assert!( + payload.get("truncated").is_none(), + "search exceeded the MCP response frame and was replaced by a retrieval handle: {payload}" + ); + payload } fn result_paths(search: &Value) -> Vec<&str> { @@ -168,7 +187,7 @@ async fn wait_for_current_generation( return current_generation; } } - tokio::task::yield_now().await; + tokio::time::sleep(READINESS_POLL_INTERVAL).await; } }) .await @@ -219,7 +238,7 @@ async fn wait_for_background_refresh( } return; } - tokio::task::yield_now().await; + tokio::time::sleep(READINESS_POLL_INTERVAL).await; } }) .await From e4290b05e8b7ccfeaa463011af7ff1a69fb4ae9f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 00:46:14 +0000 Subject: [PATCH 3/5] perf(code-index): sample git metadata through retained topology `GitMetadataFingerprintV1::capture` is the tier-1 staleness signal sampled on every query admission, and its own contract calls that cost fixed and cheap. It was neither: resolving the git-dir and common-dir through a fresh `gix::open` cost 72.7us of the 75.2us per capture, and runtime-core already owns a revalidating topology memo that answers the same question in 2.0us. Search runs two to three captures per call. Measured on a one-ref fixture repository, perf profile, 2000 warm iterations: capture 75.2us -> 10.0us. The memo is asked only for a checkout carrying `/.git`, which is both where an open at exactly this root resolves through and where a discovery started at this root stops, so it returns the same two paths. A bare control directory or a path that is not a checkout root still opens directly, because discovery would walk past it to an ancestor whose git metadata does not describe this project. The memo canonicalizes both paths; the fingerprint samples file metadata and contents, so its value and its persisted signature are unchanged. Co-authored-by: Zack Jackson --- .../src/code_index_scheduler/identity.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/identity.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/identity.rs index cf27b8f086..588c4dcbf3 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/identity.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/identity.rs @@ -269,7 +269,23 @@ fn refs_heads_signature(dir: &Path) -> Option { /// Resolve the git-dir (worktree-local) and common-dir (repository-shared) /// paths, falling back to `/.git` when gix cannot open the checkout so a /// non-repository path still yields a stable, if empty, fingerprint. +/// +/// These two paths are structural, but the fingerprint above is sampled on +/// every query admission, and re-deriving them through a fresh repository open +/// was 97% of its cost — 73 µs of 75 µs per capture, against 2 µs for the +/// retained topology this now asks first. A checkout that carries `/.git` +/// is one an open at exactly this root resolves through, which is also where a +/// discovery started at this root stops, so the retained answer is the same +/// answer. Anything else — a bare repository's control directory, a path that +/// is not a checkout root — still opens directly, because discovery would walk +/// past it to an ancestor whose git metadata does not describe this project. fn git_metadata_dirs(project_root: &Path) -> (PathBuf, PathBuf) { + if project_root.join(".git").exists() + && let Ok(topology) = + tracedecay_runtime_core::git_repository::repository_topology(project_root) + { + return (topology.git_dir.clone(), topology.common_dir.clone()); + } if let Ok(repository) = gix::open(project_root) { let git_dir = repository.git_dir().to_path_buf(); let common_dir = { From 7da32a15ae697aa9a6bb9cbfb4795a06a8e8d2ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 00:46:22 +0000 Subject: [PATCH 4/5] perf(daemon): wake the composition code-index wait on seat changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production composition's publication wait re-ran its readiness probe every 10ms for up to 20 seconds. That probe canonicalizes the root, takes the scheduler registry's mounted mutex several times, offloads a Git-metadata freshness capture to the blocking pool, and emits a decline event — spent on the same cores as the reconcile it waits for, and leaving a pending arrival the worker yields its graph prepare to. The registry already publishes the edge this wants: the serving watch signals every seat install and every source revalidation that keeps an unchanged generation seated. Drive the wait from it, keeping a 100ms floor for the terminal answers that install no seat — a route that has not mounted yet, and a verified source that publishes no generation at all. Measured on the graph-rebuild transport journey, perf profile: publication waits 55.1s -> 50.1s and 15.4s -> 14.5s, suite 164.7s -> 155.3s. Co-authored-by: Zack Jackson --- .../src/daemon/production_harness.rs | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay/src/daemon/production_harness.rs b/crates/tracedecay/src/daemon/production_harness.rs index bd00b7baa2..f379445d3e 100644 --- a/crates/tracedecay/src/daemon/production_harness.rs +++ b/crates/tracedecay/src/daemon/production_harness.rs @@ -21,6 +21,7 @@ use super::project_server_lifecycle::{detach_project_servers, shutdown_detached_ use super::*; #[cfg(unix)] use tracedecay_application::pr_tracking::try_acquire_manual_branch_lifecycle; +use tracedecay_code_index_runtime::CodeIndexSchedulerRegistryV1; #[cfg(all(unix, feature = "test-transport"))] use tracedecay_code_index_runtime::git_transactions; use tracedecay_daemon_identity::profile_identity; @@ -957,6 +958,47 @@ impl ProductionProjectCompositionHarnessV1 { } } +/// Liveness floor for the readiness probe above. +/// +/// Every seat install and every source revalidation that keeps an unchanged +/// generation seated signals the serving watch, so the common case wakes on +/// the publication itself. This bound only covers the terminal answers that +/// install no seat — a route that has not mounted yet, and a verified source +/// that publishes no generation at all. +const CODE_INDEX_READINESS_BACKSTOP: Duration = Duration::from_millis(100); + +/// Park until the mounted route seats a generation, or until the backstop. +/// +/// The probe this paces canonicalizes the root, takes the scheduler registry's +/// mounted mutex several times, offloads a Git-metadata freshness capture to +/// the blocking pool, and emits a decline event. Re-running it on a fixed +/// millisecond cadence spends that on the same cores as the reconcile it is +/// waiting for, so the wait is driven by the serving watch instead. +async fn await_serving_generation_change( + schedulers: &CodeIndexSchedulerRegistryV1, + project_root: &Path, + serving_changed: &mut Option>, +) { + if serving_changed.is_none() { + *serving_changed = schedulers + .subscribe_serving_generation_changes(project_root) + .await; + } + let Some(changed) = serving_changed.as_mut() else { + tokio::time::sleep(CODE_INDEX_READINESS_BACKSTOP).await; + return; + }; + match timeout(CODE_INDEX_READINESS_BACKSTOP, changed.changed()).await { + Ok(Ok(())) | Err(_) => {} + // The route retired its watch. Drop it and let the next probe report + // whatever typed state replaced the mount. + Ok(Err(_)) => { + *serving_changed = None; + tokio::time::sleep(CODE_INDEX_READINESS_BACKSTOP).await; + } + } +} + #[hotpath::measure(label = "daemon.harness.wait_code_index", future = true)] async fn wait_for_production_composition_code_index( invocation: &DaemonInvocationState, @@ -975,6 +1017,12 @@ async fn wait_for_production_composition_code_index( return Ok(()); } let wait_started = Instant::now(); + // Subscribe before the first probe so a seat installed between the probe + // and the wait still wakes this loop. + let mut serving_changed = invocation + .code_index_schedulers + .subscribe_serving_generation_changes(project_root) + .await; let publication = timeout(Duration::from_secs(20), async { loop { // Scope-aware readiness is the authenticated demand boundary that @@ -1025,7 +1073,12 @@ async fn wait_for_production_composition_code_index( { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + await_serving_generation_change( + &invocation.code_index_schedulers, + project_root, + &mut serving_changed, + ) + .await; } }) .await; From 5a1cc6faa54290cc12698df7fc5d63b125bbaacf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 01:03:27 +0000 Subject: [PATCH 5/5] test(tracedecay): read a handle-truncated tool answer in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two suites reached the same wall — an MCP answer over the response frame arrives as `{"truncated": true, "handle": …, "preview": …}`, where `preview` is a string and `results` / `code_generation` are absent at the top level — and only one of them handled it. The graph-rebuild receipt read that envelope as a warming generation and spent its whole 90s deadline on a generation that was already current; `limit: 20 -> 3` was a guess at how many candidates fit, and 3 still rendered 18084 characters against 15000. Lift the daemon suite's retrieve-paging resolver into the shared test surface and read every tool answer through it, so the page size stops being load-bearing: it is a property of how much ranking provenance a candidate carries, not something a journey should track. Both suites' duplicate `tool` / `tool_payload` helpers go with it. Co-authored-by: Zack Jackson --- .../tracedecay/tests/common/mcp_response.rs | 99 +++++++++++++++++++ crates/tracedecay/tests/common/mod.rs | 2 + .../tests/daemon_suite/git_watch_test.rs | 78 ++------------- .../graph_rebuild_status_test.rs | 49 ++------- 4 files changed, 115 insertions(+), 113 deletions(-) create mode 100644 crates/tracedecay/tests/common/mcp_response.rs diff --git a/crates/tracedecay/tests/common/mcp_response.rs b/crates/tracedecay/tests/common/mcp_response.rs new file mode 100644 index 0000000000..4cb87ba816 --- /dev/null +++ b/crates/tracedecay/tests/common/mcp_response.rs @@ -0,0 +1,99 @@ +//! Reading an MCP tool answer that outgrew the response frame. +//! +//! A body over the frame cap does not arrive as the JSON a journey reads. MCP +//! stores the original, answers with `{"truncated": true, "handle": …, +//! "preview": …}`, and `preview` is a *string* holding a prefix of that JSON. +//! Every field a predicate looks for — `results`, `code_generation` — is then +//! absent at the top level, and a wait that reads them as missing cannot tell +//! a truncated answer from a generation that is still warming. +//! +//! So read the stored original through `tracedecay_retrieve` the way an agent +//! does, and keep that one authority: the page size that fits the frame is a +//! property of how much ranking provenance a candidate carries, not something +//! a journey should be guessing at. + +use std::path::Path; + +use serde_json::{Value, json}; +use tracedecay::daemon::ProductionProjectCompositionHarnessV1; +use tracedecay_mcp::JsonRpcResponse; + +/// Call one MCP tool on an admitted project and read its JSON answer. +pub async fn tool_json( + harness: &ProductionProjectCompositionHarnessV1, + project: &Path, + name: &str, + arguments: Value, +) -> Value { + let payload = called_tool_payload(harness, project, name, arguments).await; + resolved_tool_payload(harness, project, payload).await +} + +/// Reassemble a payload the response frame replaced with a retrieval handle. +/// +/// An untruncated payload is returned as it arrived. +pub async fn resolved_tool_payload( + harness: &ProductionProjectCompositionHarnessV1, + project: &Path, + payload: Value, +) -> Value { + if payload.get("truncated") != Some(&json!(true)) { + return payload; + } + let handle = payload["handle"] + .as_str() + .unwrap_or_else(|| panic!("truncated response omitted its retrieve handle: {payload}")); + let mut content = String::new(); + let mut offset = 0_u64; + loop { + let retrieved = called_tool_payload( + harness, + project, + "tracedecay_retrieve", + json!({"handle": handle, "format": "json", "offset": offset}), + ) + .await; + content.push_str(retrieved["content"].as_str().unwrap_or_else(|| { + panic!("truncated response handle carried no content page: {retrieved}") + })); + if retrieved["has_more"] != json!(true) { + break; + } + let next_offset = retrieved["next_offset"].as_u64().unwrap_or_else(|| { + panic!("retrieve reported more pages without a next offset: {retrieved}") + }); + assert!( + next_offset > offset, + "retrieve did not advance past offset {offset}: {retrieved}" + ); + offset = next_offset; + } + serde_json::from_str(&content).unwrap_or_else(|error| { + panic!("truncated response handle did not retrieve JSON: {error}; content={content}") + }) +} + +/// One tool call, decoded but not reassembled. `tracedecay_retrieve` pages +/// answer within the frame by construction, so the paging loop above reads +/// them through this rather than through [`tool_json`]. +async fn called_tool_payload( + harness: &ProductionProjectCompositionHarnessV1, + project: &Path, + name: &str, + arguments: Value, +) -> Value { + let response = harness + .call_tool(project, name, arguments) + .await + .unwrap_or_else(|error| panic!("{name} failed: {error}")); + decoded_tool_payload(&response) +} + +fn decoded_tool_payload(response: &JsonRpcResponse) -> Value { + assert!(response.error.is_none(), "{response:?}"); + let result = response.result.as_ref().expect("tool result"); + assert_ne!(result["isError"], true, "tool effect failed: {result}"); + let text = result["content"][0]["text"].as_str().expect("tool text"); + serde_json::from_str(text) + .unwrap_or_else(|error| panic!("tool returned invalid JSON: {error}; text={text}")) +} diff --git a/crates/tracedecay/tests/common/mod.rs b/crates/tracedecay/tests/common/mod.rs index b460681656..5d8e698900 100644 --- a/crates/tracedecay/tests/common/mod.rs +++ b/crates/tracedecay/tests/common/mod.rs @@ -1,6 +1,8 @@ #![allow(dead_code)] // shared test support: each suite binary compiles this module and uses a subset pub mod fixture; +#[cfg(feature = "test-transport")] +pub mod mcp_response; pub mod repository_layout; use std::ffi::{OsStr, OsString}; diff --git a/crates/tracedecay/tests/daemon_suite/git_watch_test.rs b/crates/tracedecay/tests/daemon_suite/git_watch_test.rs index 07e8291aa5..4f9efc4f5c 100644 --- a/crates/tracedecay/tests/daemon_suite/git_watch_test.rs +++ b/crates/tracedecay/tests/daemon_suite/git_watch_test.rs @@ -15,7 +15,8 @@ use tracedecay_code_index_retention::code_index_generations::{ DurablePublicationPointerV1, scoped_code_index_store_root, }; use tracedecay_domain::configuration::SYNC_WATCH_LINKED_WORKTREES_SETTING_KEY; -use tracedecay_mcp::JsonRpcResponse; + +use crate::common::mcp_response::tool_json; fn git(project: &Path, args: &[&str]) { let output = Command::new("git") .args(["-c", "core.hooksPath=.git/no-hooks"]) @@ -66,29 +67,8 @@ async fn indexed_repo() -> (TempDir, PathBuf, ProductionProjectCompositionHarnes .unwrap(); (root, project, harness) } -fn tool_payload(response: &JsonRpcResponse) -> Value { - assert!(response.error.is_none(), "{response:?}"); - let result = response.result.as_ref().expect("tool result"); - assert_ne!(result["isError"], true, "tool effect failed: {result}"); - let text = result["content"][0]["text"].as_str().expect("tool text"); - serde_json::from_str(text) - .unwrap_or_else(|error| panic!("tool returned invalid JSON: {error}; text={text}")) -} -async fn tool( - harness: &ProductionProjectCompositionHarnessV1, - project: &Path, - name: &str, - arguments: Value, -) -> Value { - tool_payload( - &harness - .call_tool(project, name, arguments) - .await - .unwrap_or_else(|error| panic!("{name} failed: {error}")), - ) -} async fn status(harness: &ProductionProjectCompositionHarnessV1, project: &Path) -> Value { - tool( + tool_json( harness, project, "tracedecay_status", @@ -116,7 +96,7 @@ async fn search( project: &Path, query: &str, ) -> Value { - let payload = tool( + let payload = tool_json( harness, project, "tracedecay_search", @@ -127,53 +107,9 @@ async fn search( payload["reason"], "search_capacity_unavailable", "search {query:?} lost the execution permit race and was refused instead of queued: {payload}" ); - resolve_truncated_tool_payload(harness, project, payload).await + payload } -/// A `tracedecay_search` body over the MCP response cap arrives as a handle -/// envelope whose preview is not the JSON the journey reads. Reassemble the -/// stored original through `tracedecay_retrieve` pages exactly as an agent -/// does, so `code_generation` and `results` come from the full answer. -async fn resolve_truncated_tool_payload( - harness: &ProductionProjectCompositionHarnessV1, - project: &Path, - payload: Value, -) -> Value { - if payload.get("truncated") != Some(&json!(true)) { - return payload; - } - let handle = payload["handle"] - .as_str() - .unwrap_or_else(|| panic!("truncated search omitted retrieve handle: {payload}")); - let mut content = String::new(); - let mut offset = 0_u64; - loop { - let retrieved = tool( - harness, - project, - "tracedecay_retrieve", - json!({"handle": handle, "format": "json", "offset": offset}), - ) - .await; - content.push_str(retrieved["content"].as_str().unwrap_or_else(|| { - panic!("truncated search handle carried no content page: {retrieved}") - })); - if retrieved["has_more"] != json!(true) { - break; - } - let next_offset = retrieved["next_offset"].as_u64().unwrap_or_else(|| { - panic!("retrieve reported more pages without a next offset: {retrieved}") - }); - assert!( - next_offset > offset, - "retrieve did not advance past offset {offset}: {retrieved}" - ); - offset = next_offset; - } - serde_json::from_str(&content).unwrap_or_else(|error| { - panic!("truncated search handle did not retrieve JSON: {error}; content={content}") - }) -} fn symbol_count(payload: &Value, name: &str) -> usize { payload["results"] .as_array() @@ -191,7 +127,7 @@ fn generation_index_len(data_root: &Path, project: &Path) -> usize { pointer.generation_index.len() } async fn request_refresh(harness: &ProductionProjectCompositionHarnessV1, project: &Path) { - let receipt = tool( + let receipt = tool_json( harness, project, "tracedecay_admin_sync", @@ -356,7 +292,7 @@ async fn linked_worktree_requires_mount_then_serves_only_its_exact_generation() // `linked_worktree_disabled` state and never publishes. The opt-in is a // project-layer setting decided at route open, so write it through the // production configuration tool before the worktree route opens. - let receipt = tool( + let receipt = tool_json( &harness, &project, "tracedecay_configuration_set", diff --git a/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs b/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs index 6f52e11104..3375161f6b 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs @@ -21,7 +21,8 @@ use std::time::Duration; use serde_json::{Value, json}; use tracedecay::daemon::ProductionProjectCompositionHarnessV1; -use tracedecay_mcp::JsonRpcResponse; + +use crate::common::mcp_response::tool_json; const RECEIPT_TIMEOUT: Duration = Duration::from_secs(90); @@ -81,31 +82,8 @@ fn head(project: &Path) -> String { .to_owned() } -fn tool_payload(response: &JsonRpcResponse) -> Value { - assert!(response.error.is_none(), "{response:?}"); - let result = response.result.as_ref().expect("tool result"); - assert_ne!(result["isError"], true, "tool effect failed: {result}"); - let text = result["content"][0]["text"].as_str().expect("tool text"); - serde_json::from_str(text) - .unwrap_or_else(|error| panic!("tool returned invalid JSON: {error}; text={text}")) -} - -async fn tool( - harness: &ProductionProjectCompositionHarnessV1, - project: &Path, - name: &str, - arguments: Value, -) -> Value { - tool_payload( - &harness - .call_tool(project, name, arguments) - .await - .unwrap_or_else(|error| panic!("{name} failed: {error}")), - ) -} - async fn status(harness: &ProductionProjectCompositionHarnessV1, project: &Path) -> Value { - tool( + tool_json( harness, project, "tracedecay_status", @@ -125,26 +103,13 @@ async fn search( project: &Path, query: &str, ) -> Value { - // One ranked candidate is all these journeys read, and the frame budget is - // why the page has to stay that small: every candidate carries several KiB - // of ranking provenance, so a three-result page rendered 18 084 characters - // against the 15 000-character response frame. - let payload = tool( + tool_json( harness, project, "tracedecay_search", - json!({"query": query, "limit": 1, "format": "json"}), + json!({"query": query, "limit": 3, "format": "json"}), ) - .await; - // A truncated envelope moves `results` and `code_generation` inside - // `preview`, where every predicate below reads them as absent. That is a - // malformed observation, not a warming generation: the waits below would - // spin to their deadline against a generation that is already current. - assert!( - payload.get("truncated").is_none(), - "search exceeded the MCP response frame and was replaced by a retrieval handle: {payload}" - ); - payload + .await } fn result_paths(search: &Value) -> Vec<&str> { @@ -291,7 +256,7 @@ async fn background_refresh_and_reopen_report_only_servable_generations_inner() install_background_batch(isolation.path(), &project); commit_all(&project, "install background refresh batch"); let refreshed_revision = head(&project); - let receipt = tool( + let receipt = tool_json( &harness, &project, "tracedecay_admin_sync",