From e39c4b7f46996a5770f657d70749c12c5d2e4e7c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 25 Aug 2026 02:08:44 +0200 Subject: [PATCH] Surface Vault outages as Session Zero gaps --- crates/ghostlight-dungeon/src/main.rs | 24 ++-- crates/ghostlight-dungeon/src/session_zero.rs | 107 ++++++++++++++++++ crates/ghostlight-dungeon/src/vault.rs | 72 +++++++++++- 3 files changed, 189 insertions(+), 14 deletions(-) diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index 8dd450d..dfbfb60 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -3033,14 +3033,22 @@ async fn compile_session_zero( } } Err(error) => { - let message = error.to_string(); - if let Err(commit_error) = kernel - .command(SessionZeroCommand::CompilationFailed { - expected_revision, - message, - }) - .await - { + tracing::warn!(%error, "Session Zero compilation failed before preview publication"); + let result = if ghostlight_dungeon::vault::is_vault_unavailable(&error) { + kernel + .command(SessionZeroCommand::CompilationEvidenceUnavailable { + expected_revision, + }) + .await + } else { + kernel + .command(SessionZeroCommand::CompilationFailed { + expected_revision, + message: error.to_string(), + }) + .await + }; + if let Err(commit_error) = result { tracing::info!(%commit_error, "stale Session Zero compiler failure discarded"); } else { schedule_mesh_refresh(&mesh_state); diff --git a/crates/ghostlight-dungeon/src/session_zero.rs b/crates/ghostlight-dungeon/src/session_zero.rs index a75a2b3..8832dcc 100644 --- a/crates/ghostlight-dungeon/src/session_zero.rs +++ b/crates/ghostlight-dungeon/src/session_zero.rs @@ -1088,6 +1088,9 @@ pub enum SessionZeroCommand { expected_revision: u64, message: String, }, + CompilationEvidenceUnavailable { + expected_revision: u64, + }, Approve { actor_account_hash: String, expected_revision: u64, @@ -3874,6 +3877,35 @@ fn execute( ), )?; } + SessionZeroCommand::CompilationEvidenceUnavailable { expected_revision } => { + require_revision(&state, expected_revision)?; + state.status = SessionZeroStatus::Drafting; + let decision_id = "decision:vault-retrieval-required".to_owned(); + state.decisions.insert( + decision_id.clone(), + SessionZeroDecision { + schema: "ghostlight.session_zero_decision.v1".into(), + id: decision_id, + owner_member_id: None, + prompt: "The entitled lore Vault is currently unreachable. Should this exact draft remain unchanged until grounded compilation can be retried?".into(), + proposed_resolution: "Keep the negotiated draft unchanged and retry compilation only after Vault retrieval is available. Do not substitute nearby stories, branch inventions, or remembered setting vibes for evidence.".into(), + proposed_extraordinary_permission: None, + proposed_contract_patch: None, + proposed_character_patch: None, + evidence_receipt_ids: vec![], + pending_counter: None, + material: true, + resolved: false, + }, + ); + append_message( + &mut state, + "shared:table".into(), + None, + SessionZeroSpeakerKind::Dm, + "The lore Vault is currently unreachable, so I cannot distinguish missing canon from missing retrieval. No preview, branch assumption, or campaign state was published. I have kept the exact draft and queued an explicit decision to retry only after grounded evidence is available.".into(), + )?; + } SessionZeroCommand::Approve { actor_account_hash, expected_revision, @@ -4785,6 +4817,81 @@ mod tests { assert_eq!(compiling.state.model_receipts.len(), 3); } + #[tokio::test] + async fn unavailable_vault_becomes_one_resolvable_material_gap() { + let dir = tempdir().unwrap(); + let store = CampaignStore::open(dir.path().join("session-zero.cc")).unwrap(); + let mut initial = compilable_state(); + initial.roster_locked = true; + initial.status = SessionZeroStatus::Compiling; + SessionZeroKernel::initialize(&store, &initial).unwrap(); + let kernel = SessionZeroKernel::start(store, initial.id); + + let failed = kernel + .command(SessionZeroCommand::CompilationEvidenceUnavailable { + expected_revision: initial.revision, + }) + .await + .unwrap(); + let decision = failed + .state + .decisions + .get("decision:vault-retrieval-required") + .unwrap(); + assert_eq!(failed.state.status, SessionZeroStatus::Drafting); + assert!(failed.state.preview.is_none()); + assert!(decision.material); + assert!(!decision.resolved); + assert!(decision.proposed_contract_patch.is_none()); + assert!(decision.proposed_character_patch.is_none()); + assert!(decision.proposed_extraordinary_permission.is_none()); + let public_messages = failed + .state + .messages + .values() + .map(|message| message.text.as_str()) + .collect::>() + .join("\n"); + assert!( + public_messages.contains("cannot distinguish missing canon from missing retrieval") + ); + assert!(!public_messages.contains("Ollama")); + + let acknowledged = kernel + .command(SessionZeroCommand::ResolveDecision { + actor_account_hash: "account:host".into(), + expected_revision: failed.state.revision, + decision_id: decision.id.clone(), + resolution: SessionZeroDecisionResolution::Accept, + counter: None, + }) + .await + .unwrap(); + let compiling = kernel + .command(SessionZeroCommand::BeginCompilation { + actor_account_hash: "account:host".into(), + expected_revision: acknowledged.state.revision, + }) + .await + .unwrap(); + let failed_again = kernel + .command(SessionZeroCommand::CompilationEvidenceUnavailable { + expected_revision: compiling.state.revision, + }) + .await + .unwrap(); + assert_eq!( + failed_again + .state + .decisions + .keys() + .filter(|id| id.as_str() == "decision:vault-retrieval-required") + .count(), + 1 + ); + assert!(!failed_again.state.decisions["decision:vault-retrieval-required"].resolved); + } + #[test] fn legacy_receipt_layout_migrates_without_changing_session_revision() { let mut draft = compilable_state(); diff --git a/crates/ghostlight-dungeon/src/vault.rs b/crates/ghostlight-dungeon/src/vault.rs index f990663..302255a 100644 --- a/crates/ghostlight-dungeon/src/vault.rs +++ b/crates/ghostlight-dungeon/src/vault.rs @@ -5,9 +5,32 @@ use chrono::Utc; use reqwest::Client; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use thiserror::Error; const VOIDBOT_SEARCH_RESULT_LIMIT: u8 = 12; +#[derive(Debug, Error)] +#[error("{provider} Vault retrieval is unavailable: {detail}")] +pub struct VaultUnavailable { + provider: String, + detail: String, +} + +impl VaultUnavailable { + fn new(provider: impl Into, detail: impl Into) -> Self { + Self { + provider: provider.into(), + detail: detail.into(), + } + } +} + +pub fn is_vault_unavailable(error: &anyhow::Error) -> bool { + error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct VaultQuery { pub query: String, @@ -78,24 +101,47 @@ impl VoidBotMcpVault { name: &str, arguments: serde_json::Value, ) -> Result { - let text = self.client.post(&self.endpoint) + let response = self.client.post(&self.endpoint) .header("accept", "application/json, text/event-stream") .json(&serde_json::json!({"jsonrpc":"2.0","id":"ghostlight-vault","method":"tools/call","params":{"name":name,"arguments":arguments}})) - .send().await?.error_for_status()?.text().await?; + .send() + .await + .map_err(|error| VaultUnavailable::new(self.provider_id(), error.to_string()))? + .error_for_status() + .map_err(|error| VaultUnavailable::new(self.provider_id(), error.to_string()))?; + let text = response + .text() + .await + .map_err(|error| VaultUnavailable::new(self.provider_id(), error.to_string()))?; let payload = text .lines() .find_map(|line| line.strip_prefix("data: ")) - .ok_or_else(|| anyhow!("VoidBot MCP returned no event payload"))?; - let value: serde_json::Value = serde_json::from_str(payload)?; + .ok_or_else(|| { + VaultUnavailable::new(self.provider_id(), "MCP returned no event payload") + })?; + let value: serde_json::Value = serde_json::from_str(payload).map_err(|error| { + VaultUnavailable::new( + self.provider_id(), + format!("MCP returned an invalid event payload: {error}"), + ) + })?; if let Some(error) = value.pointer("/error/message").and_then(|v| v.as_str()) { - return Err(anyhow!("VoidBot MCP JSON-RPC error: {error}")); + return Err(VaultUnavailable::new( + self.provider_id(), + format!("MCP JSON-RPC error: {error}"), + ) + .into()); } if value.pointer("/result/isError").and_then(|v| v.as_bool()) == Some(true) { let detail = value .pointer("/result/content/0/text") .and_then(|v| v.as_str()) .unwrap_or("tool returned an unspecified error"); - return Err(anyhow!("VoidBot MCP tool error: {detail}")); + return Err(VaultUnavailable::new( + self.provider_id(), + format!("MCP tool error: {detail}"), + ) + .into()); } Ok(value) } @@ -104,8 +150,22 @@ impl VoidBotMcpVault { #[cfg(test)] mod tests { use super::*; + use anyhow::Context; use axum::{Router, response::IntoResponse, routing::post}; + #[test] + fn vault_unavailability_survives_anyhow_context() { + let unavailable = anyhow::Error::new(VaultUnavailable::new( + "voidbot.aetheria", + "embedding service is offline", + )) + .context("world compilation retrieval failed"); + assert!(is_vault_unavailable(&unavailable)); + assert!(!is_vault_unavailable(&anyhow!( + "world candidate violated topology" + ))); + } + #[tokio::test] async fn voidbot_search_results_become_exact_evidence_witnesses() { let app=Router::new().route("/mcp",post(||async { ([("content-type","text/event-stream")], "event: message\ndata: {\"result\":{\"structuredContent\":{\"results\":[{\"sourceId\":\"AetheriaLore:place.md\",\"path\":\"place.md\",\"lineStart\":7,\"lineEnd\":9,\"text\":\"The route takes six hours.\"}]}}}\n\n").into_response() }));