Surface Vault outages as Session Zero gaps

This commit is contained in:
Meta Vens 2026-08-25 02:08:44 +02:00
commit e39c4b7f46
3 changed files with 189 additions and 14 deletions

View file

@ -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);

View file

@ -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::<Vec<_>>()
.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();

View file

@ -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<String>, detail: impl Into<String>) -> Self {
Self {
provider: provider.into(),
detail: detail.into(),
}
}
}
pub fn is_vault_unavailable(error: &anyhow::Error) -> bool {
error
.chain()
.any(|cause| cause.downcast_ref::<VaultUnavailable>().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<serde_json::Value> {
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() }));