diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs index b0731d5777..8b55756ac0 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs @@ -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 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 741b034594..d851d1cf7c 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 @@ -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; @@ -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 { + 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 + } else { + CodeGraphServingReadinessV1::Unavailable { + reason: "generation_unavailable".to_owned(), + } + }); + }; + Some(latest.code_graph_serving_readiness()) +} + /// 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, ) -> 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 } @@ -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 @@ -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 { @@ -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" @@ -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, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests.rs index a18d4d0837..287fe64542 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests.rs @@ -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] @@ -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!( diff --git a/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs b/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs index 757d92e7a5..be1562e1f0 100644 --- a/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs +++ b/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs @@ -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 @@ -153,6 +170,10 @@ pub struct CodeIndexWorktreeFreshnessV1 { pub source_revision: Option, /// Latest sealed generation identity, when a complete generation exists. pub latest_generation_id: Option, + /// Whether that generation's verified graph projection can serve reads. + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub code_graph_serving: Option, /// Content identity of the complete source snapshot. pub snapshot_content_identity: Option, /// Time the complete generation was durably sealed. @@ -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(); @@ -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), @@ -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), @@ -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), diff --git a/crates/tracedecay/src/mcp/tools/handlers/info/status.rs b/crates/tracedecay/src/mcp/tools/handlers/info/status.rs index b97038e2df..8e679217b6 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/info/status.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/info/status.rs @@ -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 = diff --git a/dashboard/codegen/schemas/dashboard-contracts.schema.json b/dashboard/codegen/schemas/dashboard-contracts.schema.json index 0e670fc688..5d3bbff6e4 100644 --- a/dashboard/codegen/schemas/dashboard-contracts.schema.json +++ b/dashboard/codegen/schemas/dashboard-contracts.schema.json @@ -2168,6 +2168,71 @@ "description": "Strongly typed canonical identity: `CatalogGenerationId`.", "type": "string" }, + "CodeGraphServingReadinessV1": { + "description": "Interactive graph-serving state for the latest sealed generation.\n\nA sealed generation can expose truthful census statistics before its graph\nprojection is ready to serve queries, so readiness is reported separately.", + "oneOf": [ + { + "description": "No graph-serving authority exists for this worktree or generation.", + "properties": { + "reason": { + "type": "string" + }, + "state": { + "const": "unavailable", + "type": "string" + } + }, + "required": [ + "state", + "reason" + ], + "type": "object" + }, + { + "description": "The sealed generation exists, but graph activation has not completed.", + "properties": { + "state": { + "const": "pending", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "description": "Graph activation completed without a serving projection.", + "properties": { + "reason": { + "type": "string" + }, + "state": { + "const": "refused", + "type": "string" + } + }, + "required": [ + "state", + "reason" + ], + "type": "object" + }, + { + "description": "The verified graph projection is installed for interactive reads.", + "properties": { + "state": { + "const": "ready", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + } + ] + }, "CodeIndexBuildBlockedReasonV1": { "description": "A typed reason an otherwise active generation cannot make durable progress.", "enum": [ @@ -2540,6 +2605,17 @@ "CodeIndexWorktreeFreshnessV1": { "description": "Freshness/generation state for one mounted worktree.\n\n`Deserialize` is part of the wire contract: the CLI status command decodes\nexactly this type back out of the daemon's `tracedecay_status` response,\nkeeping one authority for the freshness shape.", "properties": { + "code_graph_serving": { + "anyOf": [ + { + "$ref": "#/$defs/CodeGraphServingReadinessV1" + }, + { + "type": "null" + } + ], + "description": "Whether that generation's verified graph projection can serve reads." + }, "coverage": { "description": "Whether this read covers the complete mounted scheduler state.", "type": "string" diff --git a/dashboard/src/contracts/generated.ts b/dashboard/src/contracts/generated.ts index 88cf57ef50..09d687bf67 100644 --- a/dashboard/src/contracts/generated.ts +++ b/dashboard/src/contracts/generated.ts @@ -636,6 +636,23 @@ export type CapabilityId = z.infer; export const CatalogGenerationIdSchema = z.string(); export type CatalogGenerationId = z.infer; +/** 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. */ +export const CodeGraphServingReadinessV1Schema = z.discriminatedUnion("state", [z.object({ + state: z.literal("pending"), +}), z.object({ + state: z.literal("ready"), +}), z.object({ + reason: z.string(), + state: z.literal("refused"), +}), z.object({ + reason: z.string(), + state: z.literal("unavailable"), +})]); +export type CodeGraphServingReadinessV1 = z.infer; + /** A typed reason an otherwise active generation cannot make durable progress. */ export const CodeIndexBuildBlockedReasonV1Schema = z.enum(["artifact_store_unavailable", "resident_memory", "retry_backoff", "source_unavailable"]); export type CodeIndexBuildBlockedReasonV1 = z.infer; @@ -739,6 +756,7 @@ export type CodeIndexWorkerStatusV1 = z.infer CodeGraphServingReadinessV1Schema), z.null()]).optional(), coverage: z.string(), hook_hint_count: z.number().int().safe().min(0).nullable(), last_reconcile_micros: z.number().int().safe().nullable(), diff --git a/scripts/check-pr-dogfood-output.py b/scripts/check-pr-dogfood-output.py index 2f65764e4c..7b66721fae 100755 --- a/scripts/check-pr-dogfood-output.py +++ b/scripts/check-pr-dogfood-output.py @@ -50,6 +50,9 @@ def validate_status(value: dict[str, Any], *, strict: bool = False) -> None: if graph.get("state") != "observed": reason = graph.get("reason", "unknown") raise ValueError(f"strict status requires an observed graph; reason={reason}") + graph_serving = worktree.get("code_graph_serving") + if not isinstance(graph_serving, dict) or graph_serving.get("state") != "ready": + raise ValueError("strict status requires a ready code-graph serving projection") def validate_context(value: dict[str, Any], *, strict: bool = False) -> None: diff --git a/scripts/test-check-pr-dogfood-output.py b/scripts/test-check-pr-dogfood-output.py index b6dd20a0f2..e4e98c8a94 100755 --- a/scripts/test-check-pr-dogfood-output.py +++ b/scripts/test-check-pr-dogfood-output.py @@ -153,6 +153,7 @@ def test_strict_status_accepts_current_text_and_observed_graph(self) -> None: "coverage": "complete", "staleness_state": "fresh", "latest_generation_id": "generation.ready", + "code_graph_serving": {"state": "ready"}, }, }, "graph_statistics": { @@ -165,6 +166,38 @@ def test_strict_status_accepts_current_text_and_observed_graph(self) -> None: strict=True, ) + def test_strict_status_rejects_graph_that_is_not_ready_to_serve(self) -> None: + for graph_serving in ( + {"state": "pending"}, + {"state": "refused", "reason": "projection_failed"}, + {"state": "unavailable", "reason": "generation_unavailable"}, + None, + ): + with self.subTest(graph_serving=graph_serving): + worktree = { + "coverage": "complete", + "staleness_state": "fresh", + "latest_generation_id": "generation.ready", + } + if graph_serving is not None: + worktree["code_graph_serving"] = graph_serving + with self.assertRaisesRegex(ValueError, "ready code-graph"): + self.checker.validate_status( + { + "code_index_freshness": { + "status": "current", + "worktree": worktree, + }, + "graph_statistics": { + "state": "observed", + "generation_id": "generation.ready", + "symbol_count": 12, + "edge_count": 9, + }, + }, + strict=True, + ) + def test_strict_status_rejects_live_exact_scope_graph_degradation(self) -> None: with self.assertRaisesRegex(ValueError, "exact_scope_generation_not_ready"): self.checker.validate_status( diff --git a/scripts/test-pr-dogfood-portability.py b/scripts/test-pr-dogfood-portability.py index 9551d30a2e..fcaa7af3d8 100755 --- a/scripts/test-pr-dogfood-portability.py +++ b/scripts/test-pr-dogfood-portability.py @@ -492,7 +492,7 @@ def test_run_mode_polls_until_strict_readiness_then_validates_journey(self) -> N if [[ "${FAKE_NEVER_READY:-0}" == "1" || "$count" -lt 3 ]]; then echo '{"code_index_freshness":{"status":"current","worktree":{"coverage":"complete","staleness_state":"fresh","latest_generation_id":"generation.text-only"}},"graph_statistics":{"state":"unavailable","reason":"exact_scope_generation_not_ready"}}' else - echo '{"code_index_freshness":{"status":"current","worktree":{"coverage":"complete","staleness_state":"fresh","latest_generation_id":"generation.ready"}},"graph_statistics":{"state":"observed","generation_id":"generation.ready","symbol_count":2,"edge_count":1}}' + echo '{"code_index_freshness":{"status":"current","worktree":{"coverage":"complete","staleness_state":"fresh","latest_generation_id":"generation.ready","code_graph_serving":{"state":"ready"}}},"graph_statistics":{"state":"observed","generation_id":"generation.ready","symbol_count":2,"edge_count":1}}' fi elif [[ "${1:-} ${2:-}" == "tool context" ]]; then echo '{"coverage":{"exact":"complete","lexical":"complete","graph":"complete","semantic":{"status":"unavailable","reason":"disabled"},"recall":"partial"},"search_matches":[{"file":"src/main.rs"}],"symbols":[{"node_id":"symbol:main"}]}'