Surface Vault outages as Session Zero gaps
This commit is contained in:
parent
4435bcb6f4
commit
e39c4b7f46
3 changed files with 189 additions and 14 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() }));
|
||||
|
|
|
|||
Loading…
Reference in a new issue