fix(pi): restore catalog and takeover authority boundaries

This commit is contained in:
SaladDay
2026-08-03 00:38:29 +00:00
parent 26447effa2
commit 9d0e75d56a
10 changed files with 925 additions and 96 deletions
+287 -30
View File
@@ -18,6 +18,7 @@ use crate::services::provider::{
provider_to_mutation_input, reconcile_provider_record_with_precondition,
write_live_with_common_config, ReconcilePrecondition,
};
use crate::services::{pi_prompt_files::lock_instruction_files_async, prompt::PromptService};
use serde_json::{json, Map, Value};
use std::str::FromStr;
use std::sync::{
@@ -27,6 +28,9 @@ use std::sync::{
use tauri::Emitter;
use tokio::sync::RwLock;
#[cfg(test)]
use std::sync::atomic::AtomicBool;
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
@@ -73,6 +77,8 @@ pub struct ProxyService {
pi_runtime: Arc<PiRuntimeStore>,
pi_server_sequence: Arc<AtomicU64>,
pi_listener: Arc<StdRwLock<Option<PiListenerIdentity>>>,
#[cfg(test)]
fail_next_pi_reconcile: Arc<AtomicBool>,
}
#[derive(Debug, Clone)]
@@ -119,6 +125,8 @@ impl ProxyService {
pi_runtime: Arc::new(PiRuntimeStore::default()),
pi_server_sequence: Arc::new(AtomicU64::new(0)),
pi_listener: Arc::new(StdRwLock::new(None)),
#[cfg(test)]
fail_next_pi_reconcile: Arc::new(AtomicBool::new(false)),
}
}
@@ -562,6 +570,13 @@ impl ProxyService {
self.pi_runtime.begin_mutation().await
}
pub(crate) async fn close_pi_runtime_at_epoch(
&self,
catalog_epoch: u64,
) -> Result<(), AppError> {
self.pi_runtime.close(catalog_epoch).await
}
pub(crate) fn project_pi_provider_value(
&self,
provider_id: &str,
@@ -597,6 +612,12 @@ impl ProxyService {
&self,
catalog_epoch: u64,
) -> Result<Vec<String>, AppError> {
#[cfg(test)]
if self.fail_next_pi_reconcile.swap(false, Ordering::AcqRel) {
return Err(AppError::Config(
"injected Pi runtime reconciliation failure".to_string(),
));
}
if !crate::settings::pi_takeover_enabled() {
self.pi_runtime.close(catalog_epoch).await?;
return Ok(Vec::new());
@@ -629,11 +650,45 @@ impl ProxyService {
Ok(build.direct_only_provider_ids)
}
#[cfg(test)]
pub(crate) fn fail_next_pi_reconcile_for_test(&self) {
self.fail_next_pi_reconcile.store(true, Ordering::Release);
}
pub(crate) async fn reconcile_pi_runtime(&self) -> Result<Vec<String>, AppError> {
let epoch = self.pi_runtime.begin_mutation().await;
self.reconcile_pi_runtime_at_epoch(epoch).await
}
/// Publish a DB-only catalog ordering without closing admission or
/// touching the native projection. The caller holds Pi's switch lock and
/// must restore the DB order if this preparation fails.
pub(crate) async fn publish_pi_runtime_order(&self) -> Result<(), AppError> {
if !crate::settings::pi_takeover_enabled() {
return Ok(());
}
let listener = self
.pi_listener
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
.ok_or_else(|| {
AppError::Conflict(
"Pi takeover is desired but the loopback listener is unavailable".to_string(),
)
})?;
let epoch = self.pi_runtime.next_even_epoch()?;
let build = build_pi_runtime(
self.db.as_ref(),
listener.server_generation,
epoch,
&listener.gateway_origin,
crate::settings::get_pi_gateway_token()?,
crate::settings::get_pi_app_proxy_config(),
)?;
self.pi_runtime.publish(build.snapshot).await
}
fn restore_pi_direct_projection_at(
&self,
models_path: &std::path::Path,
@@ -663,10 +718,34 @@ impl ProxyService {
next.pi_config_dir.as_deref(),
)?;
if old_models_path == new_models_path || !existing.pi_takeover_enabled {
if old_models_path == new_models_path {
return crate::settings::update_settings(next);
}
// Prompt operations and directory ownership share one sendable mutex.
// Holding it across runtime publication prevents a prompt write from
// committing against the new root while a failed directory move is
// rolling settings and the DB selection back to the old root.
let prompt_guard = lock_instruction_files_async().await;
let previous_prompts = self.db.get_prompts(AppType::Pi.as_str())?;
if !existing.pi_takeover_enabled {
crate::settings::update_settings(next)?;
if let Err(error) =
PromptService::reconcile_pi_native_under_guard(self.db.as_ref(), &prompt_guard)
{
let settings_restored = crate::settings::update_settings(existing.clone()).is_ok();
let prompts_restored = self
.db
.save_prompt_selection(AppType::Pi.as_str(), &previous_prompts)
.is_ok();
return Err(AppError::Config(format!(
"failed to reconcile Pi prompts in the new directory: {error}; rollback: settings={settings_restored}, prompts={prompts_restored}"
)));
}
return Ok(());
}
// Admission closes before the old native file is restored. A Pi
// process that already loaded the old gateway projection therefore
// cannot enter a catalog whose directory ownership is in flight.
@@ -689,18 +768,36 @@ impl ProxyService {
}));
}
if let Err(error) =
PromptService::reconcile_pi_native_under_guard(self.db.as_ref(), &prompt_guard)
{
let settings_restored = crate::settings::update_settings(existing.clone()).is_ok();
let prompts_restored = self
.db
.save_prompt_selection(AppType::Pi.as_str(), &previous_prompts)
.is_ok();
let runtime_restored = self.reconcile_pi_runtime_at_epoch(epoch).await.is_ok();
return Err(AppError::Config(format!(
"failed to reconcile Pi prompts in the new directory: {error}; rollback: settings={settings_restored}, prompts={prompts_restored}, gateway={runtime_restored}"
)));
}
if let Err(error) = self.reconcile_pi_runtime_at_epoch(epoch).await {
let new_direct_restored = self
.restore_pi_direct_projection_at(&new_models_path)
.is_ok();
let settings_restored = crate::settings::update_settings(existing.clone()).is_ok();
let prompts_restored = self
.db
.save_prompt_selection(AppType::Pi.as_str(), &previous_prompts)
.is_ok();
let old_gateway_restored = if settings_restored {
self.reconcile_pi_runtime_at_epoch(epoch).await.is_ok()
} else {
self.pi_runtime.republish_current(epoch).await.is_ok()
};
return Err(AppError::Config(format!(
"failed to publish Pi in the new native directory: {error}; rollback: new_direct={new_direct_restored}, settings={settings_restored}, old_gateway={old_gateway_restored}"
"failed to publish Pi in the new native directory: {error}; rollback: new_direct={new_direct_restored}, settings={settings_restored}, prompts={prompts_restored}, old_gateway={old_gateway_restored}"
)));
}
@@ -886,23 +983,9 @@ impl ProxyService {
"Pi desired takeover could not be reconciled after listener start: {error}; direct-mode rollback failed, so the live listener was retained: {rollback_error}"
));
}
let server = self.server.write().await.take();
*self
.pi_listener
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
let stopped = match server {
Some(server) => server.stop().await.map_err(|error| error.to_string()),
None => Ok(()),
};
return Err(match stopped {
Ok(()) => format!(
"Pi desired takeover could not be reconciled after listener start; direct mode was restored: {error}"
),
Err(stopped) => format!(
"Pi desired takeover could not be reconciled after listener start: {error}; direct mode was restored but listener shutdown failed: {stopped}"
),
});
return Err(format!(
"Pi desired takeover could not be reconciled after listener start; direct mode was restored and the shared listener was retained: {error}"
));
}
}
@@ -1102,6 +1185,24 @@ impl ProxyService {
// OpenCode and OpenClaw don't support proxy features, always return false
let opencode_enabled = false;
let openclaw_enabled = false;
let pi_enabled = crate::settings::pi_takeover_enabled();
let pi_operational_state = if !pi_enabled {
PiTakeoverOperationalState::Disabled
} else {
let listener = self
.pi_listener
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
if listener
.as_ref()
.is_some_and(|listener| self.pi_runtime.is_admitting(listener.server_generation))
{
PiTakeoverOperationalState::Active
} else {
PiTakeoverOperationalState::Degraded
}
};
Ok(ProxyTakeoverStatus {
claude: claude_enabled,
@@ -1110,7 +1211,8 @@ impl ProxyService {
grokbuild: grokbuild_enabled,
opencode: opencode_enabled,
openclaw: openclaw_enabled,
pi: crate::settings::pi_takeover_enabled(),
pi: pi_enabled,
pi_operational_state,
})
}
@@ -1310,6 +1412,12 @@ impl ProxyService {
async fn set_pi_takeover_locked(&self, enabled: bool) -> Result<(), String> {
if enabled {
let was_enabled = crate::settings::pi_takeover_enabled();
if !was_enabled {
// Desired intent is durable before bind/token/projection work.
// Operational failures remain retryable on the next startup.
crate::settings::set_pi_takeover_enabled(true)
.map_err(|error| error.to_string())?;
}
if !self.is_running().await {
self.start_with_pi_lock_held().await?;
}
@@ -1319,15 +1427,21 @@ impl ProxyService {
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_none()
{
let epoch = self.pi_runtime.begin_mutation().await;
let _ = self.pi_runtime.close(epoch).await;
return Err(
"Pi gateway requires the proxy listener to bind an explicit loopback address"
.to_string(),
);
}
// Persist the stable secret before changing desired state. A
// failure here leaves both native projection and admission intact.
crate::settings::get_or_create_pi_gateway_token().map_err(|error| error.to_string())?;
crate::settings::set_pi_takeover_enabled(true).map_err(|error| error.to_string())?;
// Token creation is stable and occurs only after a listener exists.
if let Err(error) = crate::settings::get_or_create_pi_gateway_token() {
if !was_enabled {
let epoch = self.pi_runtime.begin_mutation().await;
let _ = self.pi_runtime.close(epoch).await;
}
return Err(error.to_string());
}
if let Err(error) = self.reconcile_pi_runtime().await {
if was_enabled {
@@ -1347,16 +1461,15 @@ impl ProxyService {
let direct_projection = self.restore_pi_direct_projection();
match direct_projection {
Ok(()) => {
let desired_restored = crate::settings::set_pi_takeover_enabled(false);
let epoch = self.pi_runtime.begin_mutation().await;
let admission_closed = self.pi_runtime.close(epoch).await;
return Err(if desired_restored.is_ok() && admission_closed.is_ok() {
return Err(if admission_closed.is_ok() {
format!(
"failed to publish Pi gateway catalog; direct mode was restored: {error}"
"failed to publish Pi gateway catalog; direct mode was restored and desired takeover remains pending: {error}"
)
} else {
format!(
"failed to publish Pi gateway catalog and fully restore direct mode: {error}"
"failed to publish Pi gateway catalog and close Pi admission: {error}"
)
});
}
@@ -1367,7 +1480,7 @@ impl ProxyService {
// explicit instead of claiming a successful rollback
// while models.json still points local.
let epoch = self.pi_runtime.begin_mutation().await;
let _ = self.pi_runtime.republish_current(epoch).await;
let _ = self.pi_runtime.close(epoch).await;
return Err(format!(
"failed to publish Pi gateway catalog: {error}; failed to restore the native direct projection: {restore_error}"
));
@@ -3984,6 +4097,8 @@ mod tests {
let new_dir = home.dir.path().join("new-pi");
std::fs::create_dir_all(&old_dir).expect("old Pi directory");
std::fs::create_dir_all(&new_dir).expect("new Pi directory");
std::fs::write(old_dir.join("AGENTS.md"), "old-root-agents").expect("old Pi AGENTS.md");
std::fs::write(new_dir.join("AGENTS.md"), "new-root-agents").expect("new Pi AGENTS.md");
let direct = json!({
"name": "Managed Pi",
@@ -4008,6 +4123,32 @@ mod tests {
crate::settings::update_settings(settings).expect("set old Pi directory");
let db = Arc::new(Database::memory().expect("in-memory database"));
db.save_prompt(
AppType::Pi.as_str(),
&crate::prompt::Prompt {
id: "old-root".to_string(),
name: "Old root".to_string(),
content: "old-root-agents".to_string(),
description: None,
enabled: true,
created_at: Some(1),
updated_at: Some(1),
},
)
.expect("old prompt");
db.save_prompt(
AppType::Pi.as_str(),
&crate::prompt::Prompt {
id: "new-root".to_string(),
name: "New root".to_string(),
content: "new-root-agents".to_string(),
description: None,
enabled: false,
created_at: Some(2),
updated_at: Some(2),
},
)
.expect("new prompt");
use_ephemeral_proxy_port(&db).await;
let input = ProviderMutationInput {
id: "managed-pi".to_string(),
@@ -4029,7 +4170,7 @@ mod tests {
)
.expect("seed Pi catalog");
let service = ProxyService::new(db);
let service = ProxyService::new(db.clone());
service
.set_takeover_for_app("pi", true)
.await
@@ -4070,6 +4211,18 @@ mod tests {
crate::settings::get_settings().pi_config_dir.as_deref(),
Some(new_dir.to_string_lossy().as_ref())
);
let prompts = db.get_prompts(AppType::Pi.as_str()).expect("Pi prompts");
assert!(!prompts["old-root"].enabled);
assert!(prompts["new-root"].enabled);
assert_eq!(
std::fs::read_to_string(old_dir.join("AGENTS.md")).expect("old AGENTS"),
"old-root-agents",
"directory changes must not migrate or clean the old native file"
);
assert_eq!(
std::fs::read_to_string(new_dir.join("AGENTS.md")).expect("new AGENTS"),
"new-root-agents"
);
service
.set_takeover_for_app("pi", false)
@@ -4077,6 +4230,60 @@ mod tests {
.expect("disable Pi takeover");
}
#[tokio::test]
#[serial]
async fn changing_pi_directory_without_takeover_reconciles_missing_agents_truth() {
let home = TempHome::new();
crate::settings::reload_settings().expect("reload isolated settings");
let old_dir = home.dir.path().join("old-direct-pi");
let new_dir = home.dir.path().join("new-direct-pi");
std::fs::create_dir_all(&old_dir).expect("old Pi directory");
std::fs::create_dir_all(&new_dir).expect("new Pi directory");
std::fs::write(old_dir.join("AGENTS.md"), "old-only").expect("old AGENTS");
let mut settings = crate::settings::get_settings();
settings.pi_config_dir = Some(old_dir.to_string_lossy().into_owned());
settings.pi_takeover_enabled = false;
crate::settings::update_settings(settings).expect("old directory settings");
let db = Arc::new(Database::memory().expect("database"));
db.save_prompt(
AppType::Pi.as_str(),
&crate::prompt::Prompt {
id: "old-only".to_string(),
name: "Old only".to_string(),
content: "old-only".to_string(),
description: None,
enabled: true,
created_at: Some(1),
updated_at: Some(1),
},
)
.expect("prompt");
let service = ProxyService::new(db.clone());
let switch_guard = service.lock_switch_for_app(AppType::Pi.as_str()).await;
let existing = crate::settings::get_settings();
let mut next = existing.clone();
next.pi_config_dir = Some(new_dir.to_string_lossy().into_owned());
service
.replace_settings_with_pi_directory_boundary_under_lock(&switch_guard, &existing, next)
.await
.expect("change direct Pi directory");
assert!(
db.get_prompts(AppType::Pi.as_str())
.expect("prompts")
.values()
.all(|prompt| !prompt.enabled),
"a missing AGENTS.md in the new root is the inactive authority"
);
assert_eq!(
std::fs::read_to_string(old_dir.join("AGENTS.md")).expect("old AGENTS survives"),
"old-only"
);
assert!(!new_dir.join("AGENTS.md").exists());
}
#[tokio::test]
#[serial]
async fn failed_listener_rebind_recovers_pi_on_the_previous_listener() {
@@ -4199,6 +4406,56 @@ mod tests {
.expect("disable Pi takeover");
}
#[tokio::test]
#[serial]
async fn initial_pi_bind_failure_keeps_desired_state_and_reports_degraded() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload isolated settings");
let mut settings = crate::settings::get_settings();
settings.pi_config_dir = Some(
crate::config::get_home_dir()
.join(".pi/agent")
.to_string_lossy()
.into_owned(),
);
settings.pi_takeover_enabled = false;
crate::settings::update_settings(settings).expect("Pi settings");
let occupied = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("reserve port");
let db = Arc::new(Database::memory().expect("database"));
let mut proxy_config = db.get_proxy_config().await.expect("proxy config");
proxy_config.listen_address = "127.0.0.1".to_string();
proxy_config.listen_port = occupied.local_addr().expect("address").port();
db.update_proxy_config(proxy_config)
.await
.expect("fixed occupied port");
let service = ProxyService::new(db);
service
.set_takeover_for_app("pi", true)
.await
.expect_err("occupied port must fail");
assert!(
crate::settings::pi_takeover_enabled(),
"bind failure must not erase user intent"
);
let status = service.get_takeover_status().await.expect("status");
assert!(status.pi);
assert_eq!(
status.pi_operational_state,
PiTakeoverOperationalState::Degraded
);
assert!(!service.is_running().await);
drop(occupied);
service
.set_takeover_for_app("pi", false)
.await
.expect("explicit disable clears desired state");
}
fn seed_codex_model_template() {
let codex_dir = crate::codex_config::get_codex_config_dir();
std::fs::create_dir_all(&codex_dir).expect("create codex dir");