From 52e0fa8a6c584198d73f950baac6c792c00800cc Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 30 Sep 2026 10:22:20 +0800 Subject: [PATCH] feat(proxy): warn when Codex clients hold an old Stack model list Codex's app-server reads the model catalog once at startup and keeps it, while config.toml is re-read per request. Since 0.159 the codex CLI talks to a managed daemon that only restarts on upgrade, so reopening codex after a Stack change still shows the old model list; the desktop app and editor extensions have the same problem with their own app-servers. In Codex Stack mode the provider list now shows a banner when a running Codex client started before the current catalog was written: - CLI daemon: a button restarts it through Codex's own `codex app-server daemon restart`, after a confirmation that running tasks will be interrupted. Nothing runs when the daemon is not up. - Desktop app / editor extensions: the banner explains how to fully quit and reopen them; CC Switch does not restart them. Staleness is judged against a device-local history of catalog generations (codex-catalog-history.json) rather than file times: an unchanged catalog is not rewritten, and quitting CC Switch removes the catalog pointer and restores it on the next start, so a daemon that survived a CC Switch restart still has the current catalog. Every Codex write and startup records the catalog a newly started Codex would read; processes that started before the oldest recorded generation count as stale. Processes are read with ps on macOS and Linux only; Windows shows no banner for now. --- src-tauri/src/commands/proxy.rs | 18 +- src-tauri/src/lib.rs | 1 + src-tauri/src/live/project/codex.rs | 9 + src-tauri/src/mode/controller.rs | 149 ++++ src-tauri/src/mode/stack.rs | 4 + .../services/provider/codex_client_catalog.rs | 760 ++++++++++++++++++ .../src/services/provider/codex_direct.rs | 5 +- src-tauri/src/services/provider/mod.rs | 1 + .../providers/CodexStaleClientsNotice.tsx | 71 ++ src/components/providers/ProviderList.tsx | 6 + src/i18n/locales/en.json | 14 +- src/i18n/locales/ja.json | 14 +- src/i18n/locales/zh-TW.json | 14 +- src/i18n/locales/zh.json | 14 +- src/lib/api/proxy.ts | 7 + src/lib/query/proxy.ts | 34 + src/types/proxy.ts | 13 + .../CodexStaleClientsNotice.test.tsx | 133 +++ tests/components/ProviderList.test.tsx | 64 ++ tests/msw/handlers.ts | 4 + 20 files changed, 1327 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/services/provider/codex_client_catalog.rs create mode 100644 src/components/providers/CodexStaleClientsNotice.tsx create mode 100644 tests/components/CodexStaleClientsNotice.test.tsx diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index a9460afce..3d05aa8ec 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -95,14 +95,26 @@ pub fn get_direct_provider( crate::mode::controller::direct_provider_id(state.inner(), &app).map_err(|e| e.to_string()) } -/// Stack 模型:名单里的每一家和它发布的模型 id +/// Stack 模型:名单里的每一家和它发布的模型 id,以及还在用旧模型列表的 Codex 客户端 #[tauri::command] -pub fn get_proxy_stack( +pub async fn get_proxy_stack( state: tauri::State<'_, AppState>, app_type: String, ) -> Result { let app = require_proxy_app(&app_type)?; - crate::mode::controller::stack_views(state.inner(), &app) + crate::mode::controller::stack_view_with_clients(state.inner(), &app).await +} + +/// 重启 Codex 的托管守护进程(`codex` TUI 连的那个),让它重读模型目录。会中断守护进程里 +/// 正在运行的任务,只在用户确认之后调。 +#[tauri::command] +pub async fn restart_codex_app_server_daemon( +) -> Result { + crate::services::provider::codex_direct::off_runtime( + crate::services::provider::codex_client_catalog::restart_daemon, + ) + .await + .map_err(|error| error.to_string())? } /// Stack 模型:把一家加入或移出名单(`enabled` 是目标值)。成功时返回客户端看不到或看不全 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd17f5ea2..27b342117 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1586,6 +1586,7 @@ pub fn run() { commands::switch_proxy_provider, commands::get_proxy_stack, commands::set_proxy_stack_member, + commands::restart_codex_app_server_daemon, // Proxy failover commands commands::get_provider_health, commands::reset_circuit_breaker, diff --git a/src-tauri/src/live/project/codex.rs b/src-tauri/src/live/project/codex.rs index b79178601..d8532a7ad 100644 --- a/src-tauri/src/live/project/codex.rs +++ b/src-tauri/src/live/project/codex.rs @@ -550,6 +550,15 @@ pub fn live_catalog_is_foreign(config_text: &str) -> bool { }) } +/// live 的 `model_catalog_json` 指向 CC Switch 生成的目录:新启动的 Codex 读的是它。 +pub fn live_catalog_is_ours(config_text: &str) -> bool { + config_text.parse::().ok().is_some_and(|doc| { + doc.get(MODEL_CATALOG_JSON) + .and_then(Item::as_str) + .is_some_and(is_cc_switch_catalog) + }) +} + /// 行里自己指定的模型目录指针(投影的 `top` 只收不是 CC Switch 的指针)。它和独有字段 /// 一样跟着这一家走:切走时 live 里的值还相同就删(见 [`CodexConfigPatch::outgoing`]), /// 否则第 1 步会把它当成用户的指针留下,之后每一家都用它的模型目录。 diff --git a/src-tauri/src/mode/controller.rs b/src-tauri/src/mode/controller.rs index c05566f92..e02ccd98c 100644 --- a/src-tauri/src/mode/controller.rs +++ b/src-tauri/src/mode/controller.rs @@ -31,6 +31,7 @@ use crate::live::project::claude::{ use crate::live::project::gemini::GeminiProjection; use crate::live::project::grok::GrokProjection; use crate::provider::Provider; +use crate::services::provider::codex_client_catalog; use crate::services::provider::codex_direct::{self, Owner}; use crate::services::provider::codex_official_models; use crate::services::provider::{claude_direct, gemini_direct, grok_direct}; @@ -1008,9 +1009,40 @@ pub fn stack_views(state: &AppState, app: &AppType) -> Result active: mode.is_proxy() && stack.enabled, members: stack::member_views(&members), notice, + stale_clients: None, }) } +/// [`stack_views`] 再加上 Codex 客户端是不是还在用旧的模型列表(要读进程表,放到阻塞线程池 +/// 里)。路由那家自己管理目录、或者用户自己指定了目录时,Stack 模型本来就不发布或不生效,重启 +/// 也看不到,已经有 `notice` 说明,不再查。 +pub async fn stack_view_with_clients(state: &AppState, app: &AppType) -> Result { + let mut view = stack_views(state, app)?; + if matches!(app, AppType::Codex) + && view.active + && !matches!(view.notice, Some("routeOwnsCatalog" | "configOwnsCatalog")) + && codex_publishes_stack_models(state, &settled_stack(app)?) + { + view.stale_clients = codex_direct::off_runtime(|| { + codex_client_catalog::stale_clients(&DeviceStore::for_device()) + }) + .await + .map_err(err)?; + } + Ok(view) +} + +/// Codex 接着代理,名单里有要发布的 Stack 模型。 +fn codex_publishes_stack_models(state: &AppState, stack: &StackState) -> bool { + attached_route(state, &AppType::Codex) + .ok() + .flatten() + .is_some_and(|(_, route)| { + stack::published_members(&state.db, &AppType::Codex, stack, Some(&route.id)) + .is_ok_and(|published| !published.is_empty()) + }) +} + /// Codex 在 Stack 模式下有要发布的 Stack 模型,客户端却看不到或看不全:路由那家自己管理模型 /// 目录文件(Stack 模型不发布);`config.toml` 里用户自己指定了模型目录(生成的目录不生效); /// 或者官方做默认、最近一次写目录时没拿到官方列表。 @@ -1224,6 +1256,8 @@ pub async fn startup(state: &AppState) { } // 接上失败退回直连的应用可能已经把代理拉起来了。 stop_server_if_unused(state).await; + // 记一次新启动的 Codex 会读到的目录:兜住启动时补完的操作和 CC Switch 没开时的外部修改。 + codex_client_catalog::observe(&DeviceStore::for_device()); } async fn startup_app(state: &AppState, app: &AppType) -> Result<(), String> { @@ -5348,6 +5382,121 @@ model_provider = "c" assert_eq!(notice, None); } + /// 假的进程表和时钟(见 `codex_client_catalog::Env`),结束时换回真的。 + struct FakeClients { + now_ms: Arc, + table: Arc>, + } + + impl FakeClients { + const START_MS: u64 = 1_800_000_000_000; + + fn install() -> Self { + let now_ms = Arc::new(std::sync::atomic::AtomicU64::new(Self::START_MS)); + let table = Arc::new(std::sync::Mutex::new(String::new())); + let (clock, rows) = (now_ms.clone(), table.clone()); + codex_client_catalog::set_test_env(codex_client_catalog::Env { + process_table: Box::new(move || Some(rows.lock().unwrap().clone())), + now_ms: Box::new(move || clock.load(std::sync::atomic::Ordering::SeqCst)), + restart: Box::new(|_| Err("not in tests".to_string())), + }); + Self { now_ms, table } + } + + /// 桌面版的 app-server,已经跑了 `etime`(`ps` 的格式)。 + fn desktop_running_for(&self, etime: &str) { + *self.table.lock().unwrap() = format!( + "62347 {etime} /Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex -c features.code_mode_host=true app-server" + ); + } + + fn advance(&self, ms: u64) { + self.now_ms + .fetch_add(ms, std::sync::atomic::Ordering::SeqCst); + } + } + + impl Drop for FakeClients { + fn drop(&mut self) { + codex_client_catalog::reset_test_env(); + } + } + + async fn stale_clients_of(state: &AppState) -> Option { + stack_view_with_clients(state, &AppType::Codex) + .await + .expect("stack view") + .stale_clients + } + + /// 桌面版在目录变化之前启动:Stack 视图带上 `staleClients`;之后启动的不算旧。路由模式、 + /// 路由那家自己管理目录(Stack 模型本来就不发布)时不查。 + #[tokio::test] + #[serial] + async fn codex_stack_view_reports_clients_on_an_old_catalog() { + let _home = Home::new(); + let clients = FakeClients::install(); + seed_codex("", None); + clients.desktop_running_for("10:00"); + let state = state_with(AppType::Codex, &codex_stack_rows(), "a").await; + enter(&state, &AppType::Codex, true).await.expect("enter"); + set_codex_member(&state, "deepseek", true).await; + assert_eq!( + stale_clients_of(&state).await, + Some(codex_client_catalog::StaleClients { + daemon: false, + others: true + }) + ); + // 普通的 Stack 视图不读进程表。 + assert_eq!( + stack_views(&state, &AppType::Codex).unwrap().stale_clients, + None + ); + + // 桌面版重开之后启动:读到的正是现在这份目录。 + clients.advance(10_000); + clients.desktop_running_for("00:05"); + assert_eq!(stale_clients_of(&state).await, None); + + // 目录又变了(再加一家):刚才那个桌面版又旧了。 + clients.advance(10_000); + set_codex_member(&state, "zhipu", true).await; + clients.desktop_running_for("00:15"); + assert!(stale_clients_of(&state).await.is_some()); + + // 换成路由模式:不是 Stack 模式,不查。 + enter(&state, &AppType::Codex, false) + .await + .expect("routing"); + clients.desktop_running_for("10:00"); + assert_eq!(stale_clients_of(&state).await, None); + } + + #[tokio::test] + #[serial] + async fn codex_route_with_its_own_catalog_skips_the_client_check() { + let _home = Home::new(); + let clients = FakeClients::install(); + seed_codex("", None); + clients.desktop_running_for("10:00"); + let [_, deepseek, zhipu] = codex_stack_rows(); + let route = codex_native( + "a", + "https://a.example/v1", + "model_catalog_json = \"/opt/team/models.json\"\n", + None, + ); + let state = state_with(AppType::Codex, &[route, deepseek, zhipu], "a").await; + enter(&state, &AppType::Codex, true).await.expect("enter"); + set_codex_member(&state, "deepseek", true).await; + let view = stack_view_with_clients(&state, &AppType::Codex) + .await + .unwrap(); + assert_eq!(view.notice, Some("routeOwnsCatalog")); + assert_eq!(view.stale_clients, None); + } + /// 用户直接在 config.toml 里指定的模型目录:写入时照留,生成的目录不生效,同样要提示。 #[tokio::test] #[serial] diff --git a/src-tauri/src/mode/stack.rs b/src-tauri/src/mode/stack.rs index 4de9a3203..286b5c16a 100644 --- a/src-tauri/src/mode/stack.rs +++ b/src-tauri/src/mode/stack.rs @@ -26,6 +26,7 @@ use crate::live::engine::DeviceStore; use crate::live::project::claude::{env_string, has_one_m_marker, ONE_M_MARKER_FOR_CLIENT}; use crate::provider::{ClaudeStackModel, Provider}; use crate::proxy::model_mapper::strip_one_m_suffix_for_upstream; +use crate::services::provider::codex_client_catalog::StaleClients; use super::state::{self, StackState}; @@ -596,6 +597,9 @@ pub struct StackView { /// 暂不可用。 #[serde(skip_serializing_if = "Option::is_none")] pub notice: Option<&'static str>, + /// Codex 客户端还在用旧的模型列表(启动时读的目录),Stack 模型看不到,要重启才行。 + #[serde(skip_serializing_if = "Option::is_none")] + pub stale_clients: Option, } pub fn member_views(members: &[Member]) -> Vec { diff --git a/src-tauri/src/services/provider/codex_client_catalog.rs b/src-tauri/src/services/provider/codex_client_catalog.rs new file mode 100644 index 000000000..e4c13b87a --- /dev/null +++ b/src-tauri/src/services/provider/codex_client_catalog.rs @@ -0,0 +1,760 @@ +//! Codex 客户端是不是还在用旧的模型目录,以及重启 Codex 的托管守护进程。 +//! +//! Codex 的 app-server 只在启动时读一次模型目录(codex-rs `app-server/src/model_catalog.rs`: +//! 「retained startup model catalog」),之后每个请求重读 `config.toml`:路由跟着变,模型列表 +//! 不变。0.159 起 `codex` TUI 默认连一个托管守护进程(`codex app-server --managed-daemon`), +//! 它只在 Codex 升级时重启,关掉再开 `codex` 不会重读;桌面版、编辑器插件自带的 app-server +//! 也要整个重开才会重读。 +//! +//! 判断一个进程读到的是哪份目录,不能比文件时间:内容没变时引擎不重写文件,时间不动;退出 +//! CC Switch 时撤掉目录指针、下次启动再写回,时间变了,早先启动的进程读到的却正是现在这份。 +//! 所以记下目录的代次:从哪个时刻起,新启动的 Codex 会读到哪份目录([`HISTORY_FILENAME`])。 +//! 一个进程读到的,是它启动之前开始的最后一代。 +//! +//! 进程只看不动:用户在界面上确认之后,才调 Codex 自己的 `codex app-server daemon restart`。 +//! 桌面版和编辑器插件不替用户重启,只提示彻底退出再开。进程表只在 macOS、Linux 上读(`ps`), +//! Windows 上看不到进程,不出提示。 + +use std::process::Output; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::codex_config::{get_codex_config_dir, get_codex_model_catalog_path}; +use crate::live::engine::{sha256_hex, DeviceStore}; +use crate::live::project::codex::live_catalog_is_ours; + +use super::codex_direct::read_config_text; + +pub(crate) const HISTORY_FILENAME: &str = "codex-catalog-history.json"; +/// 最多记这么多代。更早启动的进程判断不了,按旧的算。 +const HISTORY_LIMIT: usize = 32; +/// 进程的启动时刻只精确到秒(`ps` 的 etime),每一代又是写完之后才记下的:启动时刻离一代的 +/// 开始不到这么久,就当它读到的是上一代。拿不准时宁可多提示一次。 +const MARGIN_MS: u64 = 2_000; +/// 新启动的 Codex 不读 CC Switch 的目录(没有目录指针,或者指向别人的目录)。 +const NO_CATALOG: &str = "none"; +/// 守护进程停机宽限期的缺省值和上限(codex-rs `app-server-daemon/src/settings.rs`)。 +const DEFAULT_SHUTDOWN_GRACE_SECS: u64 = 60; +const MAX_SHUTDOWN_GRACE_SECS: u64 = 300; +/// 宽限期之外,起新进程、等它就绪的余量(codex-rs 的 `OPERATION_LOCK_TIMEOUT` 也是在宽限期上 +/// 加 75 秒)。 +const RESTART_MARGIN: Duration = Duration::from_secs(75); + +/// 一代目录:从 `since_ms`(Unix 毫秒)起,新启动的 Codex 读到的目录。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Generation { + since_ms: u64, + fingerprint: String, +} + +/// 还在用旧模型列表的 Codex 客户端(给前端)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StaleClients { + /// 托管守护进程(`codex` TUI 连的那个):可以替用户重启。 + pub daemon: bool, + /// 其余 app-server(桌面版、编辑器插件):要用户自己彻底退出再开。 + pub others: bool, +} + +/// 重启守护进程的结果(给前端)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RestartOutcome { + Restarted, + /// 守护进程没在跑,什么都没做:`restart` 会替用户起一个新的,下次开 `codex` 时它自己会起。 + NotRunning, +} + +/// 正在跑的 app-server 的启动时刻(Unix 毫秒)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct AppServers { + daemon: Option, + others: Vec, +} + +/// 外部依赖:进程表、时钟、重启命令。测试里换成假的。 +pub(crate) struct Env { + /// `ps -Ao pid=,etime=,command=` 的输出;读不到(或在 Windows 上)是 `None`。 + pub process_table: Box Option + Send + Sync>, + /// Unix 毫秒。 + pub now_ms: Box u64 + Send + Sync>, + /// 执行 `codex app-server daemon restart`,参数是超时。 + pub restart: Box Result + Send + Sync>, +} + +impl Env { + fn real() -> Self { + Self { + process_table: Box::new(read_process_table), + now_ms: Box::new(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or_default() + }), + restart: Box::new(run_restart), + } + } +} + +fn env_slot() -> &'static Mutex>> { + static SLOT: OnceLock>>> = OnceLock::new(); + SLOT.get_or_init(|| Mutex::new(None)) +} + +fn env() -> Arc { + let mut slot = env_slot() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + slot.get_or_insert_with(|| Arc::new(Env::real())).clone() +} + +#[cfg(test)] +pub(crate) fn set_test_env(env: Env) { + *env_slot() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::new(env)); +} + +#[cfg(test)] +pub(crate) fn reset_test_env() { + *env_slot() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; +} + +#[cfg(unix)] +fn read_process_table() -> Option { + // `-ww`:输出不是终端时 Linux 的 procps 也会截断命令行。 + let output = std::process::Command::new("ps") + .args(["-ww", "-Ao", "pid=,etime=,command="]) + .env("LC_ALL", "C") + .stdin(std::process::Stdio::null()) + .output() + .map_err(|error| log::debug!("读取进程表失败: {error}")) + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).into_owned()) +} + +#[cfg(not(unix))] +fn read_process_table() -> Option { + None +} + +fn run_restart(timeout: Duration) -> Result { + // 重启的必须是读 CC Switch 写的这份配置的守护进程(配置目录可能被覆盖到别处)。 + let codex_dir = get_codex_config_dir(); + let extra_env = [("CODEX_HOME", codex_dir.to_string_lossy().into_owned())]; + crate::commands::run_detected_tool_command_with_timeout( + "codex", + &["app-server", "daemon", "restart"], + Some(timeout), + &extra_env, + &codex_dir, + ) +} + +/// 历史文件的读改写只在一个线程里做(写入和查询会同时记)。 +fn history_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +fn read_history(store: &DeviceStore) -> Vec { + let path = store.file(HISTORY_FILENAME); + let Ok(bytes) = std::fs::read(&path) else { + return Vec::new(); + }; + serde_json::from_slice(&bytes).unwrap_or_else(|error| { + log::warn!( + "Codex 模型目录的变化记录 {} 无法解析,当作空的: {error}", + path.display() + ); + Vec::new() + }) +} + +/// 现在的目录和最后一代不同就记成新的一代,返回记完之后的历史。尽力而为:写不进去只打日志, +/// 下次再记(判断会偏向「旧」)。 +fn record(store: &DeviceStore, fingerprint: &str, now_ms: u64) -> Vec { + let _guard = history_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut history = read_history(store); + if history + .last() + .is_some_and(|last| last.fingerprint == fingerprint) + { + return history; + } + history.push(Generation { + since_ms: now_ms, + fingerprint: fingerprint.to_string(), + }); + let excess = history.len().saturating_sub(HISTORY_LIMIT); + history.drain(..excess); + if let Err(error) = crate::config::write_json_file(&store.file(HISTORY_FILENAME), &history) { + log::warn!("记录 Codex 模型目录的变化失败: {error}"); + } + history +} + +/// 新启动的 Codex 现在会读到的目录:`config.toml` 顶层指向 CC Switch 的目录时是文件内容的 +/// hash,否则是 [`NO_CATALOG`]。 +fn current_fingerprint() -> String { + if !live_catalog_is_ours(&read_config_text()) { + return NO_CATALOG.to_string(); + } + std::fs::read(get_codex_model_catalog_path()) + .map(|bytes| sha256_hex(&bytes)) + .unwrap_or_else(|_| NO_CATALOG.to_string()) +} + +/// 记下新启动的 Codex 现在会读到的目录。Codex 的客户端文件每写一次(直连、进出代理、Stack +/// 增删、退出时写回)和启动接上之后各调一次。 +pub(crate) fn observe(store: &DeviceStore) { + record(store, ¤t_fingerprint(), (env().now_ms)()); +} + +/// 还在用旧目录的 Codex 客户端。都是新的,或者新启动的 Codex 本来就不读 CC Switch 的目录时 +/// 为 `None`。要读进程表,放到阻塞线程池里调。 +pub(crate) fn stale_clients(store: &DeviceStore) -> Option { + let env = env(); + let current = current_fingerprint(); + // 顺手记一次:兜住在 CC Switch 之外改了目录的情况。 + let history = record(store, ¤t, (env.now_ms)()); + if current == NO_CATALOG { + return None; + } + judge(&history, ¤t, &probe(&env)) +} + +fn judge(history: &[Generation], current: &str, servers: &AppServers) -> Option { + let daemon = servers + .daemon + .is_some_and(|started| is_stale(history, current, started)); + let others = servers + .others + .iter() + .any(|&started| is_stale(history, current, started)); + (daemon || others).then_some(StaleClients { daemon, others }) +} + +/// 启动于 `started_ms` 的进程读到的是不是别的目录。早于记下的第一代、判断不了的按旧的算。 +fn is_stale(history: &[Generation], current: &str, started_ms: u64) -> bool { + history + .iter() + .rev() + .find(|generation| generation.since_ms.saturating_add(MARGIN_MS) <= started_ms) + .is_none_or(|generation| generation.fingerprint != current) +} + +fn probe(env: &Env) -> AppServers { + let Some(table) = (env.process_table)() else { + return AppServers::default(); + }; + let plugins = format!("{}/", get_codex_config_dir().join("plugins").display()); + classify(&table, daemon_pid(), &plugins, (env.now_ms)()) +} + +/// 守护进程自己记的 pid(`/app-server-daemon/daemon.pid`)。 +fn daemon_pid() -> Option { + let path = get_codex_config_dir() + .join("app-server-daemon") + .join("daemon.pid"); + let bytes = std::fs::read(path).ok()?; + serde_json::from_slice::(&bytes) + .ok()? + .get("pid")? + .as_u64() + .and_then(|pid| u32::try_from(pid).ok()) +} + +/// 从进程表里挑出 app-server。守护进程要 pid 和 `daemon.pid` 对得上、命令行还是 +/// `--managed-daemon`(防止 pid 被别的进程复用),别的配置目录的守护进程不算。`plugins_dir` +/// 下的是 Chrome 插件自带的,没有模型选择器,不算。 +fn classify(table: &str, daemon_pid: Option, plugins_dir: &str, now_ms: u64) -> AppServers { + let mut servers = AppServers::default(); + for line in table.lines() { + let Some((pid, elapsed_secs, command)) = split_row(line) else { + continue; + }; + let Some(role) = app_server_role(command) else { + continue; + }; + let started = now_ms.saturating_sub(elapsed_secs.saturating_mul(1000)); + match role { + Role::Daemon if Some(pid) == daemon_pid => servers.daemon = Some(started), + Role::Daemon => {} + Role::Other if command.starts_with(plugins_dir) => {} + Role::Other => servers.others.push(started), + } + } + servers +} + +/// `pid etime command` 一行。 +fn split_row(line: &str) -> Option<(u32, u64, &str)> { + let (pid, rest) = line.trim_start().split_once(char::is_whitespace)?; + let (etime, command) = rest.trim_start().split_once(char::is_whitespace)?; + Some((pid.parse().ok()?, parse_etime(etime)?, command.trim_start())) +} + +/// `ps` 的 etime:`[[dd-]hh:]mm:ss`,换成秒。 +fn parse_etime(value: &str) -> Option { + let (days, clock) = match value.split_once('-') { + Some((days, clock)) => (days.parse::().ok()?, clock), + None => (0, value), + }; + let parts: Vec<&str> = clock.split(':').collect(); + if !(2..=3).contains(&parts.len()) { + return None; + } + let mut secs = 0u64; + for part in parts { + secs = secs * 60 + part.parse::().ok()?; + } + Some(days * 86_400 + secs) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Role { + Daemon, + Other, +} + +/// `…/codex [-c 键=值 …] app-server …` 是 app-server;`app-server daemon …`、`app-server proxy` +/// 是管理守护进程的子命令,不读模型目录。命令行是 `ps` 用空格拼起来的,分不出路径里的空格, +/// 所以先找可执行文件名 `codex`,再看它后面的参数。 +fn app_server_role(command: &str) -> Option { + let mut words = after_codex_executable(command)?.split_whitespace(); + let subcommand = loop { + match words.next()? { + "-c" | "--config" => { + words.next()?; + } + flag if flag.starts_with('-') => {} + word => break word, + } + }; + if subcommand != "app-server" { + return None; + } + let rest: Vec<&str> = words.collect(); + if matches!(rest.first(), Some(&"daemon") | Some(&"proxy")) { + return None; + } + Some(if rest.contains(&"--managed-daemon") { + Role::Daemon + } else { + Role::Other + }) +} + +/// 命令行里可执行文件 `codex`(`codex` 或 `…/codex`)之后的部分。 +fn after_codex_executable(command: &str) -> Option<&str> { + const NAME: &str = "codex"; + let mut from = 0; + while let Some(found) = command[from..].find(NAME) { + let start = from + found; + let end = start + NAME.len(); + let rest = &command[end..]; + if (start == 0 || command[..start].ends_with('/')) + && (rest.is_empty() || rest.starts_with(' ')) + { + return Some(rest); + } + from = end; + } + None +} + +/// 用户确认之后重启托管守护进程:Codex 自己的 `codex app-server daemon restart`,先等守护进程 +/// 里在跑的任务收尾(最多一个宽限期),再起新的。守护进程没在跑就什么都不做。 +pub(crate) fn restart_daemon() -> Result { + let env = env(); + if probe(&env).daemon.is_none() { + return Ok(RestartOutcome::NotRunning); + } + let output = (env.restart)(restart_timeout())?; + let stdout = crate::commands::decode_command_output(&output.stdout); + if !output.status.success() { + let stderr = crate::commands::decode_command_output(&output.stderr); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + return Err(if detail.is_empty() { + "重启 Codex 守护进程失败 (Failed to restart the Codex daemon)".to_string() + } else { + format!("重启 Codex 守护进程失败 (Failed to restart the Codex daemon): {detail}") + }); + } + Ok(match lifecycle_status(&stdout).as_deref() { + Some("notRunning") => RestartOutcome::NotRunning, + _ => RestartOutcome::Restarted, + }) +} + +/// `codex app-server daemon` 的子命令在 stdout 最后一行打一个 JSON(codex-rs `LifecycleOutput`)。 +fn lifecycle_status(stdout: &str) -> Option { + let line = stdout.lines().rev().find(|line| !line.trim().is_empty())?; + serde_json::from_str::(line.trim()) + .ok()? + .get("status")? + .as_str() + .map(str::to_string) +} + +/// 守护进程的停机宽限期(`/app-server-daemon/settings.json` 的 +/// `shutdownGraceSeconds`)加上起新进程的余量。 +fn restart_timeout() -> Duration { + let grace = std::fs::read( + get_codex_config_dir() + .join("app-server-daemon") + .join("settings.json"), + ) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|settings| settings.get("shutdownGraceSeconds")?.as_u64()) + .unwrap_or(DEFAULT_SHUTDOWN_GRACE_SECS) + .min(MAX_SHUTDOWN_GRACE_SECS); + Duration::from_secs(grace) + RESTART_MARGIN +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + + const DAEMON: &str = + "/Users/me/.codex/packages/app-server-daemon/releases/0.159.2-aarch64-apple-darwin/bin/codex"; + + /// 临时 home(历史文件和 Codex 目录都落在这里),结束时换回真的依赖。 + struct Scope { + dir: tempfile::TempDir, + saved: Option, + } + + impl Scope { + fn new() -> Self { + let dir = tempfile::TempDir::new().unwrap(); + let saved = std::env::var_os("CC_SWITCH_TEST_HOME"); + std::env::set_var("CC_SWITCH_TEST_HOME", dir.path()); + std::fs::create_dir_all(get_codex_config_dir()).unwrap(); + Self { dir, saved } + } + + fn store(&self) -> DeviceStore { + DeviceStore::at(self.dir.path().join(".cc-switch")) + } + } + + impl Drop for Scope { + fn drop(&mut self) { + reset_test_env(); + match self.saved.take() { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + } + } + + fn generation(since_ms: u64, fingerprint: &str) -> Generation { + Generation { + since_ms, + fingerprint: fingerprint.to_string(), + } + } + + /// 假的时钟和进程表:`clock` 是现在(Unix 毫秒),`table` 是 `ps` 的输出。 + fn fake_env(clock: Arc, table: Arc>, restarted: Arc) { + let now = clock.clone(); + set_test_env(Env { + process_table: Box::new(move || Some(table.lock().unwrap().clone())), + now_ms: Box::new(move || now.load(Ordering::SeqCst)), + restart: Box::new(move |_| { + restarted.store(true, Ordering::SeqCst); + Ok(success(r#"{"status":"restarted","pid":1}"#)) + }), + }); + } + + #[cfg(unix)] + fn success(stdout: &str) -> Output { + use std::os::unix::process::ExitStatusExt; + Output { + status: std::process::ExitStatus::from_raw(0), + stdout: format!("{stdout}\n").into_bytes(), + stderr: Vec::new(), + } + } + + #[cfg(windows)] + fn success(stdout: &str) -> Output { + use std::os::windows::process::ExitStatusExt; + Output { + status: std::process::ExitStatus::from_raw(0), + stdout: format!("{stdout}\n").into_bytes(), + stderr: Vec::new(), + } + } + + fn write_daemon_pid(pid: u32) { + let dir = get_codex_config_dir().join("app-server-daemon"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("daemon.pid"), format!(r#"{{"pid":{pid}}}"#)).unwrap(); + } + + /// 让新启动的 Codex 读 CC Switch 的目录(`catalog` 是目录内容),`None` 是撤掉指针。 + fn point_at_catalog(catalog: Option<&str>) { + let config = get_codex_config_dir().join("config.toml"); + match catalog { + Some(content) => { + std::fs::write( + &config, + "model_provider = \"cc-switch-official\"\nmodel_catalog_json = \"cc-switch-model-catalog.json\"\n", + ) + .unwrap(); + std::fs::write(get_codex_model_catalog_path(), content).unwrap(); + } + None => std::fs::write(&config, "model = \"gpt-6-astra\"\n").unwrap(), + } + } + + #[test] + fn parses_etime() { + assert_eq!(parse_etime("05:03"), Some(303)); + assert_eq!(parse_etime("02:53:10"), Some(2 * 3600 + 53 * 60 + 10)); + assert_eq!(parse_etime("3-01:02:03"), Some(3 * 86_400 + 3723)); + assert_eq!( + parse_etime("02-11:19:30"), + Some(2 * 86_400 + 11 * 3600 + 19 * 60 + 30) + ); + assert_eq!(parse_etime("12"), None); + assert_eq!(parse_etime("a:b"), None); + } + + #[test] + fn classifies_app_servers() { + let table = [ + format!("35946 02-11:19:30 {DAEMON} app-server daemon pid-update-loop"), + format!("59013 07:19 {DAEMON} app-server --listen unix:// --managed-daemon"), + // 另一个配置目录的守护进程:pid 对不上,不算。 + format!("70000 00:10 {DAEMON} app-server --listen unix:// --managed-daemon"), + "62347 04:57 /Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex -c features.code_mode_host=true app-server --analytics-default-enabled -c plugins.codex-app-tools@openai-bundled.mcp_servers.codex_app.enabled=true".to_string(), + "83919 02:11:54 /Users/me/.codex/plugins/.plugin-appserver/codex-cli/CodexCLI.app/Contents/MacOS/codex app-server --analytics-default-enabled".to_string(), + "81234 01:00 /Users/me/Library/Application Support/Code/User/globalStorage/openai.chatgpt/bin/codex app-server".to_string(), + "90000 00:05 /opt/homebrew/bin/codex app-server proxy".to_string(), + "91000 00:05 /opt/homebrew/bin/codex --no-daemon".to_string(), + "92000 00:05 /usr/bin/vim /Users/me/.codex/config.toml".to_string(), + "93000 00:05 /Users/me/codex-tools/run app-server".to_string(), + ] + .join("\n"); + let now = 10_000_000; + let servers = classify(&table, Some(59013), "/Users/me/.codex/plugins/", now); + assert_eq!(servers.daemon, Some(now - 439_000)); + assert_eq!(servers.others, vec![now - 297_000, now - 60_000]); + // daemon.pid 指向的进程不是守护进程(pid 被复用了):不算守护进程。 + assert_eq!( + classify(&table, Some(62347), "/Users/me/.codex/plugins/", now).daemon, + None + ); + } + + #[test] + fn judges_by_generation_started_before() { + let history = [generation(1_000_000, "a"), generation(2_000_000, "b")]; + // 在 b 之后启动:读到的是现在这份。 + assert!(!is_stale(&history, "b", 2_000_000 + MARGIN_MS)); + // 离 b 开始不到余量:当它读到的是 a。 + assert!(is_stale(&history, "b", 2_000_000 + MARGIN_MS - 1)); + assert!(is_stale(&history, "b", 1_500_000)); + // 早于第一代:判断不了,按旧的算。 + assert!(is_stale(&history, "b", 500_000)); + // 来回切:a → b → a,在第一次 a 时启动的进程读到的正是现在这份。 + let history = [ + generation(1_000_000, "a"), + generation(2_000_000, "b"), + generation(3_000_000, "a"), + ]; + assert!(!is_stale(&history, "a", 1_500_000)); + assert!(is_stale(&history, "a", 2_500_000)); + } + + #[test] + #[serial] + fn records_only_changes_and_trims() { + let scope = Scope::new(); + let store = scope.store(); + assert_eq!(record(&store, "a", 1).len(), 1); + assert_eq!(record(&store, "a", 2), vec![generation(1, "a")]); + for step in 0..40u64 { + record(&store, &format!("f{step}"), 10 + step); + } + let history = read_history(&store); + assert_eq!(history.len(), HISTORY_LIMIT); + assert_eq!(history.last(), Some(&generation(49, "f39"))); + assert_eq!(history.first(), Some(&generation(18, "f8"))); + } + + #[test] + #[serial] + fn fingerprint_follows_the_catalog_pointer() { + let _scope = Scope::new(); + assert_eq!(current_fingerprint(), NO_CATALOG); + point_at_catalog(Some("{\"models\":[]}")); + let fingerprint = current_fingerprint(); + assert_eq!(fingerprint, sha256_hex(b"{\"models\":[]}")); + // 指向别人的目录:新启动的 Codex 不读 CC Switch 的目录。 + std::fs::write( + get_codex_config_dir().join("config.toml"), + "model_catalog_json = \"/elsewhere/models.json\"\n", + ) + .unwrap(); + assert_eq!(current_fingerprint(), NO_CATALOG); + point_at_catalog(None); + assert_eq!(current_fingerprint(), NO_CATALOG); + } + + /// 进 Stack → 守护进程启动 → 退出 CC Switch(撤掉指针)→ 再打开(写回同一份目录):守护进程 + /// 读到的正是现在这份,不提示。退出期间重启过的守护进程读到的是没有目录的配置,要提示。 + #[test] + #[serial] + fn quitting_and_reopening_cc_switch_is_not_stale() { + let scope = Scope::new(); + let store = scope.store(); + let clock = Arc::new(AtomicU64::new(1_000_000)); + let table = Arc::new(Mutex::new(String::new())); + fake_env( + clock.clone(), + table.clone(), + Arc::new(AtomicBool::new(false)), + ); + let daemon_row = |elapsed: &str| { + format!("59013 {elapsed} {DAEMON} app-server --listen unix:// --managed-daemon") + }; + write_daemon_pid(59013); + + point_at_catalog(Some("stack")); + observe(&store); + // 进 Stack 之后 10 秒守护进程启动。 + clock.store(1_010_000, Ordering::SeqCst); + *table.lock().unwrap() = daemon_row("00:00"); + clock.store(1_060_000, Ordering::SeqCst); + *table.lock().unwrap() = daemon_row("00:50"); + assert_eq!(stale_clients(&store), None); + + // 退出 CC Switch,一分钟后再打开。 + point_at_catalog(None); + observe(&store); + clock.store(1_120_000, Ordering::SeqCst); + point_at_catalog(Some("stack")); + observe(&store); + *table.lock().unwrap() = daemon_row("01:50"); + assert_eq!(stale_clients(&store), None); + + // 目录变了(Stack 增删):守护进程还拿着旧的。 + clock.store(1_200_000, Ordering::SeqCst); + point_at_catalog(Some("stack + kimi")); + observe(&store); + *table.lock().unwrap() = daemon_row("03:10"); + assert_eq!( + stale_clients(&store), + Some(StaleClients { + daemon: true, + others: false + }) + ); + + // 退出期间守护进程重启过(比如 Codex 自动升级)。 + point_at_catalog(None); + observe(&store); + clock.store(1_300_000, Ordering::SeqCst); + *table.lock().unwrap() = daemon_row("00:30"); + clock.store(1_400_000, Ordering::SeqCst); + point_at_catalog(Some("stack + kimi")); + observe(&store); + *table.lock().unwrap() = daemon_row("02:10"); + assert_eq!( + stale_clients(&store), + Some(StaleClients { + daemon: true, + others: false + }) + ); + } + + #[test] + #[serial] + fn desktop_app_servers_are_reported_separately() { + let scope = Scope::new(); + let store = scope.store(); + let clock = Arc::new(AtomicU64::new(1_000_000)); + let table = Arc::new(Mutex::new(String::new())); + fake_env( + clock.clone(), + table.clone(), + Arc::new(AtomicBool::new(false)), + ); + point_at_catalog(Some("old")); + observe(&store); + clock.store(1_100_000, Ordering::SeqCst); + point_at_catalog(Some("new")); + observe(&store); + clock.store(1_200_000, Ordering::SeqCst); + // 桌面版在旧目录时启动;守护进程没在跑。 + *table.lock().unwrap() = + "62347 02:30 /Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex -c features.code_mode_host=true app-server".to_string(); + assert_eq!( + stale_clients(&store), + Some(StaleClients { + daemon: false, + others: true + }) + ); + // 撤掉指针之后不提示(新启动的 Codex 也不读 CC Switch 的目录)。 + point_at_catalog(None); + assert_eq!(stale_clients(&store), None); + } + + #[test] + #[serial] + fn restart_skips_when_the_daemon_is_not_running() { + let _scope = Scope::new(); + let restarted = Arc::new(AtomicBool::new(false)); + let table = Arc::new(Mutex::new(String::new())); + fake_env( + Arc::new(AtomicU64::new(1_000_000)), + table.clone(), + restarted.clone(), + ); + assert_eq!(restart_daemon(), Ok(RestartOutcome::NotRunning)); + assert!(!restarted.load(Ordering::SeqCst)); + + write_daemon_pid(59013); + *table.lock().unwrap() = + format!("59013 07:19 {DAEMON} app-server --listen unix:// --managed-daemon"); + assert_eq!(restart_daemon(), Ok(RestartOutcome::Restarted)); + assert!(restarted.load(Ordering::SeqCst)); + } + + #[test] + fn reads_the_lifecycle_status() { + assert_eq!( + lifecycle_status("warning: x\n{\"status\":\"restarted\",\"pid\":1}\n\n").as_deref(), + Some("restarted") + ); + assert_eq!(lifecycle_status("not json"), None); + } +} diff --git a/src-tauri/src/services/provider/codex_direct.rs b/src-tauri/src/services/provider/codex_direct.rs index 87f30bc38..3e0d5b22c 100644 --- a/src-tauri/src/services/provider/codex_direct.rs +++ b/src-tauri/src/services/provider/codex_direct.rs @@ -1075,7 +1075,10 @@ pub(crate) fn run_with_edits( }); } - write.run(op, &changes, pending) + let report = write.run(op, &changes, pending); + // 按磁盘上的实际内容记新启动的 Codex 会读到的目录:失败时可能已经发布了一部分。 + super::codex_client_catalog::observe(store); + report } /// 直连写入:`prepare` → `plan` → `run`。 diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index a1b45b4a3..8eef684b5 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod claude_direct; mod claude_editor; +pub(crate) mod codex_client_catalog; pub(crate) mod codex_direct; mod codex_editor; mod codex_login; diff --git a/src/components/providers/CodexStaleClientsNotice.tsx b/src/components/providers/CodexStaleClientsNotice.tsx new file mode 100644 index 000000000..a0673772b --- /dev/null +++ b/src/components/providers/CodexStaleClientsNotice.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { AlertTriangle, RotateCw } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { useRestartCodexAppServerDaemon } from "@/lib/query/proxy"; +import type { CodexStaleClients } from "@/types/proxy"; + +interface CodexStaleClientsNoticeProps { + staleClients: CodexStaleClients; +} + +/** + * Stack 模式下 Codex 客户端还在用旧的模型列表(它们只在启动时读模型目录)。命令行连的守护 + * 进程确认后一键重启;桌面版、编辑器插件只提示用户彻底退出再开。重启会中断守护进程里正在 + * 运行的任务,执行期间确认框保持打开。 + */ +export function CodexStaleClientsNotice({ + staleClients, +}: CodexStaleClientsNoticeProps) { + const { t } = useTranslation(); + const [confirming, setConfirming] = useState(false); + const restart = useRestartCodexAppServerDaemon(); + + return ( +
+
+ + {t("proxy.stackMode.codexStale.title")} +
+
    + {staleClients.daemon && ( +
  • {t("proxy.stackMode.codexStale.daemon")}
  • + )} + {staleClients.others && ( +
  • {t("proxy.stackMode.codexStale.others")}
  • + )} +
+ {staleClients.daemon && ( + + )} + + restart.mutate(undefined, { + onSettled: () => setConfirming(false), + }) + } + onCancel={() => setConfirming(false)} + /> +
+ ); +} diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 19621380d..488b4af94 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -34,6 +34,7 @@ import { import { useStreamCheck } from "@/hooks/useStreamCheck"; import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; +import { CodexStaleClientsNotice } from "@/components/providers/CodexStaleClientsNotice"; import { useAutoFailoverEnabled, useFailoverQueue, @@ -178,6 +179,8 @@ export function ProviderList({ isStackAppId(appId) && isProxyTakeover === true && stack?.active === true; const stackMembers = stack?.members; const stackNotice = isStackMode ? stack?.notice : undefined; + const codexStaleClients = + isStackMode && appId === "codex" ? stack?.staleClients : undefined; const setStackMember = useSetProxyStackMember(); const stackMemberOf = useCallback( (providerId: string): ProxyStackMember | undefined => @@ -584,6 +587,9 @@ export function ProviderList({ return (
{piStateErrorNotice} + {codexStaleClients && ( + + )} {claudeDesktopStatusMessages.length > 0 && (
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cc041147d..ba6d1088b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2918,7 +2918,19 @@ "inactive": "Enter Stack mode: add several providers and all their models appear in the {{appLabel}} model list" }, "ariaLabel": "{{appLabel}} Stack mode", - "failoverUnavailable": "{{appLabel}} is in Stack mode, which has no failover. The queue and settings are kept and apply again in routing mode" + "failoverUnavailable": "{{appLabel}} is in Stack mode, which has no failover. The queue and settings are kept and apply again in routing mode", + "codexStale": { + "title": "Codex is still using an old model list, so Stack models won't show", + "daemon": "CLI: reopening codex isn't enough; its background daemon needs a restart.", + "others": "Desktop app: quit it completely and reopen it (⌘Q on macOS; closing the window isn't enough). Editor extensions: reopen the editor window.", + "restart": "Restart Codex daemon", + "confirmTitle": "Restart the Codex daemon?", + "confirmMessage": "Every codex session in your terminals shares this daemon, so running tasks will be interrupted. Reopen codex afterwards to see the Stack models.", + "confirm": "Restart", + "restarted": "Codex daemon restarted. Reopen codex to see the Stack models", + "notRunning": "The Codex daemon isn't running. codex will load the new model list the next time it starts", + "failed": "Failed to restart the Codex daemon: {{error}}" + } }, "failover": { "proxyRequired": "Routing service must be started to configure failover", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3e6c117b7..ece456560 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2918,7 +2918,19 @@ "inactive": "Stack モードに入る:複数のプロバイダーを追加し、それらのモデルをすべて {{appLabel}} のモデル一覧に表示します" }, "ariaLabel": "{{appLabel}} の Stack モード", - "failoverUnavailable": "{{appLabel}} は Stack モードのため、フェイルオーバーは行いません。キューと設定は保持され、ルーティングモードに戻すと再び有効になります" + "failoverUnavailable": "{{appLabel}} は Stack モードのため、フェイルオーバーは行いません。キューと設定は保持され、ルーティングモードに戻すと再び有効になります", + "codexStale": { + "title": "Codex が古いモデル一覧を使っているため、Stack モデルが表示されません", + "daemon": "CLI:codex を開き直すだけでは反映されません。バックグラウンドのデーモンを再起動する必要があります。", + "others": "デスクトップ版:完全に終了してから開き直してください(macOS では ⌘Q。ウィンドウを閉じるだけでは不十分です)。エディター拡張:エディターのウィンドウを開き直してください。", + "restart": "Codex デーモンを再起動", + "confirmTitle": "Codex デーモンを再起動しますか?", + "confirmMessage": "ターミナルで動いているすべての codex がこのデーモンを共有しているため、実行中のタスクは中断されます。再起動後に codex を開き直すと Stack モデルが表示されます。", + "confirm": "再起動", + "restarted": "Codex デーモンを再起動しました。codex を開き直すと Stack モデルが表示されます", + "notRunning": "Codex デーモンは動いていません。次に codex を起動したときに新しいモデル一覧が読み込まれます", + "failed": "Codex デーモンの再起動に失敗しました:{{error}}" + } }, "failover": { "proxyRequired": "フェイルオーバーを設定するには、ルーティングサービスを先に起動する必要があります", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 0f3a62807..4a5c6b9f2 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2919,7 +2919,19 @@ "inactive": "進入 Stack 模式:新增多家供應商,它們的模型都出現在 {{appLabel}} 的模型清單裡" }, "ariaLabel": "{{appLabel}} Stack 模式", - "failoverUnavailable": "{{appLabel}} 在 Stack 模式,不做故障轉移。佇列和設定保留,回到路由模式後恢復" + "failoverUnavailable": "{{appLabel}} 在 Stack 模式,不做故障轉移。佇列和設定保留,回到路由模式後恢復", + "codexStale": { + "title": "Codex 還在用舊的模型清單,Stack 模型不會出現", + "daemon": "命令列:重開 codex 不夠,要重新啟動背景的守護行程。", + "others": "桌面版:要完全結束再重開(macOS 上按 ⌘Q,只關視窗不行);編輯器擴充功能要重開編輯器視窗。", + "restart": "重新啟動 Codex 守護行程", + "confirmTitle": "重新啟動 Codex 守護行程?", + "confirmMessage": "所有終端機裡的 codex 共用這個守護行程,正在執行的任務會被中斷。重新啟動後重開 codex,就能看到 Stack 模型。", + "confirm": "重新啟動", + "restarted": "Codex 守護行程已重新啟動,重開 codex 就能看到 Stack 模型", + "notRunning": "Codex 守護行程沒有在執行,下次開啟 codex 時會讀到新的模型清單", + "failed": "重新啟動 Codex 守護行程失敗:{{error}}" + } }, "failover": { "proxyRequired": "需要先啟動路由服務才能設定故障轉移", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index d16bf5e42..e74693008 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2918,7 +2918,19 @@ "inactive": "进入 Stack 模式:添加多家供应商,它们的模型都出现在 {{appLabel}} 的模型列表里" }, "ariaLabel": "{{appLabel}} Stack 模式", - "failoverUnavailable": "{{appLabel}} 在 Stack 模式,不做故障转移。队列和设置保留,回到路由模式后恢复" + "failoverUnavailable": "{{appLabel}} 在 Stack 模式,不做故障转移。队列和设置保留,回到路由模式后恢复", + "codexStale": { + "title": "Codex 还在用旧的模型列表,Stack 模型不会出现", + "daemon": "命令行:重开 codex 不够,要重启后台的守护进程。", + "others": "桌面版:要彻底退出再重开(macOS 上按 ⌘Q,只关窗口不行);编辑器插件要重开编辑器窗口。", + "restart": "重启 Codex 守护进程", + "confirmTitle": "重启 Codex 守护进程?", + "confirmMessage": "所有终端里的 codex 共用这个守护进程,正在运行的任务会被中断。重启之后重新打开 codex,就能看到 Stack 模型。", + "confirm": "重启", + "restarted": "Codex 守护进程已重启,重新打开 codex 就能看到 Stack 模型", + "notRunning": "Codex 守护进程没在运行,下次打开 codex 时会读到新的模型列表", + "failed": "重启 Codex 守护进程失败:{{error}}" + } }, "failover": { "proxyRequired": "需要先启动路由服务才能配置故障转移", diff --git a/src/lib/api/proxy.ts b/src/lib/api/proxy.ts index d123f2666..6d8a14f71 100644 --- a/src/lib/api/proxy.ts +++ b/src/lib/api/proxy.ts @@ -7,6 +7,7 @@ import type { AppProxyConfig, ProxyStack, ProxyStackNotice, + CodexDaemonRestartOutcome, } from "@/types/proxy"; export const proxyApi = { @@ -76,6 +77,12 @@ export const proxyApi = { return invoke("set_proxy_stack_member", { appType, providerId, enabled }); }, + // 重启 Codex 的托管守护进程(codex 命令行连的那个),让它重读模型目录。会中断正在运行的 + // 任务,只在用户确认之后调 + async restartCodexAppServerDaemon(): Promise { + return invoke("restart_codex_app_server_daemon"); + }, + // ========== v3+ 全局/应用级配置 API ========== // 获取全局代理配置 diff --git a/src/lib/query/proxy.ts b/src/lib/query/proxy.ts index d9cfeebca..c38a09ade 100644 --- a/src/lib/query/proxy.ts +++ b/src/lib/query/proxy.ts @@ -137,6 +137,40 @@ export function useSetProxyStackMember() { }); } +/** + * 重启 Codex 的托管守护进程,让它重读模型目录。结束后重新查 Stack 名单:重启成功时 + * 「还在用旧模型列表」的提示随之消失。 + */ +export function useRestartCodexAppServerDaemon() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: () => proxyApi.restartCodexAppServerDaemon(), + onSuccess: (outcome) => { + if (outcome === "notRunning") { + toast.info(t("proxy.stackMode.codexStale.notRunning"), { + closeButton: true, + }); + return; + } + toast.success(t("proxy.stackMode.codexStale.restarted"), { + closeButton: true, + }); + }, + onError: (error: unknown) => { + toast.error( + t("proxy.stackMode.codexStale.failed", { + error: extractErrorMessage(error), + }), + ); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["providers", "codex"] }); + }, + }); +} + // ========== 代理服务器控制 Hooks ========== /** diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 4d4bf11ce..75cfd8946 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -72,14 +72,27 @@ export type ProxyStackNotice = | "officialModelsBundled" | "officialModelsUnavailable"; +/** + * 还在用旧模型列表的 Codex 客户端(它们只在启动时读模型目录):`daemon` 是 `codex` 命令行连的 + * 托管守护进程,可以一键重启;`others` 是桌面版、编辑器插件,要用户自己彻底退出再开。 + */ +export interface CodexStaleClients { + daemon: boolean; + others: boolean; +} + /** Stack 模式的状态、名单和提示。 */ export interface ProxyStack { /** 在 Stack 模式(代理模式且 Stack 模式开着)。 */ active: boolean; members: ProxyStackMember[]; notice?: ProxyStackNotice; + staleClients?: CodexStaleClients; } +/** 重启 Codex 守护进程的结果:`notRunning` 表示它没在运行,什么都没做。 */ +export type CodexDaemonRestartOutcome = "restarted" | "notRunning"; + /** 增删 Stack 模型失败。`partial` 为真:已部分写入,下次操作或重启 CC Switch 时补完。 */ export interface ProxyStackWriteError { partial: boolean; diff --git a/tests/components/CodexStaleClientsNotice.test.tsx b/tests/components/CodexStaleClientsNotice.test.tsx new file mode 100644 index 000000000..ad0f32964 --- /dev/null +++ b/tests/components/CodexStaleClientsNotice.test.tsx @@ -0,0 +1,133 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import { toast } from "sonner"; +import { CodexStaleClientsNotice } from "@/components/providers/CodexStaleClientsNotice"; +import type { CodexStaleClients } from "@/types/proxy"; +import { server } from "../msw/server"; + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + info: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + }, +})); + +const TAURI_ENDPOINT = "http://tauri.local"; +const RESTART = `${TAURI_ENDPOINT}/restart_codex_app_server_daemon`; + +function renderNotice(staleClients: CodexStaleClients) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + render( + + + , + ); + return { invalidate }; +} + +function clickRestartAndConfirm() { + fireEvent.click( + screen.getByRole("button", { name: "proxy.stackMode.codexStale.restart" }), + ); + fireEvent.click( + screen.getByRole("button", { name: "proxy.stackMode.codexStale.confirm" }), + ); +} + +describe("CodexStaleClientsNotice", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("restarts the daemon only after the user confirms", async () => { + let restarts = 0; + server.use( + http.post(RESTART, () => { + restarts += 1; + return HttpResponse.json("restarted"); + }), + ); + const { invalidate } = renderNotice({ daemon: true, others: false }); + + expect( + screen.getByText("proxy.stackMode.codexStale.daemon"), + ).toBeInTheDocument(); + expect( + screen.queryByText("proxy.stackMode.codexStale.others"), + ).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: "proxy.stackMode.codexStale.restart" }), + ); + // 只是打开确认框:会中断正在运行的任务,没确认之前不重启。 + expect( + screen.getByText("proxy.stackMode.codexStale.confirmMessage"), + ).toBeInTheDocument(); + expect(restarts).toBe(0); + + fireEvent.click( + screen.getByRole("button", { name: "proxy.stackMode.codexStale.confirm" }), + ); + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith( + "proxy.stackMode.codexStale.restarted", + expect.anything(), + ), + ); + expect(restarts).toBe(1); + // 重新查 Stack 名单,提示随之消失。 + expect(invalidate).toHaveBeenCalledWith({ queryKey: ["providers", "codex"] }); + await waitFor(() => + expect( + screen.queryByText("proxy.stackMode.codexStale.confirmMessage"), + ).not.toBeInTheDocument(), + ); + }); + + it("says so when the daemon is not running", async () => { + server.use(http.post(RESTART, () => HttpResponse.json("notRunning"))); + renderNotice({ daemon: true, others: true }); + clickRestartAndConfirm(); + await waitFor(() => + expect(toast.info).toHaveBeenCalledWith( + "proxy.stackMode.codexStale.notRunning", + expect.anything(), + ), + ); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("shows the failure", async () => { + server.use( + http.post(RESTART, () => + HttpResponse.json("重启 Codex 守护进程失败: boom", { status: 500 }), + ), + ); + renderNotice({ daemon: true, others: false }); + clickRestartAndConfirm(); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + "proxy.stackMode.codexStale.failed", + ), + ); + }); + + it("only explains how to reopen the desktop app", () => { + renderNotice({ daemon: false, others: true }); + expect( + screen.getByText("proxy.stackMode.codexStale.others"), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { + name: "proxy.stackMode.codexStale.restart", + }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/tests/components/ProviderList.test.tsx b/tests/components/ProviderList.test.tsx index c90e37b5d..b969a7080 100644 --- a/tests/components/ProviderList.test.tsx +++ b/tests/components/ProviderList.test.tsx @@ -527,6 +527,70 @@ describe("ProviderList Component", () => { }); }); + it("warns when Codex clients still use an old model list in Stack mode", async () => { + const route = createProvider({ id: "route", name: "Route" }); + useDragSortMock.mockReturnValue({ + sortedProviders: [route], + sensors: [], + handleDragEnd: vi.fn(), + }); + let active = true; + server.use( + http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () => + HttpResponse.json({ + active, + members: [ + { providerId: "route", modelIds: [], route: true }, + ], + staleClients: { daemon: true, others: false }, + }), + ), + ); + + const view = renderWithQueryClient( + , + ); + expect( + await screen.findByText("proxy.stackMode.codexStale.title"), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "proxy.stackMode.codexStale.restart" }), + ).toBeInTheDocument(); + view.unmount(); + + // 不在 Stack 模式:不提示。 + active = false; + renderWithQueryClient( + , + ); + await waitFor(() => + expect(lastProps("route")?.isStackMode).toBe(false), + ); + expect( + screen.queryByText("proxy.stackMode.codexStale.title"), + ).not.toBeInTheDocument(); + }); + it("hides Stack mode outside proxy mode and for apps without it", async () => { const provider = createProvider({ id: "a", name: "A" }); useDragSortMock.mockReturnValue({ diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index 82b3745e9..710917551 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -366,6 +366,10 @@ export const handlers = [ http.post(`${TAURI_ENDPOINT}/set_proxy_stack_member`, () => success(null)), + http.post(`${TAURI_ENDPOINT}/restart_codex_app_server_daemon`, () => + success("restarted"), + ), + http.post(`${TAURI_ENDPOINT}/get_proxy_takeover_status`, () => success({ claude: false,