Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,23 @@ fn refs_heads_signature(dir: &Path) -> Option<String> {
/// Resolve the git-dir (worktree-local) and common-dir (repository-shared)
/// paths, falling back to `<root>/.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 `<root>/.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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
);
}
55 changes: 54 additions & 1 deletion crates/tracedecay/src/daemon/production_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<tokio::sync::watch::Receiver<()>>,
) {
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,
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
99 changes: 99 additions & 0 deletions crates/tracedecay/tests/common/mcp_response.rs
Original file line number Diff line number Diff line change
@@ -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}"))
}
2 changes: 2 additions & 0 deletions crates/tracedecay/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down
Loading
Loading