diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index e2a400a3b..db388d2d0 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -104,10 +104,51 @@ async fn launcher_main(args: Vec, helper_only: bool, options: LaunchOpti }); let hooks = LauncherHooks::default(); let handle = launch_and_inject_with_hooks(options, &hooks).await?; - handle.wait_for_codex_exit().await?; + run_periodic_until_exit( + handle.wait_for_codex_exit(), + std::time::Duration::from_secs(60), + || repair_session_index_automatically(true), + ) + .await?; Ok(()) } +// 退出时不再安排下一次检查;已开始的数据库事务先完成,避免脱离启动器生命周期。 +async fn run_periodic_until_exit(exit: F, interval: std::time::Duration, mut check: C) -> T +where + F: std::future::Future, + C: FnMut() -> W, + W: std::future::Future, +{ + tokio::pin!(exit); + loop { + tokio::select! { + biased; + result = &mut exit => return result, + _ = tokio::time::sleep(interval) => check().await, + } + } +} + +async fn repair_session_index_automatically(check_setting: bool) { + let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + if check_setting && !codex_plus_core::settings::SettingsStore::default().load()?.provider_sync_enabled { + return Ok(()); + } + codex_plus_data::repair_session_index(None)?; + Ok(()) + }) + .await + .map_err(anyhow::Error::from) + .and_then(|result| result); + if let Err(error) = result { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "launcher.session_index_repair.failed", + json!({ "message": error.to_string() }), + ); + } +} + fn current_timestamp_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -376,7 +417,9 @@ impl LaunchHooks for LauncherHooks { let result = tokio::task::spawn_blocking(|| codex_plus_data::run_provider_sync(None)) .await .map_err(|error| anyhow::anyhow!("provider sync task failed: {error}"))?; - require_completed_provider_sync(&result.status, &result.message) + require_completed_provider_sync(&result.status, &result.message)?; + repair_session_index_automatically(false).await; + Ok(()) } fn has_pending_remote_control_session_recoveries(&self) -> bool { @@ -1140,6 +1183,33 @@ fn default_user_scripts_config_dir() -> PathBuf { mod tests { use super::*; + #[tokio::test] + async fn session_index_monitor_does_not_start_after_exit() { + let checks = std::cell::Cell::new(0); + let result = run_periodic_until_exit(async { 42 }, std::time::Duration::ZERO, || { + checks.set(checks.get() + 1); + std::future::ready(()) + }).await; + assert_eq!(result, 42); + assert_eq!(checks.get(), 0); + } + + #[tokio::test] + async fn session_index_monitor_finishes_current_check_before_exit() { + let (done, exit) = tokio::sync::oneshot::channel::<()>(); + let mut done = Some(done); + let finished = std::cell::Cell::new(false); + run_periodic_until_exit(exit, std::time::Duration::from_millis(1), || { + done.take().expect("only one check").send(()).unwrap(); + let finished = &finished; + async move { + tokio::task::yield_now().await; + finished.set(true); + } + }).await.unwrap(); + assert!(finished.get()); + } + #[test] fn parse_launch_options_accepts_manager_forwarded_ports_and_app_path() { let options = parse_launch_options([ diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 1f17a6456..8e7e50e4c 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -3226,6 +3226,35 @@ fn merge_manual_provider_sync_targets( }); } +#[tauri::command] +pub async fn repair_session_index() -> CommandResult { + let result = tauri::async_runtime::spawn_blocking(|| codex_plus_data::repair_session_index(None)) + .await + .map_err(|error| anyhow::anyhow!("session index repair task failed: {error}")) + .and_then(|result| result); + match result { + Ok(report) => ok( + &format!("会话索引检查完成,恢复 {} 条消息。", report.repaired_items), + json!(report), + ), + Err(error) => failed(&format!("修复会话索引失败:{error}"), json!({})), + } +} + +#[tauri::command] +pub async fn load_session_index_repair_report() -> CommandResult { + let result = tauri::async_runtime::spawn_blocking(|| { + codex_plus_data::load_session_index_repair_report(None) + }) + .await + .map_err(|error| anyhow::anyhow!("session index report task failed: {error}")) + .and_then(|result| result); + match result { + Ok(report) => ok("已读取会话索引修复报告。", json!({ "report": report })), + Err(error) => failed(&format!("读取会话索引修复报告失败:{error}"), json!({})), + } +} + #[tauri::command] pub async fn preview_session_index_cleanup() -> CommandResult { let result = tauri::async_runtime::spawn_blocking(|| { diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index d7deb13dd..10c18abf1 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -121,6 +121,8 @@ pub fn run() { commands::forget_zed_remote_project, commands::delete_local_session, commands::load_provider_sync_targets, + commands::repair_session_index, + commands::load_session_index_repair_report, commands::preview_session_index_cleanup, commands::apply_session_index_cleanup, commands::sync_providers_now, diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 9b7a42f8a..feeb7b361 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -785,6 +785,28 @@ type TaskProgress = { message: string; }; +type SessionIndexRepairReport = { + scannedFiles: number; + cachedFiles: number; + repairedItems: number; + alreadyPresent: number; + skippedItems: number; + deferredItems?: number; + issues: string[]; + backupPath: string | null; + elapsedMs: number; + checkedAtMs?: number; + pendingDetails?: { + threadId: string | null; + turnId: string | null; + reason: string; + state: "waiting" | "blocked"; + firstSeenAtMs: number; + lastCheckedAtMs: number; + checks: number; + }[]; +}; + type LogsResult = CommandResult<{ path: string; text: string; @@ -1186,6 +1208,10 @@ export function App() { message: t("尚未检查官方远端插件缓存。"), }); const [providerSyncTargets, setProviderSyncTargets] = useState(null); + const [sessionIndexRepairActive, setSessionIndexRepairActive] = useState(false); + const sessionIndexRepairRunning = useRef(false); + const sessionIndexReportLoading = useRef(false); + const [sessionIndexRepairReport, setSessionIndexRepairReport] = useState(null); const [selectedProviderSyncTarget, setSelectedProviderSyncTarget] = useState(""); const [removeOwnedData, setRemoveOwnedData] = useState(false); const [relaySwitching, setRelaySwitching] = useState(false); @@ -2074,6 +2100,7 @@ export function App() { await refreshSettings(true); await refreshLocalSessions(true); await refreshProviderSyncTargets(true); + await refreshSessionIndexRepairReport(); } if (next === "zedRemote") { await refreshSettings(true); @@ -2537,7 +2564,48 @@ export function App() { return result; }; + const refreshSessionIndexRepairReport = async (isCurrent = () => true) => { + if (sessionIndexReportLoading.current || sessionIndexRepairRunning.current) return; + sessionIndexReportLoading.current = true; + try { + // 持久报告读取失败时保留现有结果,后台刷新不触发全局通知或忙碌状态。 + const result = await call>( + "load_session_index_repair_report", + ); + if (isCurrent() && !sessionIndexRepairRunning.current && isSuccessStatus(result.status)) { + setSessionIndexRepairReport((previous) => { + if ((previous?.checkedAtMs ?? 0) > (result.report?.checkedAtMs ?? 0)) return previous; + return result.report; + }); + } + } catch { + // 下次页面刷新再读;读取报告本身不会启动修复。 + } finally { + sessionIndexReportLoading.current = false; + } + }; + + const repairSessionIndex = async () => { + if (sessionIndexRepairRunning.current || providerSyncProgress.active) return; + sessionIndexRepairRunning.current = true; + setSessionIndexRepairActive(true); + try { + const result = await run(() => call>("repair_session_index")); + if (result) { + if (isSuccessStatus(result.status)) { + setSessionIndexRepairReport(result); + await refreshLocalSessions(true); + } + showNotice(t("修复会话索引"), result.message, result.status); + } + } finally { + sessionIndexRepairRunning.current = false; + setSessionIndexRepairActive(false); + } + }; + const syncProvidersNow = async () => { + if (sessionIndexRepairRunning.current) return; if (providerSyncProgress.active) return; setProviderSyncProgress({ active: true, @@ -3048,6 +3116,18 @@ export function App() { }; }, []); + useEffect(() => { + if (route !== "sessions") return; + let disposed = false; + const refresh = () => void refreshSessionIndexRepairReport(() => !disposed); + refresh(); + const timer = window.setInterval(refresh, 15_000); + return () => { + disposed = true; + window.clearInterval(timer); + }; + }, [route]); + useEffect(() => { if (route !== "settings" || pendingSettingsSection !== "stepwise") return; let secondFrame = 0; @@ -3337,6 +3417,7 @@ export function App() { } }, syncProvidersNow, + repairSessionIndex, refreshProviderSyncTargets, setProviderSyncTarget: (provider: string) => { setSelectedProviderSyncTarget(provider); @@ -3545,6 +3626,8 @@ export function App() { form={settingsForm} sessions={localSessions} providerSyncProgress={providerSyncProgress} + sessionIndexRepairActive={sessionIndexRepairActive} + sessionIndexRepairReport={sessionIndexRepairReport} providerSyncTargets={providerSyncTargets} selectedProviderSyncTarget={selectedProviderSyncTarget} onFormChange={setSettingsForm} @@ -3762,6 +3845,7 @@ type Actions = { saveDreamSkinScreenshot: () => Promise; saveManualCodexAppPath: () => Promise; syncProvidersNow: () => Promise; + repairSessionIndex: () => Promise; refreshProviderSyncTargets: (silent?: boolean) => Promise; setProviderSyncTarget: (provider: string) => void; setLaunchMode: (launchMode: LaunchMode) => Promise; @@ -6140,6 +6224,8 @@ function SessionsScreen({ form, sessions, providerSyncProgress, + sessionIndexRepairActive, + sessionIndexRepairReport, providerSyncTargets, selectedProviderSyncTarget, onFormChange, @@ -6149,6 +6235,8 @@ function SessionsScreen({ form: BackendSettings; sessions: LocalSessionsResult | null; providerSyncProgress: ProviderSyncProgress; + sessionIndexRepairActive: boolean; + sessionIndexRepairReport: SessionIndexRepairReport | null; providerSyncTargets: ProviderSyncTargetsResult | null; selectedProviderSyncTarget: string; onFormChange: (value: BackendSettings) => void; @@ -6277,7 +6365,7 @@ function SessionsScreen({ /> {t("启动前自动修复历史会话")} - {t("启动 Codex 前整理旧对话的归属标记。")} + {t("启动前整理会话归属并检查缺失消息;运行期间自动检查索引。保存设置后生效。")} @@ -6292,13 +6380,21 @@ function SessionsScreen({ {t("导入文件")} +