Skip to content
Merged
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
24 changes: 24 additions & 0 deletions crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3143,6 +3143,30 @@ impl LatestCompleteCodeIndexV1 {
)
}

/// Snapshot graph-serving activation with one lock acquisition so status
/// cannot combine states from opposite sides of an activation transition.
pub fn code_graph_serving_readiness(
&self,
) -> tracedecay_dashboard_api::code_index_freshness_api::CodeGraphServingReadinessV1 {
match &*self
.graph_activation
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
{
CodeGraphActivationStateV1::Pending => {
tracedecay_dashboard_api::code_index_freshness_api::CodeGraphServingReadinessV1::Pending
}
CodeGraphActivationStateV1::Refused(reason) => {
tracedecay_dashboard_api::code_index_freshness_api::CodeGraphServingReadinessV1::Refused {
reason: (*reason).to_owned(),
}
}
CodeGraphActivationStateV1::Ready(_) => {
tracedecay_dashboard_api::code_index_freshness_api::CodeGraphServingReadinessV1::Ready
}
}
}

fn refuse_graph_activation(&self, reason: &'static str) {
let mut state = self
.graph_activation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ use std::{
use std::sync::Condvar;

use tracedecay_code_index::production::CodeIndexPublishedGenerationV1;
use tracedecay_dashboard_api::code_index_freshness_api::CodeIndexConvergenceParkedV1;
use tracedecay_dashboard_api::code_index_freshness_api::{
CodeGraphServingReadinessV1, CodeIndexConvergenceParkedV1,
};
use tracedecay_domain::configuration::ConfigurationRevisionId;
use tracedecay_domain::{CodeGenerationId, ManifestDigest, ProjectId, RepositoryId, WorktreeId};
use tracedecay_lsp::LspRuntimeFailure;
Expand Down Expand Up @@ -695,21 +697,49 @@ fn dashboard_freshness_identity(
identity
}

/// Project the graph-serving state without warming any serving derivation.
fn dashboard_code_graph_serving(
latest: Option<&LatestCompleteCodeIndexV1>,
text_generation_present: bool,
graph_activation_enabled: bool,
) -> Option<CodeGraphServingReadinessV1> {
if !graph_activation_enabled {
return Some(CodeGraphServingReadinessV1::Unavailable {
reason: "graph_activation_disabled".to_owned(),
});
}
let Some(latest) = latest else {
return Some(if text_generation_present {
CodeGraphServingReadinessV1::Pending
Comment thread
ScriptedAlchemy marked this conversation as resolved.
} else {
CodeGraphServingReadinessV1::Unavailable {
reason: "generation_unavailable".to_owned(),
}
});
};
Some(latest.code_graph_serving_readiness())
Comment thread
ScriptedAlchemy marked this conversation as resolved.
}

/// Whether status may report this worktree as terminal (`fresh` / `current`).
///
/// Exact and lexical serve from the text owner while native graph activation
/// is still in flight. Search on that text-only path reports
/// `retriever_unavailable` for the graph lane. Dashboard `current` must wait
/// for the seated serving generation whose graph is Ready or Refused so a
/// terminal receipt is one search can actually complete. Graph-off worktrees
/// keep the text-owner receipt.
/// Refused graph activation remains terminal for text serving, preserving the
/// existing status behavior; strict dogfood can distinguish it from Ready via
/// the separate typed projection.
fn dashboard_generation_is_ready(
latest: Option<&LatestCompleteCodeIndexV1>,
text_ready: bool,
graph_activation_enabled: bool,
code_graph_serving: &Option<CodeGraphServingReadinessV1>,
) -> bool {
if graph_activation_enabled {
latest.is_some_and(|latest| !latest.graph_activation_is_pending())
latest.is_some()
&& matches!(
code_graph_serving,
Some(
CodeGraphServingReadinessV1::Ready
| CodeGraphServingReadinessV1::Refused { .. }
)
)
} else {
latest.is_some() || text_ready
}
Expand Down Expand Up @@ -4518,10 +4548,16 @@ impl CodeIndexSchedulerRegistryV1 {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.count();
let code_graph_serving = dashboard_code_graph_serving(
latest.as_ref(),
text.is_some(),
graph_activation_enabled,
);
let ready = dashboard_generation_is_ready(
latest.as_ref(),
text_ready,
graph_activation_enabled,
&code_graph_serving,
);
let stale = hook_hint_count != Some(0);
let last_reconcile_micros = match last_reconciled_at_micros
Expand All @@ -4532,6 +4568,7 @@ impl CodeIndexSchedulerRegistryV1 {
};
return tracedecay_dashboard_api::code_index_freshness_api::CodeIndexWorktreeFreshnessV1 {
worktree_root: canonical_root.display().to_string(),
code_graph_serving,
last_reconcile_micros,
staleness_state: Some(
if parked.is_some() && !ready {
Expand Down Expand Up @@ -4580,10 +4617,13 @@ impl CodeIndexSchedulerRegistryV1 {
.as_ref()
.is_some_and(LatestCodeTextGenerationV1::text_serving_is_ready);
let hook_hint_count = scheduler.pending_hint_count();
let code_graph_serving =
dashboard_code_graph_serving(latest.as_ref(), text.is_some(), graph_activation_enabled);
let ready = dashboard_generation_is_ready(
latest.as_ref(),
text_ready,
graph_activation_enabled,
&code_graph_serving,
);
let staleness_state = if parked.is_some() && !ready {
"parked"
Expand Down Expand Up @@ -4611,6 +4651,7 @@ impl CodeIndexSchedulerRegistryV1 {
};
tracedecay_dashboard_api::code_index_freshness_api::CodeIndexWorktreeFreshnessV1 {
worktree_root: canonical_root.display().to_string(),
code_graph_serving,
last_reconcile_micros: scheduler.last_reconciled_at_micros(),
staleness_state: Some(staleness_state.to_owned()),
hook_hint_count,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5778,6 +5778,12 @@ async fn dashboard_freshness_projects_the_mounted_scheduler_generation() {
);
assert_eq!(projected.staleness_state.as_deref(), Some("fresh"));
assert_eq!(projected.coverage, "complete");
assert_eq!(
projected.code_graph_serving,
Some(
tracedecay_dashboard_api::code_index_freshness_api::CodeGraphServingReadinessV1::Ready
)
);
}

#[tokio::test]
Expand Down Expand Up @@ -11789,6 +11795,13 @@ async fn changed_text_generation_is_ready_before_slow_graph_activation_starts()
Some("fresh"),
"dashboard must not claim a terminal generation while native graph activation is still held: {freshness:?}"
);
assert_eq!(
freshness.code_graph_serving,
Some(
tracedecay_dashboard_api::code_index_freshness_api::CodeGraphServingReadinessV1::Pending
),
"a sealed text generation must not imply graph-serving readiness while activation is held"
);
activation_gate.release();

assert_ne!(
Expand Down
46 changes: 46 additions & 0 deletions crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,23 @@ pub struct CodeIndexConvergenceParkedV1 {
pub retries_on_wake: bool,
}

/// Interactive graph-serving state for the latest sealed generation.
///
/// A sealed generation can expose truthful census statistics before its graph
/// projection is ready to serve queries, so readiness is reported separately.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum CodeGraphServingReadinessV1 {
/// No graph-serving authority exists for this worktree or generation.
Unavailable { reason: String },
/// The sealed generation exists, but graph activation has not completed.
Pending,
/// Graph activation completed without a serving projection.
Refused { reason: String },
/// The verified graph projection is installed for interactive reads.
Ready,
}

/// Freshness/generation state for one mounted worktree.
///
/// `Deserialize` is part of the wire contract: the CLI status command decodes
Expand All @@ -153,6 +170,10 @@ pub struct CodeIndexWorktreeFreshnessV1 {
pub source_revision: Option<String>,
/// Latest sealed generation identity, when a complete generation exists.
pub latest_generation_id: Option<String>,
/// Whether that generation's verified graph projection can serve reads.
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub code_graph_serving: Option<CodeGraphServingReadinessV1>,
/// Content identity of the complete source snapshot.
pub snapshot_content_identity: Option<String>,
/// Time the complete generation was durably sealed.
Expand Down Expand Up @@ -309,6 +330,24 @@ mod tests {
crate::events_api::dashboard_state_fixture("project.dashboard-code-index").await
}

#[test]
fn graph_serving_readiness_is_additive_for_older_daemon_responses() {
let mut value = serde_json::to_value(CodeIndexWorktreeFreshnessV1::default())
.expect("freshness serializes");
value
.as_object_mut()
.expect("freshness object")
.remove("code_graph_serving");

let decoded: CodeIndexWorktreeFreshnessV1 =
serde_json::from_value(value).expect("older response remains readable");
assert_eq!(decoded.code_graph_serving, None);

let ready = serde_json::to_value(CodeGraphServingReadinessV1::Ready)
.expect("ready state serializes");
assert_eq!(ready, serde_json::json!({ "state": "ready" }));
}

#[tokio::test]
async fn freshness_route_is_typed_unsupported_without_daemon_authority() {
let _pin = tracedecay_runtime_core::config::PinnedUserDataDir::new();
Expand All @@ -334,6 +373,7 @@ mod tests {
source_reference: Some("refs/heads/main".to_owned()),
source_revision: Some("commit.fixture".to_owned()),
latest_generation_id: Some("generation.fixture".to_owned()),
code_graph_serving: Some(CodeGraphServingReadinessV1::Ready),
snapshot_content_identity: Some("sha256:fixture".to_owned()),
sealed_at_micros: Some(41),
last_reconcile_micros: Some(42),
Expand Down Expand Up @@ -373,6 +413,9 @@ mod tests {
source_reference: None,
source_revision: None,
latest_generation_id: None,
code_graph_serving: Some(CodeGraphServingReadinessV1::Unavailable {
reason: "generation_unavailable".to_owned(),
}),
snapshot_content_identity: None,
sealed_at_micros: None,
last_reconcile_micros: Some(42),
Expand Down Expand Up @@ -404,6 +447,9 @@ mod tests {
source_reference: Some("refs/heads/main".to_owned()),
source_revision: Some("commit.fixture".to_owned()),
latest_generation_id: None,
code_graph_serving: Some(CodeGraphServingReadinessV1::Unavailable {
reason: "generation_unavailable".to_owned(),
}),
snapshot_content_identity: None,
sealed_at_micros: None,
last_reconcile_micros: Some(42),
Expand Down
18 changes: 18 additions & 0 deletions crates/tracedecay/src/mcp/tools/handlers/info/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,24 @@ mod tests {
assert!(warning.contains("restore owner-only access"));
}

#[test]
fn status_freshness_preserves_typed_graph_serving_readiness() {
let freshness =
tracedecay_dashboard_api::code_index_freshness_api::CodeIndexWorktreeFreshnessV1 {
worktree_root: "/project".to_owned(),
code_graph_serving: Some(
tracedecay_dashboard_api::code_index_freshness_api::CodeGraphServingReadinessV1::Ready,
),
..Default::default()
};

let value = serde_json::to_value(freshness).expect("freshness serializes");
assert_eq!(
value["code_graph_serving"],
serde_json::json!({ "state": "ready" })
);
}

#[test]
fn a_serving_worktree_with_a_parked_newer_build_stays_current_but_warns() {
let freshness =
Expand Down
76 changes: 76 additions & 0 deletions dashboard/codegen/schemas/dashboard-contracts.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading