mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
fix(pi): restore catalog and takeover authority boundaries
This commit is contained in:
+287
-30
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user