From 5793b178185699a89a8cc64ec62bc2f45fbc26bc Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:05:27 +0700 Subject: [PATCH 01/12] Reconcile Claude account warning state --- .../src-tauri/src/commands/claude_accounts.rs | 41 +++++-- .../src-tauri/src/commands/providers.rs | 57 ++++++++- rust/src/notifications.rs | 116 ++++++++++++++++++ 3 files changed, 200 insertions(+), 14 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs index 04b2471fe0..b9f27525c9 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -8,10 +8,12 @@ use codexbar::providers::claude::claude_swap::{ }; use serde::Serialize; use std::sync::Mutex; +use std::time::Duration; use tauri::Emitter; use tauri::Manager; static MUTATION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +const AMBIENT_RECONCILIATION_GRACE: Duration = Duration::from_secs(5); #[tauri::command] pub fn claude_accounts_list() -> Result, String> { @@ -119,7 +121,20 @@ async fn refresh_after_claude_change(app: tauri::AppHandle) -> Result<(), String }; crate::events::emit_provider_updated(&app, &pending); let _emit = app.emit("claude-accounts-reconciling", ()); - let refresh_result = super::refresh_providers(app.clone()).await; + let refresh_app = app.clone(); + let mut ambient = + tauri::async_runtime::spawn(async move { super::refresh_providers(refresh_app).await }); + let refresh_result = + match tokio::time::timeout(AMBIENT_RECONCILIATION_GRACE, &mut ambient).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + // Dropping only the JoinHandle waiter detaches the ambient refresh; + // it keeps ownership of its provider request and can publish later. + let _reconciled = app.emit("claude-accounts-reconciled", ()); + changed(&app); + return Ok(()); + } + }; if !refresh_providers_ran(&app) { // begin_provider_refresh skipped (another batch owns the refresh) or // finish_provider_refresh dropped a superseded generation: a refresh @@ -198,6 +213,10 @@ fn reauthentication_is_repaired(account: &ClaudeSwapAccountRow) -> bool { account.is_active && account.usage_status == ClaudeSwapUsageStatus::Ok } +fn switch_is_reconciled(account: &ClaudeSwapAccountRow) -> bool { + account.is_active +} + fn run_claude_swap_operation( config: &ClaudeSwapConfig, slot: u32, @@ -214,15 +233,11 @@ fn run_claude_swap_operation( if !result.switched { return Err(result.reason); } - if matches!(operation, ClaudeSwapAccountOperation::Switch) { - return Ok(ClaudeSwapMutationOutcome::confirmed()); - } - let after = match claude_swap::read_account_list(&config.executable_path) { Ok(list) => list, Err(error) => { return Ok(ClaudeSwapMutationOutcome::applied_unconfirmed(format!( - "claude-swap re-authentication was applied, but confirmation failed: {error}" + "claude-swap account change was applied, but confirmation failed: {error}" ))); } }; @@ -230,11 +245,15 @@ fn run_claude_swap_operation( Ok(account) => account, Err(error) => return Ok(ClaudeSwapMutationOutcome::applied_unconfirmed(error)), }; - if reauthentication_is_repaired(account) { + let confirmed = match operation { + ClaudeSwapAccountOperation::Switch => switch_is_reconciled(account), + ClaudeSwapAccountOperation::Reauthenticate => reauthentication_is_repaired(account), + }; + if confirmed { Ok(ClaudeSwapMutationOutcome::confirmed()) } else { Ok(ClaudeSwapMutationOutcome::applied_unconfirmed( - "claude-swap re-authentication completed without a confirmed account repair.", + "claude-swap account change completed without a confirmed active account.", )) } } @@ -429,6 +448,12 @@ mod tests { assert!(!reauthentication_is_repaired(&account_row(true, "unknown"))); } + #[test] + fn switch_reconciliation_requires_the_requested_slot_to_be_active() { + assert!(switch_is_reconciled(&account_row(true, "ok"))); + assert!(!switch_is_reconciled(&account_row(false, "ok"))); + } + #[test] fn applied_but_unconfirmed_outcome_remains_refreshable() { let outcome = ClaudeSwapMutationOutcome::applied_unconfirmed("confirmation unavailable"); diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 0500016b22..0748aee902 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -5,6 +5,12 @@ use serde::Serialize; use std::sync::Arc; const MAX_CONCURRENT_PROVIDER_FETCHES: usize = 8; +const CLAUDE_UNRESOLVED_WARNING_IDENTITY: &str = "claude-account:unknown"; + +fn is_claude_warning_source(source_label: &str) -> bool { + let source = source_label.trim().to_ascii_lowercase(); + source == "oauth" || source == "cli" || source.starts_with("cli ") +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RefreshScope { @@ -1012,6 +1018,13 @@ fn notify_usage_thresholds( .and_then(ProviderAccountData::active_account) .map(|account| account.id); let account = quota_notification_account_identity(snapshot, token_account_id); + if provider == ProviderId::Claude && account != CLAUDE_UNRESOLVED_WARNING_IDENTITY { + guard.notification_manager.adopt_threshold_account_identity( + provider, + CLAUDE_UNRESOLVED_WARNING_IDENTITY, + &account, + ); + } // Skip all session consumers for synthetic/no-session // placeholders (e.g. Claude OAuth five_hour: null). if guard.notification_manager.check_session_lane( @@ -1111,6 +1124,11 @@ fn quota_notification_account_identity( { return format!("org:{}", org.to_ascii_lowercase()); } + if snapshot.provider_id == ProviderId::Claude.cli_name() + && is_claude_warning_source(&snapshot.source_label) + { + return CLAUDE_UNRESOLVED_WARNING_IDENTITY.to_string(); + } // Do not fall back to plan_name/login_method — those are display tiers and // flicker across refreshes, re-arming still-hot windows for a new identity. String::new() @@ -1141,6 +1159,13 @@ fn notify_predictive_pace( ) else { return; }; + if provider == ProviderId::Claude && identity != CLAUDE_UNRESOLVED_WARNING_IDENTITY { + manager.adopt_predictive_account_identity( + provider, + CLAUDE_UNRESOLVED_WARNING_IDENTITY, + &identity, + ); + } let observed_at = chrono::DateTime::parse_from_rfc3339(&snapshot.updated_at) .ok() .map(|date| date.with_timezone(&chrono::Utc)); @@ -1202,7 +1227,13 @@ fn predictive_warning_identity( return Some(format!("token-account:{}", id.as_hyphenated())); } let source = source_label.trim().to_ascii_lowercase(); - let account = account_email?.trim().to_ascii_lowercase(); + let account = account_email + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if provider == ProviderId::Claude && account.is_empty() && is_claude_warning_source(&source) { + return Some(CLAUDE_UNRESOLVED_WARNING_IDENTITY.to_string()); + } if source.is_empty() || account.is_empty() { return None; } @@ -1319,15 +1350,23 @@ mod predictive_warning_tests { } #[test] - fn predictive_warning_identity_skips_unidentified_accounts() { + fn predictive_warning_identity_scopes_unresolved_claude_sources() { assert_eq!( predictive_warning_identity(ProviderId::Claude, "oauth", None, None), - None + Some(CLAUDE_UNRESOLVED_WARNING_IDENTITY.to_string()) ); assert_eq!( predictive_warning_identity(ProviderId::Codex, "cli", Some(" "), None), None ); + assert_eq!( + predictive_warning_identity(ProviderId::Claude, "cli (reduced fidelity)", None, None,), + Some(CLAUDE_UNRESOLVED_WARNING_IDENTITY.to_string()) + ); + assert_eq!( + predictive_warning_identity(ProviderId::Claude, "web", None, None), + None + ); } fn empty_snapshot() -> ProviderUsageSnapshot { @@ -1366,11 +1405,17 @@ mod predictive_warning_tests { ); snapshot.account_organization = None; - // plan_name/login_method is not a stable ownership key — fall through to "". - assert_eq!(quota_notification_account_identity(&snapshot, None), ""); + snapshot.source_label = "oauth".to_string(); + assert_eq!( + quota_notification_account_identity(&snapshot, None), + CLAUDE_UNRESOLVED_WARNING_IDENTITY + ); snapshot.plan_name = None; - assert_eq!(quota_notification_account_identity(&snapshot, None), ""); + assert_eq!( + quota_notification_account_identity(&snapshot, None), + CLAUDE_UNRESOLVED_WARNING_IDENTITY + ); } /// The forecast scope key and the notification identity must never disagree. diff --git a/rust/src/notifications.rs b/rust/src/notifications.rs index d815bb868a..ba3fe34668 100755 --- a/rust/src/notifications.rs +++ b/rust/src/notifications.rs @@ -229,6 +229,65 @@ impl NotificationManager { } } + /// Move threshold and session-transition history from a temporary account + /// discriminator to a verified one. This is intentionally explicit: the + /// caller must establish provider-owned account continuity first. + pub fn adopt_threshold_account_identity(&mut self, provider: ProviderId, from: &str, to: &str) { + if from.is_empty() || to.is_empty() || from == to { + return; + } + + let moved = self + .sent_notifications + .iter() + .filter(|(key_provider, account, _, _)| *key_provider == provider && account == from) + .cloned() + .collect::>(); + self.sent_notifications + .retain(|(key_provider, account, _, _)| *key_provider != provider || account != from); + self.sent_notifications.extend( + moved.into_iter().map(|(key_provider, _, window, kind)| { + (key_provider, to.to_string(), window, kind) + }), + ); + + if let Some(previous) = self + .previous_session_percent + .remove(&(provider, from.to_string())) + { + self.previous_session_percent + .insert((provider, to.to_string()), previous); + } + } + + /// Move predictive warning history independently from threshold history. + /// Predictive identities include the fetch source, so callers must never + /// use this to collapse OAuth and CLI histories into one known-account key. + pub fn adopt_predictive_account_identity( + &mut self, + provider: ProviderId, + from: &str, + to: &str, + ) { + if from.is_empty() || to.is_empty() || from == to { + return; + } + + let moved = self + .predictive_warning_keys + .iter() + .filter(|key| key.provider == provider && key.identity == from) + .cloned() + .collect::>(); + self.predictive_warning_keys + .retain(|key| key.provider != provider || key.identity != from); + self.predictive_warning_keys + .extend(moved.into_iter().map(|mut key| { + key.identity = to.to_string(); + key + })); + } + pub fn check_predictive_pace( &mut self, provider: ProviderId, @@ -855,6 +914,63 @@ mod tests { )); } + #[test] + fn unresolved_warning_history_adopts_verified_identity_without_crossing_providers() { + let now = DateTime::from_timestamp(1_800_000_000, 0).unwrap(); + let reset = window(now, Duration::hours(3), 300); + let risk = pace(false, Some(3600.0)); + let settings = Settings::default(); + let mut manager = NotificationManager::new(); + + manager.check_and_notify( + ProviderId::Claude, + "claude-account:unknown", + "session", + 80.0, + &settings, + ); + assert!(manager.record_predictive_observation( + true, + ProviderId::Claude, + "claude-account:unknown", + PredictiveWarningWindow::Session, + &reset, + &risk, + )); + + manager.adopt_threshold_account_identity( + ProviderId::Claude, + "claude-account:unknown", + "person@example.com", + ); + manager.adopt_predictive_account_identity( + ProviderId::Claude, + "claude-account:unknown", + "oauth:person@example.com", + ); + + assert!(manager.sent_notifications.contains(&( + ProviderId::Claude, + "person@example.com".to_string(), + "session".to_string(), + NotificationType::HighUsage, + ))); + assert!(!manager.record_predictive_observation( + true, + ProviderId::Claude, + "oauth:person@example.com", + PredictiveWarningWindow::Session, + &reset, + &risk, + )); + assert!( + manager + .predictive_warning_keys + .iter() + .all(|key| key.identity != "claude-account:unknown") + ); + } + #[test] fn session_below_high_does_not_rearm_weekly_high_toast() { // Repro for #198: session cool + weekly hot on every refresh used to From 6fbacb1052470715a25a9e2fee9f24eeeb3990ec Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:00:38 +0700 Subject: [PATCH 02/12] Separate Claude warning source lanes --- .../src-tauri/src/commands/claude_accounts.rs | 18 +- .../src-tauri/src/commands/providers.rs | 257 +++++++++++++----- 2 files changed, 206 insertions(+), 69 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs index b9f27525c9..db897db11e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -122,16 +122,20 @@ async fn refresh_after_claude_change(app: tauri::AppHandle) -> Result<(), String crate::events::emit_provider_updated(&app, &pending); let _emit = app.emit("claude-accounts-reconciling", ()); let refresh_app = app.clone(); - let mut ambient = - tauri::async_runtime::spawn(async move { super::refresh_providers(refresh_app).await }); + let mut ambient = tauri::async_runtime::spawn(async move { + let refresh_result = super::refresh_providers(refresh_app.clone()).await; + if refresh_providers_ran(&refresh_app) { + let _reconciled = refresh_app.emit("claude-accounts-reconciled", ()); + changed(&refresh_app); + } + refresh_result + }); let refresh_result = match tokio::time::timeout(AMBIENT_RECONCILIATION_GRACE, &mut ambient).await { Ok(joined) => joined.map_err(|error| error.to_string())?, Err(_) => { - // Dropping only the JoinHandle waiter detaches the ambient refresh; - // it keeps ownership of its provider request and can publish later. - let _reconciled = app.emit("claude-accounts-reconciled", ()); - changed(&app); + // Dropping only the JoinHandle waiter detaches the owning refresh. + // The task emits the terminal event only after its refresh settles. return Ok(()); } }; @@ -142,8 +146,6 @@ async fn refresh_after_claude_change(app: tauri::AppHandle) -> Result<(), String // owning batch's completion settle listeners. return refresh_result; } - let _reconciled = app.emit("claude-accounts-reconciled", ()); - changed(&app); refresh_result } diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 0748aee902..e3421992e7 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -5,11 +5,124 @@ use serde::Serialize; use std::sync::Arc; const MAX_CONCURRENT_PROVIDER_FETCHES: usize = 8; -const CLAUDE_UNRESOLVED_WARNING_IDENTITY: &str = "claude-account:unknown"; -fn is_claude_warning_source(source_label: &str) -> bool { - let source = source_label.trim().to_ascii_lowercase(); - source == "oauth" || source == "cli" || source.starts_with("cli ") +#[derive(Debug, Clone, PartialEq, Eq)] +enum WarningSourceLane { + ClaudeOauth, + ClaudeCli, + Named(String), +} + +impl WarningSourceLane { + fn from_label(provider: ProviderId, source_label: &str) -> Option { + let source = source_label.trim().to_ascii_lowercase(); + if source.is_empty() { + return None; + } + if provider == ProviderId::Claude { + if source == "oauth" { + return Some(Self::ClaudeOauth); + } + if source == "cli" || source.starts_with("cli ") { + return Some(Self::ClaudeCli); + } + } + Some(Self::Named(source)) + } + + fn key(&self) -> &str { + match self { + Self::ClaudeOauth => "oauth", + Self::ClaudeCli => "cli", + Self::Named(source) => source, + } + } + + fn supports_unresolved_account(&self) -> bool { + matches!(self, Self::ClaudeOauth | Self::ClaudeCli) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum WarningAccountState { + Token(uuid::Uuid), + Email(String), + Organization(String), + Unresolved, + Missing, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WarningIdentity { + provider: ProviderId, + source_lane: Option, + account: WarningAccountState, +} + +impl WarningIdentity { + fn new( + provider: ProviderId, + source_label: &str, + account_email: Option<&str>, + account_organization: Option<&str>, + token_account_id: Option, + ) -> Self { + let source_lane = WarningSourceLane::from_label(provider, source_label); + let normalized = |value: Option<&str>| { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase) + }; + let account = if let Some(id) = token_account_id { + WarningAccountState::Token(id) + } else if let Some(email) = normalized(account_email) { + WarningAccountState::Email(email) + } else if let Some(organization) = normalized(account_organization) { + WarningAccountState::Organization(organization) + } else if provider == ProviderId::Claude + && source_lane + .as_ref() + .is_some_and(WarningSourceLane::supports_unresolved_account) + { + WarningAccountState::Unresolved + } else { + WarningAccountState::Missing + }; + Self { + provider, + source_lane, + account, + } + } + + fn unresolved_key(&self) -> Option { + let lane = self.source_lane.as_ref()?; + (self.provider == ProviderId::Claude && lane.supports_unresolved_account()) + .then(|| format!("{}:{}:unknown", self.provider.cli_name(), lane.key())) + } + + fn threshold_key(&self) -> String { + match &self.account { + WarningAccountState::Token(id) => format!("token-account:{}", id.as_hyphenated()), + WarningAccountState::Email(email) => email.clone(), + WarningAccountState::Organization(organization) => format!("org:{organization}"), + WarningAccountState::Unresolved => self.unresolved_key().unwrap_or_default(), + WarningAccountState::Missing => String::new(), + } + } + + fn predictive_key(&self) -> Option { + match &self.account { + WarningAccountState::Token(id) => Some(format!("token-account:{}", id.as_hyphenated())), + WarningAccountState::Email(email) => self + .source_lane + .as_ref() + .map(|lane| format!("{}:{email}", lane.key())), + WarningAccountState::Unresolved => self.unresolved_key(), + WarningAccountState::Organization(_) | WarningAccountState::Missing => None, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1017,11 +1130,20 @@ fn notify_usage_thresholds( .get(&provider) .and_then(ProviderAccountData::active_account) .map(|account| account.id); - let account = quota_notification_account_identity(snapshot, token_account_id); - if provider == ProviderId::Claude && account != CLAUDE_UNRESOLVED_WARNING_IDENTITY { + let warning_identity = WarningIdentity::new( + provider, + &snapshot.source_label, + snapshot.account_email.as_deref(), + snapshot.account_organization.as_deref(), + token_account_id, + ); + let account = warning_identity.threshold_key(); + if let Some(unresolved) = warning_identity.unresolved_key() + && unresolved != account + { guard.notification_manager.adopt_threshold_account_identity( provider, - CLAUDE_UNRESOLVED_WARNING_IDENTITY, + &unresolved, &account, ); } @@ -1105,33 +1227,18 @@ fn quota_notification_account_identity( snapshot: &ProviderUsageSnapshot, token_account_id: Option, ) -> String { - if let Some(id) = token_account_id { - return format!("token-account:{}", id.as_hyphenated()); - } - if let Some(email) = snapshot - .account_email - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - return email.to_ascii_lowercase(); - } - if let Some(org) = snapshot - .account_organization - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - return format!("org:{}", org.to_ascii_lowercase()); - } - if snapshot.provider_id == ProviderId::Claude.cli_name() - && is_claude_warning_source(&snapshot.source_label) - { - return CLAUDE_UNRESOLVED_WARNING_IDENTITY.to_string(); - } - // Do not fall back to plan_name/login_method — those are display tiers and - // flicker across refreshes, re-arming still-hot windows for a new identity. - String::new() + ProviderId::from_cli_name(&snapshot.provider_id) + .map(|provider| { + WarningIdentity::new( + provider, + &snapshot.source_label, + snapshot.account_email.as_deref(), + snapshot.account_organization.as_deref(), + token_account_id, + ) + .threshold_key() + }) + .unwrap_or_default() } fn notify_predictive_pace( @@ -1151,20 +1258,20 @@ fn notify_predictive_pace( .get(&provider) .and_then(ProviderAccountData::active_account) .map(|account| account.id); - let Some(identity) = predictive_warning_identity( + let warning_identity = WarningIdentity::new( provider, &snapshot.source_label, snapshot.account_email.as_deref(), + None, token_account_id, - ) else { + ); + let Some(identity) = warning_identity.predictive_key() else { return; }; - if provider == ProviderId::Claude && identity != CLAUDE_UNRESOLVED_WARNING_IDENTITY { - manager.adopt_predictive_account_identity( - provider, - CLAUDE_UNRESOLVED_WARNING_IDENTITY, - &identity, - ); + if let Some(unresolved) = warning_identity.unresolved_key() + && unresolved != identity + { + manager.adopt_predictive_account_identity(provider, &unresolved, &identity); } let observed_at = chrono::DateTime::parse_from_rfc3339(&snapshot.updated_at) .ok() @@ -1223,21 +1330,14 @@ fn predictive_warning_identity( if !matches!(provider, ProviderId::Claude | ProviderId::Codex) { return None; } - if let Some(id) = token_account_id { - return Some(format!("token-account:{}", id.as_hyphenated())); - } - let source = source_label.trim().to_ascii_lowercase(); - let account = account_email - .unwrap_or_default() - .trim() - .to_ascii_lowercase(); - if provider == ProviderId::Claude && account.is_empty() && is_claude_warning_source(&source) { - return Some(CLAUDE_UNRESOLVED_WARNING_IDENTITY.to_string()); - } - if source.is_empty() || account.is_empty() { - return None; - } - Some(format!("{source}:{account}")) + WarningIdentity::new( + provider, + source_label, + account_email, + None, + token_account_id, + ) + .predictive_key() } #[derive(Debug, Clone, Serialize)] @@ -1353,7 +1453,7 @@ mod predictive_warning_tests { fn predictive_warning_identity_scopes_unresolved_claude_sources() { assert_eq!( predictive_warning_identity(ProviderId::Claude, "oauth", None, None), - Some(CLAUDE_UNRESOLVED_WARNING_IDENTITY.to_string()) + Some("claude:oauth:unknown".to_string()) ); assert_eq!( predictive_warning_identity(ProviderId::Codex, "cli", Some(" "), None), @@ -1361,7 +1461,7 @@ mod predictive_warning_tests { ); assert_eq!( predictive_warning_identity(ProviderId::Claude, "cli (reduced fidelity)", None, None,), - Some(CLAUDE_UNRESOLVED_WARNING_IDENTITY.to_string()) + Some("claude:cli:unknown".to_string()) ); assert_eq!( predictive_warning_identity(ProviderId::Claude, "web", None, None), @@ -1408,13 +1508,48 @@ mod predictive_warning_tests { snapshot.source_label = "oauth".to_string(); assert_eq!( quota_notification_account_identity(&snapshot, None), - CLAUDE_UNRESOLVED_WARNING_IDENTITY + "claude:oauth:unknown" ); snapshot.plan_name = None; + snapshot.source_label = "cli (reduced fidelity)".to_string(); assert_eq!( quota_notification_account_identity(&snapshot, None), - CLAUDE_UNRESOLVED_WARNING_IDENTITY + "claude:cli:unknown" + ); + } + + #[test] + fn claude_unresolved_warning_lanes_adopt_only_the_matching_source() { + let oauth = WarningIdentity::new(ProviderId::Claude, "oauth", None, None, None); + let cli = WarningIdentity::new( + ProviderId::Claude, + "cli (reduced fidelity)", + None, + None, + None, + ); + let resolved_cli = WarningIdentity::new( + ProviderId::Claude, + "cli", + Some("person@example.com"), + None, + None, + ); + + assert_eq!( + oauth.unresolved_key().as_deref(), + Some("claude:oauth:unknown") + ); + assert_eq!(cli.unresolved_key().as_deref(), Some("claude:cli:unknown")); + assert_eq!( + resolved_cli.unresolved_key().as_deref(), + Some("claude:cli:unknown") + ); + assert_ne!(oauth.unresolved_key(), resolved_cli.unresolved_key()); + assert_eq!( + resolved_cli.predictive_key().as_deref(), + Some("cli:person@example.com") ); } From 651414fe0cb7357ba0f17b5f92a7248472f63f70 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:35:19 +0700 Subject: [PATCH 03/12] Fix Claude refresh generation ownership --- .../src-tauri/src/commands/claude_accounts.rs | 39 ++--- .../src-tauri/src/commands/providers.rs | 140 +++++++++++++----- 2 files changed, 113 insertions(+), 66 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs index db897db11e..9afd060d27 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -123,40 +123,21 @@ async fn refresh_after_claude_change(app: tauri::AppHandle) -> Result<(), String let _emit = app.emit("claude-accounts-reconciling", ()); let refresh_app = app.clone(); let mut ambient = tauri::async_runtime::spawn(async move { - let refresh_result = super::refresh_providers(refresh_app.clone()).await; - if refresh_providers_ran(&refresh_app) { + let refresh_outcome = super::do_refresh_providers_with_outcome(&refresh_app).await?; + if refresh_outcome.published_generation().is_some() { let _reconciled = refresh_app.emit("claude-accounts-reconciled", ()); changed(&refresh_app); } - refresh_result + Ok::<(), String>(()) }); - let refresh_result = - match tokio::time::timeout(AMBIENT_RECONCILIATION_GRACE, &mut ambient).await { - Ok(joined) => joined.map_err(|error| error.to_string())?, - Err(_) => { - // Dropping only the JoinHandle waiter detaches the owning refresh. - // The task emits the terminal event only after its refresh settles. - return Ok(()); - } - }; - if !refresh_providers_ran(&app) { - // begin_provider_refresh skipped (another batch owns the refresh) or - // finish_provider_refresh dropped a superseded generation: a refresh - // is still in flight, so stay in the reconciling phase and let the - // owning batch's completion settle listeners. - return refresh_result; + match tokio::time::timeout(AMBIENT_RECONCILIATION_GRACE, &mut ambient).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + // Dropping only the JoinHandle waiter detaches the owning refresh. + // The task emits the terminal event only after its refresh settles. + Ok(()) + } } - refresh_result -} - -/// Whether the completed refresh batch owned the generation it published -/// under. A skipped or superseded batch must not settle account listeners. -fn refresh_providers_ran(app: &tauri::AppHandle) -> bool { - let state = app.state::>(); - state - .lock() - .map(|guard| !guard.is_refreshing) - .unwrap_or(false) } #[derive(Debug, Clone, Copy)] diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index e3421992e7..f5691655ef 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -131,6 +131,22 @@ enum RefreshScope { AutoResume, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProviderRefreshOutcome { + Skipped, + Published { generation: u64 }, + Superseded { generation: u64 }, +} + +impl ProviderRefreshOutcome { + pub(crate) fn published_generation(self) -> Option { + match self { + Self::Published { generation } => Some(generation), + Self::Skipped | Self::Superseded { .. } => None, + } + } +} + impl RefreshScope { fn provider_ids(self, settings: &Settings, enabled_ids: &[ProviderId]) -> Vec { match self { @@ -470,11 +486,19 @@ pub(crate) fn is_current_provider_refresh_generation(guard: &AppState, generatio /// Core refresh logic, usable from both the Tauri command and tray menu actions. pub(crate) async fn do_refresh_providers(app: &tauri::AppHandle) -> Result<(), String> { + do_refresh_providers_with_outcome(app).await.map(|_| ()) +} + +pub(crate) async fn do_refresh_providers_with_outcome( + app: &tauri::AppHandle, +) -> Result { do_refresh_providers_with_policy(app, true, RefreshScope::AllEnabled).await } pub(crate) async fn do_refresh_providers_if_stale(app: &tauri::AppHandle) -> Result<(), String> { - do_refresh_providers_with_policy(app, false, RefreshScope::AllEnabled).await + do_refresh_providers_with_policy(app, false, RefreshScope::AllEnabled) + .await + .map(|_| ()) } /// Refresh only enabled providers with the opt-in exact-session watcher. This @@ -483,14 +507,16 @@ pub(crate) async fn do_refresh_providers_if_stale(app: &tauri::AppHandle) -> Res pub(crate) async fn do_refresh_auto_resume_providers_if_stale( app: &tauri::AppHandle, ) -> Result<(), String> { - do_refresh_providers_with_policy(app, false, RefreshScope::AutoResume).await + do_refresh_providers_with_policy(app, false, RefreshScope::AutoResume) + .await + .map(|_| ()) } async fn do_refresh_providers_with_policy( app: &tauri::AppHandle, force: bool, scope: RefreshScope, -) -> Result<(), String> { +) -> Result { let state = app.state::>(); let expected_generation = state .lock() @@ -500,7 +526,7 @@ async fn do_refresh_providers_with_policy( let enabled_ids = settings.get_enabled_provider_ids(); let refresh_ids = scope.provider_ids(&settings, &enabled_ids); if refresh_ids.is_empty() { - return Ok(()); + return Ok(ProviderRefreshOutcome::Skipped); } let inputs = ProviderRefreshInputs::load(settings, enabled_ids); @@ -509,7 +535,7 @@ async fn do_refresh_providers_with_policy( else { // Settings or account identity changed while inputs were loading, or a // newer batch already owns the refresh. Discard this input snapshot. - return Ok(()); + return Ok(ProviderRefreshOutcome::Skipped); }; // Ensure cache only contains currently enabled providers for this generation. @@ -541,14 +567,14 @@ async fn do_refresh_providers_with_policy( let Some(error_count) = finish_provider_refresh(&state, generation)? else { // Superseded by a newer generation (or invalidate). Do not clear UI // "refreshing" for a dead batch or stamp tray from incomplete work. - return Ok(()); + return Ok(ProviderRefreshOutcome::Superseded { generation }); }; update_tray_and_notifications(app, &state, &inputs.settings, &inputs.token_accounts)?; events::emit_refresh_complete(app, enabled_count, error_count); crate::auto_refresh::schedule_refresh_enrichment(&inputs.settings); - Ok(()) + Ok(ProviderRefreshOutcome::Published { generation }) } fn begin_provider_refresh( @@ -1081,21 +1107,25 @@ fn finish_provider_refresh( generation: u64, ) -> Result, String> { let mut guard = state.lock().map_err(|e| e.to_string())?; - if !is_current_provider_refresh_generation(&guard, generation) { + Ok(complete_provider_refresh(&mut guard, generation)) +} + +fn complete_provider_refresh(guard: &mut AppState, generation: u64) -> Option { + if !is_current_provider_refresh_generation(guard, generation) { // A newer begin or invalidate owns the lock/generation. Do not clear // is_refreshing — that would race a live successor batch. - return Ok(None); + return None; } guard.is_refreshing = false; guard.provider_refresh_started_at = None; guard.provider_cache_updated_at = Some(std::time::Instant::now()); - Ok(Some( + Some( guard .provider_cache .iter() .filter(|s| s.error.is_some()) .count(), - )) + ) } fn update_tray_and_notifications( @@ -1321,25 +1351,6 @@ fn notify_predictive_pace( } } -fn predictive_warning_identity( - provider: ProviderId, - source_label: &str, - account_email: Option<&str>, - token_account_id: Option, -) -> Option { - if !matches!(provider, ProviderId::Claude | ProviderId::Codex) { - return None; - } - WarningIdentity::new( - provider, - source_label, - account_email, - None, - token_account_id, - ) - .predictive_key() -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeepSeekPricingStatus { @@ -1418,32 +1429,38 @@ mod predictive_warning_tests { let account_id = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(); assert_eq!( - predictive_warning_identity( + WarningIdentity::new( ProviderId::Claude, "cli", Some("Person@Example.com"), None, + None, ) + .predictive_key() .as_deref(), Some("cli:person@example.com") ); assert_eq!( - predictive_warning_identity( + WarningIdentity::new( ProviderId::Claude, "oauth", Some("Person@Example.com"), None, + None, ) + .predictive_key() .as_deref(), Some("oauth:person@example.com") ); assert_eq!( - predictive_warning_identity( + WarningIdentity::new( ProviderId::Claude, "oauth", Some("Person@Example.com"), + None, Some(account_id), ) + .predictive_key() .as_deref(), Some("token-account:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") ); @@ -1452,19 +1469,26 @@ mod predictive_warning_tests { #[test] fn predictive_warning_identity_scopes_unresolved_claude_sources() { assert_eq!( - predictive_warning_identity(ProviderId::Claude, "oauth", None, None), + WarningIdentity::new(ProviderId::Claude, "oauth", None, None, None).predictive_key(), Some("claude:oauth:unknown".to_string()) ); assert_eq!( - predictive_warning_identity(ProviderId::Codex, "cli", Some(" "), None), + WarningIdentity::new(ProviderId::Codex, "cli", Some(" "), None, None).predictive_key(), None ); assert_eq!( - predictive_warning_identity(ProviderId::Claude, "cli (reduced fidelity)", None, None,), + WarningIdentity::new( + ProviderId::Claude, + "cli (reduced fidelity)", + None, + None, + None, + ) + .predictive_key(), Some("claude:cli:unknown".to_string()) ); assert_eq!( - predictive_warning_identity(ProviderId::Claude, "web", None, None), + WarningIdentity::new(ProviderId::Claude, "web", None, None, None).predictive_key(), None ); } @@ -1744,4 +1768,46 @@ mod refresh_generation_tests { assert_eq!(generation, expected_generation.wrapping_add(1)); assert!(state.is_refreshing); } + + #[test] + fn superseded_generation_cannot_publish_or_release_its_successor() { + let mut state = AppState::new(); + let initial_generation = state.provider_refresh_generation; + let first_generation = + reserve_provider_refresh(&mut state, true, &[ProviderId::Claude], initial_generation) + .expect("first reservation should succeed") + .expect("first refresh should be reserved"); + + invalidate_account_usage(&mut state, ProviderId::Claude); + let successor_input_generation = state.provider_refresh_generation; + let successor_generation = reserve_provider_refresh( + &mut state, + true, + &[ProviderId::Claude], + successor_input_generation, + ) + .expect("successor reservation should succeed") + .expect("successor refresh should be reserved"); + + assert_eq!( + complete_provider_refresh(&mut state, first_generation), + None + ); + assert!(state.is_refreshing); + assert_eq!(state.provider_refresh_generation, successor_generation); + + assert!(complete_provider_refresh(&mut state, successor_generation).is_some()); + assert!(!state.is_refreshing); + assert_eq!(state.provider_refresh_generation, successor_generation); + } + + #[test] + fn refresh_outcome_reports_only_the_generation_that_published() { + let published = ProviderRefreshOutcome::Published { generation: 42 }; + let superseded = ProviderRefreshOutcome::Superseded { generation: 41 }; + + assert_eq!(published.published_generation(), Some(42)); + assert_eq!(superseded.published_generation(), None); + assert_eq!(ProviderRefreshOutcome::Skipped.published_generation(), None); + } } From 5c7354ff55ab7cf404915db3d2aa5ef62a30143f Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:08:42 +0700 Subject: [PATCH 04/12] Fix Claude reconciliation generation lifecycle --- .../src-tauri/src/commands/claude_accounts.rs | 26 +- .../src/commands/claude_reconciliation.rs | 257 ++++++++++++++++++ .../src-tauri/src/commands/mod.rs | 1 + .../src-tauri/src/commands/providers.rs | 178 ++++++++---- 4 files changed, 401 insertions(+), 61 deletions(-) create mode 100644 apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs index 9afd060d27..e3bb6e9bad 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -1,4 +1,4 @@ -use super::invalidate_account_usage; +use super::{claude_reconciliation, invalidate_account_usage}; use crate::state::AppState; use codexbar::core::ProviderId; use codexbar::providers::claude::accounts::{self, AccountManager, ClaudeAccount}; @@ -105,14 +105,14 @@ fn account_row_for_slot( /// settle. Event order is load-bearing for every listener: /// /// 1. `claude-accounts-reconciling` — listeners show the reconciling phase. -/// 2. the bounded provider refresh runs; superseded batches are detected below. -/// 3. `claude-accounts-reconciled` — the terminal marker; settling must be -/// event-driven, never inferred from a switch promise resolving. +/// 2. the bounded provider refresh runs with a typed ownership outcome. +/// 3. `claude-accounts-reconciled` — the success/failure terminal marker; +/// settling must be event-driven, never inferred from a switch promise. /// 4. `claude-accounts-updated` + tray rebuild — the settled reload. /// -/// Do not reorder these emits. A refresh that never started or was superseded -/// mid-flight keeps the reconciling phase armed instead of settling, so no -/// surface shows "settled" while a refresh is still in flight. +/// The Claude reconciliation coordinator serializes begin/terminal emission. +/// Detached stale workers become superseded terminals internally and cannot +/// settle a newer operation. Every current outcome clears the reconciling UI. async fn refresh_after_claude_change(app: tauri::AppHandle) -> Result<(), String> { let pending = { let state = app.state::>(); @@ -120,15 +120,17 @@ async fn refresh_after_claude_change(app: tauri::AppHandle) -> Result<(), String invalidate_account_usage(&mut state, ProviderId::Claude) }; crate::events::emit_provider_updated(&app, &pending); - let _emit = app.emit("claude-accounts-reconciling", ()); + let reconciliation = claude_reconciliation::begin(&app); let refresh_app = app.clone(); let mut ambient = tauri::async_runtime::spawn(async move { - let refresh_outcome = super::do_refresh_providers_with_outcome(&refresh_app).await?; - if refresh_outcome.published_generation().is_some() { - let _reconciled = refresh_app.emit("claude-accounts-reconciled", ()); + let terminal = claude_reconciliation::ClaudeReconciliationResult::from_refresh( + super::do_refresh_providers_with_outcome(&refresh_app).await, + ); + let command_result = terminal.command_result(); + if claude_reconciliation::complete(&refresh_app, reconciliation, terminal) { changed(&refresh_app); } - Ok::<(), String>(()) + command_result }); match tokio::time::timeout(AMBIENT_RECONCILIATION_GRACE, &mut ambient).await { Ok(joined) => joined.map_err(|error| error.to_string())?, diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs new file mode 100644 index 0000000000..8a967b2372 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs @@ -0,0 +1,257 @@ +use super::{ProviderRefreshOutcome, ProviderRefreshSkipReason}; +use serde::Serialize; +use std::sync::{LazyLock, Mutex, MutexGuard}; +use tauri::Emitter; + +static COORDINATOR: LazyLock> = + LazyLock::new(|| Mutex::new(ClaudeReconciliationCoordinator::default())); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ClaudeReconciliationToken(u64); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ClaudeReconciliationResult { + status: ClaudeReconciliationStatus, + provider_refresh_generation: Option, + detail: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClaudeReconciliationTerminal { + generation: u64, + status: ClaudeReconciliationStatus, + provider_refresh_generation: Option, + detail: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +enum ClaudeReconciliationStatus { + Succeeded, + Failed, +} + +impl ClaudeReconciliationResult { + pub(super) fn from_refresh(result: Result) -> Self { + match result { + Ok(ProviderRefreshOutcome::Published { generation }) => { + Self::succeeded(Some(generation), "published") + } + Ok(ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::NoEnabledProviders, + }) => Self::succeeded(None, "noEnabledProviders"), + Ok(ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::CacheFresh { generation }, + }) => Self::succeeded(Some(generation), "cacheFresh"), + Ok(ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::Active { generation }, + }) => Self::failed( + Some(generation), + format!("provider refresh generation {generation} is already active"), + ), + Ok(ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::InputSuperseded { expected, current }, + }) => Self::failed( + Some(current), + format!("provider refresh input generation {expected} was superseded by {current}"), + ), + Ok(ProviderRefreshOutcome::Superseded { + generation, + current_generation, + }) => Self::failed( + Some(current_generation), + format!( + "provider refresh generation {generation} was superseded by {current_generation}" + ), + ), + Err(error) => Self::failed(None, error), + } + } + + fn succeeded(provider_refresh_generation: Option, detail: impl Into) -> Self { + Self { + status: ClaudeReconciliationStatus::Succeeded, + provider_refresh_generation, + detail: detail.into(), + } + } + + fn failed(provider_refresh_generation: Option, detail: impl Into) -> Self { + Self { + status: ClaudeReconciliationStatus::Failed, + provider_refresh_generation, + detail: detail.into(), + } + } + + pub(super) fn command_result(&self) -> Result<(), String> { + match self.status { + ClaudeReconciliationStatus::Succeeded => Ok(()), + ClaudeReconciliationStatus::Failed => Err(self.detail.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CompletionDisposition { + Current(ClaudeReconciliationTerminal), + Superseded(ClaudeReconciliationTerminal), +} + +#[derive(Debug, Default)] +struct ClaudeReconciliationCoordinator { + next_generation: u64, + active_generation: Option, +} + +impl ClaudeReconciliationCoordinator { + fn begin(&mut self) -> ClaudeReconciliationToken { + self.next_generation = self.next_generation.wrapping_add(1); + let token = ClaudeReconciliationToken(self.next_generation); + self.active_generation = Some(token.0); + token + } + + fn complete( + &mut self, + token: ClaudeReconciliationToken, + result: ClaudeReconciliationResult, + ) -> CompletionDisposition { + if self.active_generation != Some(token.0) { + let successor = self.active_generation.unwrap_or(self.next_generation); + return CompletionDisposition::Superseded(ClaudeReconciliationTerminal { + generation: token.0, + status: ClaudeReconciliationStatus::Failed, + provider_refresh_generation: result.provider_refresh_generation, + detail: format!("superseded by Claude reconciliation generation {successor}"), + }); + } + self.active_generation = None; + CompletionDisposition::Current(ClaudeReconciliationTerminal { + generation: token.0, + status: result.status, + provider_refresh_generation: result.provider_refresh_generation, + detail: result.detail, + }) + } +} + +fn coordinator() -> MutexGuard<'static, ClaudeReconciliationCoordinator> { + COORDINATOR + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Begin a Claude reconciliation and publish its token while holding the same +/// lock used by terminal publication. This prevents an old detached worker's +/// terminal event from being interleaved after a newer reconciling event. +pub(super) fn begin(app: &tauri::AppHandle) -> ClaudeReconciliationToken { + let mut coordinator = coordinator(); + let token = coordinator.begin(); + let _ = app.emit( + "claude-accounts-reconciling", + serde_json::json!({ "generation": token.0 }), + ); + token +} + +/// Publish a terminal event only when `token` still owns the current Claude +/// reconciliation. The ownership check and event emission are serialized with +/// `begin`, so a stale worker cannot settle a newer operation. +pub(super) fn complete( + app: &tauri::AppHandle, + token: ClaudeReconciliationToken, + result: ClaudeReconciliationResult, +) -> bool { + let mut coordinator = coordinator(); + match coordinator.complete(token, result) { + CompletionDisposition::Current(terminal) => { + let _ = app.emit("claude-accounts-reconciled", terminal); + true + } + CompletionDisposition::Superseded(terminal) => { + tracing::debug!( + generation = terminal.generation, + detail = %terminal.detail, + "Claude reconciliation completed after it was superseded" + ); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlapping_reconciliations_only_allow_the_latest_terminal() { + let mut coordinator = ClaudeReconciliationCoordinator::default(); + let first = coordinator.begin(); + let second = coordinator.begin(); + + assert!(matches!( + coordinator.complete(first, ClaudeReconciliationResult::succeeded(None, "first")), + CompletionDisposition::Superseded(ClaudeReconciliationTerminal { + generation, + status: ClaudeReconciliationStatus::Failed, + .. + }) if generation == first.0 + )); + assert!(matches!( + coordinator.complete(second, ClaudeReconciliationResult::succeeded(None, "second")), + CompletionDisposition::Current(ClaudeReconciliationTerminal { + generation, + status: ClaudeReconciliationStatus::Succeeded, + .. + }) if generation == second.0 + )); + } + + #[test] + fn superseded_provider_refresh_is_an_explicit_failure() { + let terminal = + ClaudeReconciliationResult::from_refresh(Ok(ProviderRefreshOutcome::Superseded { + generation: 4, + current_generation: 5, + })); + + assert_eq!(terminal.status, ClaudeReconciliationStatus::Failed); + assert_eq!(terminal.provider_refresh_generation, Some(5)); + assert!(terminal.command_result().is_err()); + } + + #[test] + fn active_provider_refresh_is_an_explicit_failure() { + let terminal = + ClaudeReconciliationResult::from_refresh(Ok(ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::Active { generation: 9 }, + })); + + assert_eq!(terminal.status, ClaudeReconciliationStatus::Failed); + assert_eq!(terminal.provider_refresh_generation, Some(9)); + assert!(terminal.command_result().is_err()); + } + + #[test] + fn no_enabled_provider_is_an_explicit_success() { + let terminal = + ClaudeReconciliationResult::from_refresh(Ok(ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::NoEnabledProviders, + })); + + assert_eq!(terminal.status, ClaudeReconciliationStatus::Succeeded); + assert_eq!(terminal.detail, "noEnabledProviders"); + assert_eq!(terminal.command_result(), Ok(())); + } + + #[test] + fn provider_refresh_error_is_an_explicit_failure() { + let terminal = ClaudeReconciliationResult::from_refresh(Err("refresh failed".into())); + + assert_eq!(terminal.status, ClaudeReconciliationStatus::Failed); + assert_eq!(terminal.detail, "refresh failed"); + assert_eq!(terminal.command_result(), Err("refresh failed".into())); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index b7d71cf937..965b69d5cb 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -33,6 +33,7 @@ mod agent_sessions; mod bridge; mod browser_import; mod claude_accounts; +mod claude_reconciliation; mod codex_accounts; mod codex_workspaces; mod credential_detection; diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index f5691655ef..43cd7cb44d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -133,18 +133,36 @@ enum RefreshScope { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ProviderRefreshOutcome { - Skipped, - Published { generation: u64 }, - Superseded { generation: u64 }, + Skipped { + reason: ProviderRefreshSkipReason, + }, + Published { + generation: u64, + }, + Superseded { + generation: u64, + current_generation: u64, + }, } -impl ProviderRefreshOutcome { - pub(crate) fn published_generation(self) -> Option { - match self { - Self::Published { generation } => Some(generation), - Self::Skipped | Self::Superseded { .. } => None, - } - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProviderRefreshSkipReason { + NoEnabledProviders, + Active { generation: u64 }, + InputSuperseded { expected: u64, current: u64 }, + CacheFresh { generation: u64 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProviderRefreshReservation { + Reserved { generation: u64 }, + Skipped(ProviderRefreshSkipReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProviderRefreshCompletion { + Published { error_count: usize }, + Superseded { current_generation: u64 }, } impl RefreshScope { @@ -526,16 +544,20 @@ async fn do_refresh_providers_with_policy( let enabled_ids = settings.get_enabled_provider_ids(); let refresh_ids = scope.provider_ids(&settings, &enabled_ids); if refresh_ids.is_empty() { - return Ok(ProviderRefreshOutcome::Skipped); + return Ok(ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::NoEnabledProviders, + }); } let inputs = ProviderRefreshInputs::load(settings, enabled_ids); - let Some(generation) = - begin_provider_refresh(&state, force, &refresh_ids, expected_generation)? - else { - // Settings or account identity changed while inputs were loading, or a - // newer batch already owns the refresh. Discard this input snapshot. - return Ok(ProviderRefreshOutcome::Skipped); + let generation = match begin_provider_refresh(&state, force, &refresh_ids, expected_generation)? + { + ProviderRefreshReservation::Reserved { generation } => generation, + ProviderRefreshReservation::Skipped(reason) => { + // Settings or account identity changed while inputs were loading, + // another batch owns the refresh, or the cache is already fresh. + return Ok(ProviderRefreshOutcome::Skipped { reason }); + } }; // Ensure cache only contains currently enabled providers for this generation. @@ -564,10 +586,16 @@ async fn do_refresh_providers_with_policy( ); await_provider_refreshes(handles).await; - let Some(error_count) = finish_provider_refresh(&state, generation)? else { - // Superseded by a newer generation (or invalidate). Do not clear UI - // "refreshing" for a dead batch or stamp tray from incomplete work. - return Ok(ProviderRefreshOutcome::Superseded { generation }); + let error_count = match finish_provider_refresh(&state, generation)? { + ProviderRefreshCompletion::Published { error_count } => error_count, + ProviderRefreshCompletion::Superseded { current_generation } => { + // Superseded by a newer generation (or invalidate). Do not clear UI + // "refreshing" for dead work or stamp tray from incomplete work. + return Ok(ProviderRefreshOutcome::Superseded { + generation, + current_generation, + }); + } }; update_tray_and_notifications(app, &state, &inputs.settings, &inputs.token_accounts)?; @@ -582,7 +610,7 @@ fn begin_provider_refresh( force: bool, provider_ids: &[ProviderId], expected_generation: u64, -) -> Result, String> { +) -> Result { let mut guard = state.lock().map_err(|e| e.to_string())?; reserve_provider_refresh(&mut guard, force, provider_ids, expected_generation) } @@ -592,22 +620,35 @@ fn reserve_provider_refresh( force: bool, provider_ids: &[ProviderId], expected_generation: u64, -) -> Result, String> { +) -> Result { if guard.is_refreshing { - return Ok(None); + return Ok(ProviderRefreshReservation::Skipped( + ProviderRefreshSkipReason::Active { + generation: guard.provider_refresh_generation, + }, + )); } if guard.provider_refresh_generation != expected_generation { - return Ok(None); + return Ok(ProviderRefreshReservation::Skipped( + ProviderRefreshSkipReason::InputSuperseded { + expected: expected_generation, + current: guard.provider_refresh_generation, + }, + )); } if provider_cache_can_skip_refresh(guard, force, provider_ids) { - return Ok(None); + return Ok(ProviderRefreshReservation::Skipped( + ProviderRefreshSkipReason::CacheFresh { + generation: guard.provider_refresh_generation, + }, + )); } guard.provider_refresh_generation = guard.provider_refresh_generation.wrapping_add(1); let generation = guard.provider_refresh_generation; guard.is_refreshing = true; guard.provider_refresh_started_at = Some(std::time::Instant::now()); - Ok(Some(generation)) + Ok(ProviderRefreshReservation::Reserved { generation }) } fn provider_cache_can_skip_refresh( @@ -1105,27 +1146,29 @@ async fn await_provider_refreshes(handles: Vec>) { fn finish_provider_refresh( state: &tauri::State<'_, Mutex>, generation: u64, -) -> Result, String> { +) -> Result { let mut guard = state.lock().map_err(|e| e.to_string())?; Ok(complete_provider_refresh(&mut guard, generation)) } -fn complete_provider_refresh(guard: &mut AppState, generation: u64) -> Option { +fn complete_provider_refresh(guard: &mut AppState, generation: u64) -> ProviderRefreshCompletion { if !is_current_provider_refresh_generation(guard, generation) { // A newer begin or invalidate owns the lock/generation. Do not clear // is_refreshing — that would race a live successor batch. - return None; + return ProviderRefreshCompletion::Superseded { + current_generation: guard.provider_refresh_generation, + }; } guard.is_refreshing = false; guard.provider_refresh_started_at = None; guard.provider_cache_updated_at = Some(std::time::Instant::now()); - Some( - guard + ProviderRefreshCompletion::Published { + error_count: guard .provider_cache .iter() .filter(|s| s.error.is_some()) .count(), - ) + } } fn update_tray_and_notifications( @@ -1750,7 +1793,10 @@ mod refresh_generation_tests { assert_eq!( reserve_provider_refresh(&mut state, true, &[ProviderId::Codex], expected_generation,) .expect("reservation should not fail"), - None + ProviderRefreshReservation::Skipped(ProviderRefreshSkipReason::InputSuperseded { + expected: expected_generation, + current: state.provider_refresh_generation, + }) ); assert!(!state.is_refreshing); } @@ -1760,10 +1806,12 @@ mod refresh_generation_tests { let mut state = AppState::new(); let expected_generation = state.provider_refresh_generation; - let generation = + let ProviderRefreshReservation::Reserved { generation } = reserve_provider_refresh(&mut state, true, &[ProviderId::Codex], expected_generation) .expect("reservation should succeed") - .expect("refresh should be reserved"); + else { + panic!("refresh should be reserved"); + }; assert_eq!(generation, expected_generation.wrapping_add(1)); assert!(state.is_refreshing); @@ -1773,41 +1821,73 @@ mod refresh_generation_tests { fn superseded_generation_cannot_publish_or_release_its_successor() { let mut state = AppState::new(); let initial_generation = state.provider_refresh_generation; - let first_generation = - reserve_provider_refresh(&mut state, true, &[ProviderId::Claude], initial_generation) - .expect("first reservation should succeed") - .expect("first refresh should be reserved"); + let ProviderRefreshReservation::Reserved { + generation: first_generation, + } = reserve_provider_refresh(&mut state, true, &[ProviderId::Claude], initial_generation) + .expect("first reservation should succeed") + else { + panic!("first refresh should be reserved"); + }; invalidate_account_usage(&mut state, ProviderId::Claude); let successor_input_generation = state.provider_refresh_generation; - let successor_generation = reserve_provider_refresh( + let ProviderRefreshReservation::Reserved { + generation: successor_generation, + } = reserve_provider_refresh( &mut state, true, &[ProviderId::Claude], successor_input_generation, ) .expect("successor reservation should succeed") - .expect("successor refresh should be reserved"); + else { + panic!("successor refresh should be reserved"); + }; assert_eq!( complete_provider_refresh(&mut state, first_generation), - None + ProviderRefreshCompletion::Superseded { + current_generation: successor_generation + } ); assert!(state.is_refreshing); assert_eq!(state.provider_refresh_generation, successor_generation); - assert!(complete_provider_refresh(&mut state, successor_generation).is_some()); + assert!(matches!( + complete_provider_refresh(&mut state, successor_generation), + ProviderRefreshCompletion::Published { .. } + )); assert!(!state.is_refreshing); assert_eq!(state.provider_refresh_generation, successor_generation); } #[test] - fn refresh_outcome_reports_only_the_generation_that_published() { + fn refresh_outcome_preserves_generation_ownership_and_skip_reason() { let published = ProviderRefreshOutcome::Published { generation: 42 }; - let superseded = ProviderRefreshOutcome::Superseded { generation: 41 }; + let superseded = ProviderRefreshOutcome::Superseded { + generation: 41, + current_generation: 42, + }; + let active = ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::Active { generation: 42 }, + }; - assert_eq!(published.published_generation(), Some(42)); - assert_eq!(superseded.published_generation(), None); - assert_eq!(ProviderRefreshOutcome::Skipped.published_generation(), None); + assert_eq!( + published, + ProviderRefreshOutcome::Published { generation: 42 } + ); + assert_eq!( + superseded, + ProviderRefreshOutcome::Superseded { + generation: 41, + current_generation: 42 + } + ); + assert_eq!( + active, + ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::Active { generation: 42 } + } + ); } } From 9ea414beda80a7a7db2d0302c5867fac56942012 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:32:48 +0700 Subject: [PATCH 05/12] Harden Claude reconciliation lifecycle --- .../src/commands/claude_reconciliation.rs | 252 +++++++++- .../src-tauri/src/commands/mod.rs | 5 + .../src/commands/provider_refresh.rs | 225 +++++++++ .../src-tauri/src/commands/providers.rs | 472 +----------------- .../src/commands/warning_identity.rs | 228 +++++++++ 5 files changed, 700 insertions(+), 482 deletions(-) create mode 100644 apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs create mode 100644 apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs index 8a967b2372..9622cae816 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs @@ -1,10 +1,14 @@ use super::{ProviderRefreshOutcome, ProviderRefreshSkipReason}; use serde::Serialize; +use std::collections::VecDeque; use std::sync::{LazyLock, Mutex, MutexGuard}; +use std::time::Duration; use tauri::Emitter; static COORDINATOR: LazyLock> = LazyLock::new(|| Mutex::new(ClaudeReconciliationCoordinator::default())); +const REPLAY_DELAY_MIN: Duration = Duration::from_millis(250); +const REPLAY_DELAY_MAX: Duration = Duration::from_secs(5); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct ClaudeReconciliationToken(u64); @@ -25,6 +29,31 @@ struct ClaudeReconciliationTerminal { detail: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClaudeReconciliationStarted { + generation: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PendingEvent { + Reconciling(ClaudeReconciliationStarted), + Reconciled(ClaudeReconciliationTerminal), +} + +impl PendingEvent { + fn publish(&self, app: &tauri::AppHandle) -> Result<(), String> { + match self { + Self::Reconciling(payload) => app + .emit("claude-accounts-reconciling", payload) + .map_err(|error| error.to_string()), + Self::Reconciled(payload) => app + .emit("claude-accounts-reconciled", payload) + .map_err(|error| error.to_string()), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] enum ClaudeReconciliationStatus { @@ -103,6 +132,9 @@ enum CompletionDisposition { struct ClaudeReconciliationCoordinator { next_generation: u64, active_generation: Option, + pending_events: VecDeque, + publisher_active: bool, + replay_scheduled: bool, } impl ClaudeReconciliationCoordinator { @@ -110,6 +142,10 @@ impl ClaudeReconciliationCoordinator { self.next_generation = self.next_generation.wrapping_add(1); let token = ClaudeReconciliationToken(self.next_generation); self.active_generation = Some(token.0); + self.pending_events + .push_back(PendingEvent::Reconciling(ClaudeReconciliationStarted { + generation: token.0, + })); token } @@ -128,12 +164,50 @@ impl ClaudeReconciliationCoordinator { }); } self.active_generation = None; - CompletionDisposition::Current(ClaudeReconciliationTerminal { + let terminal = ClaudeReconciliationTerminal { generation: token.0, status: result.status, provider_refresh_generation: result.provider_refresh_generation, detail: result.detail, - }) + }; + self.pending_events + .push_back(PendingEvent::Reconciled(terminal.clone())); + CompletionDisposition::Current(terminal) + } + + fn claim_pending_event(&mut self) -> Option { + if self.publisher_active { + return None; + } + let event = self.pending_events.front()?.clone(); + self.publisher_active = true; + Some(event) + } + + fn finish_publish(&mut self, event: &PendingEvent, succeeded: bool) { + debug_assert!(self.publisher_active); + self.publisher_active = false; + if succeeded { + let published = self.pending_events.pop_front(); + debug_assert_eq!(published.as_ref(), Some(event)); + } + } + + fn schedule_replay(&mut self) -> bool { + if self.replay_scheduled { + return false; + } + self.replay_scheduled = true; + true + } + + fn finish_replay_if_idle(&mut self) -> bool { + if self.pending_events.is_empty() && !self.publisher_active { + self.replay_scheduled = false; + true + } else { + false + } } } @@ -143,33 +217,90 @@ fn coordinator() -> MutexGuard<'static, ClaudeReconciliationCoordinator> { .unwrap_or_else(|poisoned| poisoned.into_inner()) } -/// Begin a Claude reconciliation and publish its token while holding the same -/// lock used by terminal publication. This prevents an old detached worker's -/// terminal event from being interleaved after a newer reconciling event. +fn publish_pending_with( + state: &Mutex, + mut publish: F, +) -> Result<(), String> +where + F: FnMut(&PendingEvent) -> Result<(), String>, +{ + loop { + let Some(event) = state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .claim_pending_event() + else { + return Ok(()); + }; + + let result = publish(&event); + state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .finish_publish(&event, result.is_ok()); + result?; + } +} + +fn publish_pending(app: &tauri::AppHandle) -> Result<(), String> { + publish_pending_with(&COORDINATOR, |event| event.publish(app)) +} + +fn schedule_replay(app: tauri::AppHandle) { + if !coordinator().schedule_replay() { + return; + } + + tauri::async_runtime::spawn(async move { + let mut delay = REPLAY_DELAY_MIN; + loop { + tokio::time::sleep(delay).await; + match publish_pending(&app) { + Ok(()) => { + if coordinator().finish_replay_if_idle() { + break; + } + delay = REPLAY_DELAY_MIN; + } + Err(error) => { + tracing::debug!( + %error, + "Claude reconciliation event replay remains pending" + ); + delay = delay.saturating_mul(2).min(REPLAY_DELAY_MAX); + } + } + } + }); +} + +fn publish_or_schedule_replay(app: &tauri::AppHandle) { + if let Err(error) = publish_pending(app) { + tracing::warn!(%error, "Claude reconciliation event queued for replay"); + schedule_replay(app.clone()); + } +} + +/// Begin a Claude reconciliation and queue its event in generation order. +/// Publication occurs after releasing the coordinator lock. Failed events stay +/// at the front of the queue and are retried before newer generations. pub(super) fn begin(app: &tauri::AppHandle) -> ClaudeReconciliationToken { - let mut coordinator = coordinator(); - let token = coordinator.begin(); - let _ = app.emit( - "claude-accounts-reconciling", - serde_json::json!({ "generation": token.0 }), - ); + let token = coordinator().begin(); + publish_or_schedule_replay(app); token } -/// Publish a terminal event only when `token` still owns the current Claude -/// reconciliation. The ownership check and event emission are serialized with -/// `begin`, so a stale worker cannot settle a newer operation. +/// Queue a terminal event only when `token` still owns the current Claude +/// reconciliation. The coordinator orders state transitions; the outbox orders +/// external publication without holding the global mutex across `app.emit`. pub(super) fn complete( app: &tauri::AppHandle, token: ClaudeReconciliationToken, result: ClaudeReconciliationResult, ) -> bool { - let mut coordinator = coordinator(); - match coordinator.complete(token, result) { - CompletionDisposition::Current(terminal) => { - let _ = app.emit("claude-accounts-reconciled", terminal); - true - } + let disposition = coordinator().complete(token, result); + let is_current = match disposition { + CompletionDisposition::Current(_) => true, CompletionDisposition::Superseded(terminal) => { tracing::debug!( generation = terminal.generation, @@ -178,7 +309,9 @@ pub(super) fn complete( ); false } - } + }; + publish_or_schedule_replay(app); + is_current } #[cfg(test)] @@ -209,6 +342,83 @@ mod tests { )); } + #[test] + fn failed_terminal_publish_is_retained_and_replayed_before_a_new_generation() { + let state = Mutex::new(ClaudeReconciliationCoordinator::default()); + let first = state.lock().unwrap().begin(); + publish_pending_with(&state, |_| Ok(())).expect("begin event should publish"); + state.lock().unwrap().complete( + first, + ClaudeReconciliationResult::succeeded(Some(7), "published"), + ); + + assert_eq!( + publish_pending_with(&state, |_| Err("event transport unavailable".into())), + Err("event transport unavailable".into()) + ); + { + let coordinator = state.lock().unwrap(); + assert_eq!(coordinator.pending_events.len(), 1); + assert!(!coordinator.publisher_active); + } + + let second = state.lock().unwrap().begin(); + let mut replayed = Vec::new(); + publish_pending_with(&state, |event| { + replayed.push(event.clone()); + Ok(()) + }) + .expect("queued events should replay"); + + assert!(matches!( + replayed.as_slice(), + [ + PendingEvent::Reconciled(ClaudeReconciliationTerminal { + generation: first_generation, + .. + }), + PendingEvent::Reconciling(ClaudeReconciliationStarted { + generation: second_generation, + }), + ] if *first_generation == first.0 && *second_generation == second.0 + )); + assert!(state.lock().unwrap().pending_events.is_empty()); + } + + #[test] + fn publisher_runs_without_the_coordinator_lock_and_drains_reentrant_work_in_order() { + let state = Mutex::new(ClaudeReconciliationCoordinator::default()); + let first = state.lock().unwrap().begin(); + let mut injected = false; + let mut published = Vec::new(); + + publish_pending_with(&state, |event| { + let guard = state + .try_lock() + .expect("external publisher must run outside the coordinator lock"); + drop(guard); + published.push(event.clone()); + if !injected { + injected = true; + state.lock().unwrap().begin(); + } + Ok(()) + }) + .expect("reentrant enqueue should drain"); + + assert!(matches!( + published.as_slice(), + [ + PendingEvent::Reconciling(ClaudeReconciliationStarted { + generation: first_generation, + }), + PendingEvent::Reconciling(ClaudeReconciliationStarted { + generation: second_generation, + }), + ] if *first_generation == first.0 && *second_generation == first.0.wrapping_add(1) + )); + } + #[test] fn superseded_provider_refresh_is_an_explicit_failure() { let terminal = diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index 965b69d5cb..484a2ea74b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -42,6 +42,7 @@ mod diagnostics; mod grok_accounts; mod locale_cmd; mod provider_detail; +mod provider_refresh; mod provider_settings; mod providers; mod settings; @@ -49,6 +50,7 @@ mod shortcuts; mod surface; mod system; mod usage_items; +mod warning_identity; pub use agent_sessions::*; pub(crate) use bridge::*; @@ -62,6 +64,9 @@ pub use diagnostics::*; pub use grok_accounts::*; pub use locale_cmd::*; pub use provider_detail::*; +pub(crate) use provider_refresh::{ + ProviderRefreshOutcome, ProviderRefreshSkipReason, is_provider_cache_fresh, +}; pub use provider_settings::*; pub use providers::*; pub use settings::*; diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs new file mode 100644 index 0000000000..80675e1eca --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs @@ -0,0 +1,225 @@ +use crate::state::AppState; +use codexbar::core::ProviderId; + +use super::PROVIDER_CACHE_STALE_AFTER; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProviderRefreshOutcome { + Skipped { + reason: ProviderRefreshSkipReason, + }, + Published { + generation: u64, + }, + Superseded { + generation: u64, + current_generation: u64, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProviderRefreshSkipReason { + NoEnabledProviders, + Active { generation: u64 }, + InputSuperseded { expected: u64, current: u64 }, + CacheFresh { generation: u64 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ProviderRefreshReservation { + Reserved { generation: u64 }, + Skipped(ProviderRefreshSkipReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ProviderRefreshCompletion { + Published { error_count: usize }, + Superseded { current_generation: u64 }, +} + +pub(crate) fn is_provider_cache_fresh( + updated_at: Option, + stale_after: std::time::Duration, +) -> bool { + updated_at + .map(|updated| updated.elapsed() <= stale_after) + .unwrap_or(false) +} + +pub(super) fn reserve_provider_refresh( + state: &mut AppState, + force: bool, + provider_ids: &[ProviderId], + expected_generation: u64, +) -> ProviderRefreshReservation { + if state.is_refreshing { + return ProviderRefreshReservation::Skipped(ProviderRefreshSkipReason::Active { + generation: state.provider_refresh_generation, + }); + } + if state.provider_refresh_generation != expected_generation { + return ProviderRefreshReservation::Skipped(ProviderRefreshSkipReason::InputSuperseded { + expected: expected_generation, + current: state.provider_refresh_generation, + }); + } + if provider_cache_can_skip_refresh(state, force, provider_ids) { + return ProviderRefreshReservation::Skipped(ProviderRefreshSkipReason::CacheFresh { + generation: state.provider_refresh_generation, + }); + } + + state.provider_refresh_generation = state.provider_refresh_generation.wrapping_add(1); + let generation = state.provider_refresh_generation; + state.is_refreshing = true; + state.provider_refresh_started_at = Some(std::time::Instant::now()); + ProviderRefreshReservation::Reserved { generation } +} + +fn provider_cache_can_skip_refresh( + state: &AppState, + force: bool, + provider_ids: &[ProviderId], +) -> bool { + let cache_has_all = provider_ids.iter().all(|id| { + state + .provider_cache + .iter() + .any(|snapshot| snapshot.provider_id == id.cli_name()) + }); + if !force && crate::proof_harness::seed_usage_json_active() && cache_has_all { + return true; + } + !force + && cache_has_all + && provider_ids.iter().all(|id| { + is_provider_cache_fresh( + state.provider_cache_updated_at_by_provider.get(id).copied(), + PROVIDER_CACHE_STALE_AFTER, + ) + }) +} + +pub(super) fn complete_provider_refresh( + state: &mut AppState, + generation: u64, +) -> ProviderRefreshCompletion { + if state.provider_refresh_generation != generation { + return ProviderRefreshCompletion::Superseded { + current_generation: state.provider_refresh_generation, + }; + } + state.is_refreshing = false; + state.provider_refresh_started_at = None; + state.provider_cache_updated_at = Some(std::time::Instant::now()); + ProviderRefreshCompletion::Published { + error_count: state + .provider_cache + .iter() + .filter(|snapshot| snapshot.error.is_some()) + .count(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_inputs_cannot_reserve_a_new_generation_after_invalidation() { + let mut state = AppState::new(); + let expected_generation = state.provider_refresh_generation; + state.provider_refresh_generation = state.provider_refresh_generation.wrapping_add(1); + + assert_eq!( + reserve_provider_refresh(&mut state, true, &[ProviderId::Codex], expected_generation,), + ProviderRefreshReservation::Skipped(ProviderRefreshSkipReason::InputSuperseded { + expected: expected_generation, + current: state.provider_refresh_generation, + }) + ); + assert!(!state.is_refreshing); + } + + #[test] + fn matching_generation_reserves_the_next_generation() { + let mut state = AppState::new(); + let expected_generation = state.provider_refresh_generation; + + let ProviderRefreshReservation::Reserved { generation } = + reserve_provider_refresh(&mut state, true, &[ProviderId::Codex], expected_generation) + else { + panic!("refresh should be reserved"); + }; + + assert_eq!(generation, expected_generation.wrapping_add(1)); + assert!(state.is_refreshing); + } + + #[test] + fn superseded_generation_cannot_publish_or_release_its_successor() { + let mut state = AppState::new(); + let initial_generation = state.provider_refresh_generation; + let ProviderRefreshReservation::Reserved { + generation: first_generation, + } = reserve_provider_refresh(&mut state, true, &[ProviderId::Claude], initial_generation) + else { + panic!("first refresh should be reserved"); + }; + + state.provider_refresh_generation = state.provider_refresh_generation.wrapping_add(1); + state.is_refreshing = false; + let successor_input_generation = state.provider_refresh_generation; + let ProviderRefreshReservation::Reserved { + generation: successor_generation, + } = reserve_provider_refresh( + &mut state, + true, + &[ProviderId::Claude], + successor_input_generation, + ) + else { + panic!("successor refresh should be reserved"); + }; + + assert_eq!( + complete_provider_refresh(&mut state, first_generation), + ProviderRefreshCompletion::Superseded { + current_generation: successor_generation + } + ); + assert!(state.is_refreshing); + + assert!(matches!( + complete_provider_refresh(&mut state, successor_generation), + ProviderRefreshCompletion::Published { .. } + )); + assert!(!state.is_refreshing); + } + + #[test] + fn public_outcome_preserves_generation_ownership_and_skip_reason() { + assert_eq!( + ProviderRefreshOutcome::Published { generation: 42 }, + ProviderRefreshOutcome::Published { generation: 42 } + ); + assert_eq!( + ProviderRefreshOutcome::Superseded { + generation: 41, + current_generation: 42, + }, + ProviderRefreshOutcome::Superseded { + generation: 41, + current_generation: 42, + } + ); + assert_eq!( + ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::Active { generation: 42 }, + }, + ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::Active { generation: 42 }, + } + ); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 43cd7cb44d..cbdd389e47 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1,3 +1,8 @@ +use super::provider_refresh::{ + ProviderRefreshCompletion, ProviderRefreshReservation, complete_provider_refresh, + reserve_provider_refresh, +}; +use super::warning_identity::WarningIdentity; use super::*; use chrono::{Local, Utc}; use codexbar::core::HookUsageWindow; @@ -6,165 +11,12 @@ use std::sync::Arc; const MAX_CONCURRENT_PROVIDER_FETCHES: usize = 8; -#[derive(Debug, Clone, PartialEq, Eq)] -enum WarningSourceLane { - ClaudeOauth, - ClaudeCli, - Named(String), -} - -impl WarningSourceLane { - fn from_label(provider: ProviderId, source_label: &str) -> Option { - let source = source_label.trim().to_ascii_lowercase(); - if source.is_empty() { - return None; - } - if provider == ProviderId::Claude { - if source == "oauth" { - return Some(Self::ClaudeOauth); - } - if source == "cli" || source.starts_with("cli ") { - return Some(Self::ClaudeCli); - } - } - Some(Self::Named(source)) - } - - fn key(&self) -> &str { - match self { - Self::ClaudeOauth => "oauth", - Self::ClaudeCli => "cli", - Self::Named(source) => source, - } - } - - fn supports_unresolved_account(&self) -> bool { - matches!(self, Self::ClaudeOauth | Self::ClaudeCli) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum WarningAccountState { - Token(uuid::Uuid), - Email(String), - Organization(String), - Unresolved, - Missing, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct WarningIdentity { - provider: ProviderId, - source_lane: Option, - account: WarningAccountState, -} - -impl WarningIdentity { - fn new( - provider: ProviderId, - source_label: &str, - account_email: Option<&str>, - account_organization: Option<&str>, - token_account_id: Option, - ) -> Self { - let source_lane = WarningSourceLane::from_label(provider, source_label); - let normalized = |value: Option<&str>| { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_ascii_lowercase) - }; - let account = if let Some(id) = token_account_id { - WarningAccountState::Token(id) - } else if let Some(email) = normalized(account_email) { - WarningAccountState::Email(email) - } else if let Some(organization) = normalized(account_organization) { - WarningAccountState::Organization(organization) - } else if provider == ProviderId::Claude - && source_lane - .as_ref() - .is_some_and(WarningSourceLane::supports_unresolved_account) - { - WarningAccountState::Unresolved - } else { - WarningAccountState::Missing - }; - Self { - provider, - source_lane, - account, - } - } - - fn unresolved_key(&self) -> Option { - let lane = self.source_lane.as_ref()?; - (self.provider == ProviderId::Claude && lane.supports_unresolved_account()) - .then(|| format!("{}:{}:unknown", self.provider.cli_name(), lane.key())) - } - - fn threshold_key(&self) -> String { - match &self.account { - WarningAccountState::Token(id) => format!("token-account:{}", id.as_hyphenated()), - WarningAccountState::Email(email) => email.clone(), - WarningAccountState::Organization(organization) => format!("org:{organization}"), - WarningAccountState::Unresolved => self.unresolved_key().unwrap_or_default(), - WarningAccountState::Missing => String::new(), - } - } - - fn predictive_key(&self) -> Option { - match &self.account { - WarningAccountState::Token(id) => Some(format!("token-account:{}", id.as_hyphenated())), - WarningAccountState::Email(email) => self - .source_lane - .as_ref() - .map(|lane| format!("{}:{email}", lane.key())), - WarningAccountState::Unresolved => self.unresolved_key(), - WarningAccountState::Organization(_) | WarningAccountState::Missing => None, - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RefreshScope { AllEnabled, AutoResume, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ProviderRefreshOutcome { - Skipped { - reason: ProviderRefreshSkipReason, - }, - Published { - generation: u64, - }, - Superseded { - generation: u64, - current_generation: u64, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ProviderRefreshSkipReason { - NoEnabledProviders, - Active { generation: u64 }, - InputSuperseded { expected: u64, current: u64 }, - CacheFresh { generation: u64 }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProviderRefreshReservation { - Reserved { generation: u64 }, - Skipped(ProviderRefreshSkipReason), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProviderRefreshCompletion { - Published { error_count: usize }, - Superseded { current_generation: u64 }, -} - impl RefreshScope { fn provider_ids(self, settings: &Settings, enabled_ids: &[ProviderId]) -> Vec { match self { @@ -438,15 +290,6 @@ pub(crate) fn provider_fetch_timeout(id: ProviderId, ctx: &FetchContext) -> std: provider_timeout.max(context_timeout.min(MAX_CONTEXT_FETCH_TIMEOUT)) } -pub(crate) fn is_provider_cache_fresh( - updated_at: Option, - stale_after: std::time::Duration, -) -> bool { - updated_at - .map(|updated| updated.elapsed() <= stale_after) - .unwrap_or(false) -} - pub(crate) fn upsert_provider_cache( cache: &mut Vec, snapshot: ProviderUsageSnapshot, @@ -612,69 +455,12 @@ fn begin_provider_refresh( expected_generation: u64, ) -> Result { let mut guard = state.lock().map_err(|e| e.to_string())?; - reserve_provider_refresh(&mut guard, force, provider_ids, expected_generation) -} - -fn reserve_provider_refresh( - guard: &mut AppState, - force: bool, - provider_ids: &[ProviderId], - expected_generation: u64, -) -> Result { - if guard.is_refreshing { - return Ok(ProviderRefreshReservation::Skipped( - ProviderRefreshSkipReason::Active { - generation: guard.provider_refresh_generation, - }, - )); - } - if guard.provider_refresh_generation != expected_generation { - return Ok(ProviderRefreshReservation::Skipped( - ProviderRefreshSkipReason::InputSuperseded { - expected: expected_generation, - current: guard.provider_refresh_generation, - }, - )); - } - if provider_cache_can_skip_refresh(guard, force, provider_ids) { - return Ok(ProviderRefreshReservation::Skipped( - ProviderRefreshSkipReason::CacheFresh { - generation: guard.provider_refresh_generation, - }, - )); - } - - guard.provider_refresh_generation = guard.provider_refresh_generation.wrapping_add(1); - let generation = guard.provider_refresh_generation; - guard.is_refreshing = true; - guard.provider_refresh_started_at = Some(std::time::Instant::now()); - Ok(ProviderRefreshReservation::Reserved { generation }) -} - -fn provider_cache_can_skip_refresh( - guard: &AppState, - force: bool, - provider_ids: &[ProviderId], -) -> bool { - let cache_has_all = provider_ids.iter().all(|id| { - guard - .provider_cache - .iter() - .any(|snapshot| snapshot.provider_id == id.cli_name()) - }); - // Proof-harness seed: pin the synthetic snapshot for the whole run so a - // periodic auto-refresh cannot overwrite seeded capture conditions. - if !force && crate::proof_harness::seed_usage_json_active() && cache_has_all { - return true; - } - !force - && cache_has_all - && provider_ids.iter().all(|id| { - is_provider_cache_fresh( - guard.provider_cache_updated_at_by_provider.get(id).copied(), - PROVIDER_CACHE_STALE_AFTER, - ) - }) + Ok(reserve_provider_refresh( + &mut guard, + force, + provider_ids, + expected_generation, + )) } struct ProviderRefreshInputs { @@ -1151,26 +937,6 @@ fn finish_provider_refresh( Ok(complete_provider_refresh(&mut guard, generation)) } -fn complete_provider_refresh(guard: &mut AppState, generation: u64) -> ProviderRefreshCompletion { - if !is_current_provider_refresh_generation(guard, generation) { - // A newer begin or invalidate owns the lock/generation. Do not clear - // is_refreshing — that would race a live successor batch. - return ProviderRefreshCompletion::Superseded { - current_generation: guard.provider_refresh_generation, - }; - } - guard.is_refreshing = false; - guard.provider_refresh_started_at = None; - guard.provider_cache_updated_at = Some(std::time::Instant::now()); - ProviderRefreshCompletion::Published { - error_count: guard - .provider_cache - .iter() - .filter(|s| s.error.is_some()) - .count(), - } -} - fn update_tray_and_notifications( app: &tauri::AppHandle, state: &tauri::State<'_, Mutex>, @@ -1467,75 +1233,6 @@ pub fn get_cached_providers( mod predictive_warning_tests { use super::*; - #[test] - fn predictive_warning_identity_keeps_claude_sources_and_token_accounts_separate() { - let account_id = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(); - - assert_eq!( - WarningIdentity::new( - ProviderId::Claude, - "cli", - Some("Person@Example.com"), - None, - None, - ) - .predictive_key() - .as_deref(), - Some("cli:person@example.com") - ); - assert_eq!( - WarningIdentity::new( - ProviderId::Claude, - "oauth", - Some("Person@Example.com"), - None, - None, - ) - .predictive_key() - .as_deref(), - Some("oauth:person@example.com") - ); - assert_eq!( - WarningIdentity::new( - ProviderId::Claude, - "oauth", - Some("Person@Example.com"), - None, - Some(account_id), - ) - .predictive_key() - .as_deref(), - Some("token-account:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - ); - } - - #[test] - fn predictive_warning_identity_scopes_unresolved_claude_sources() { - assert_eq!( - WarningIdentity::new(ProviderId::Claude, "oauth", None, None, None).predictive_key(), - Some("claude:oauth:unknown".to_string()) - ); - assert_eq!( - WarningIdentity::new(ProviderId::Codex, "cli", Some(" "), None, None).predictive_key(), - None - ); - assert_eq!( - WarningIdentity::new( - ProviderId::Claude, - "cli (reduced fidelity)", - None, - None, - None, - ) - .predictive_key(), - Some("claude:cli:unknown".to_string()) - ); - assert_eq!( - WarningIdentity::new(ProviderId::Claude, "web", None, None, None).predictive_key(), - None - ); - } - fn empty_snapshot() -> ProviderUsageSnapshot { let metadata = codexbar::core::instantiate_provider(ProviderId::Claude) .metadata() @@ -1586,40 +1283,6 @@ mod predictive_warning_tests { ); } - #[test] - fn claude_unresolved_warning_lanes_adopt_only_the_matching_source() { - let oauth = WarningIdentity::new(ProviderId::Claude, "oauth", None, None, None); - let cli = WarningIdentity::new( - ProviderId::Claude, - "cli (reduced fidelity)", - None, - None, - None, - ); - let resolved_cli = WarningIdentity::new( - ProviderId::Claude, - "cli", - Some("person@example.com"), - None, - None, - ); - - assert_eq!( - oauth.unresolved_key().as_deref(), - Some("claude:oauth:unknown") - ); - assert_eq!(cli.unresolved_key().as_deref(), Some("claude:cli:unknown")); - assert_eq!( - resolved_cli.unresolved_key().as_deref(), - Some("claude:cli:unknown") - ); - assert_ne!(oauth.unresolved_key(), resolved_cli.unresolved_key()); - assert_eq!( - resolved_cli.predictive_key().as_deref(), - Some("cli:person@example.com") - ); - } - /// The forecast scope key and the notification identity must never disagree. /// If they did, one account would be seen as two identities and its burn history /// would be split, silently halving the sample count behind every forecast. @@ -1778,116 +1441,3 @@ mod reset_backfill_tests { assert!(fresh.primary.resets_at.is_none()); } } - -#[cfg(test)] -mod refresh_generation_tests { - use super::*; - - #[test] - fn stale_inputs_cannot_reserve_a_new_generation_after_invalidation() { - let mut state = AppState::new(); - let expected_generation = state.provider_refresh_generation; - - invalidate_account_usage(&mut state, ProviderId::Codex); - - assert_eq!( - reserve_provider_refresh(&mut state, true, &[ProviderId::Codex], expected_generation,) - .expect("reservation should not fail"), - ProviderRefreshReservation::Skipped(ProviderRefreshSkipReason::InputSuperseded { - expected: expected_generation, - current: state.provider_refresh_generation, - }) - ); - assert!(!state.is_refreshing); - } - - #[test] - fn a_matching_generation_reserves_the_next_refresh_generation() { - let mut state = AppState::new(); - let expected_generation = state.provider_refresh_generation; - - let ProviderRefreshReservation::Reserved { generation } = - reserve_provider_refresh(&mut state, true, &[ProviderId::Codex], expected_generation) - .expect("reservation should succeed") - else { - panic!("refresh should be reserved"); - }; - - assert_eq!(generation, expected_generation.wrapping_add(1)); - assert!(state.is_refreshing); - } - - #[test] - fn superseded_generation_cannot_publish_or_release_its_successor() { - let mut state = AppState::new(); - let initial_generation = state.provider_refresh_generation; - let ProviderRefreshReservation::Reserved { - generation: first_generation, - } = reserve_provider_refresh(&mut state, true, &[ProviderId::Claude], initial_generation) - .expect("first reservation should succeed") - else { - panic!("first refresh should be reserved"); - }; - - invalidate_account_usage(&mut state, ProviderId::Claude); - let successor_input_generation = state.provider_refresh_generation; - let ProviderRefreshReservation::Reserved { - generation: successor_generation, - } = reserve_provider_refresh( - &mut state, - true, - &[ProviderId::Claude], - successor_input_generation, - ) - .expect("successor reservation should succeed") - else { - panic!("successor refresh should be reserved"); - }; - - assert_eq!( - complete_provider_refresh(&mut state, first_generation), - ProviderRefreshCompletion::Superseded { - current_generation: successor_generation - } - ); - assert!(state.is_refreshing); - assert_eq!(state.provider_refresh_generation, successor_generation); - - assert!(matches!( - complete_provider_refresh(&mut state, successor_generation), - ProviderRefreshCompletion::Published { .. } - )); - assert!(!state.is_refreshing); - assert_eq!(state.provider_refresh_generation, successor_generation); - } - - #[test] - fn refresh_outcome_preserves_generation_ownership_and_skip_reason() { - let published = ProviderRefreshOutcome::Published { generation: 42 }; - let superseded = ProviderRefreshOutcome::Superseded { - generation: 41, - current_generation: 42, - }; - let active = ProviderRefreshOutcome::Skipped { - reason: ProviderRefreshSkipReason::Active { generation: 42 }, - }; - - assert_eq!( - published, - ProviderRefreshOutcome::Published { generation: 42 } - ); - assert_eq!( - superseded, - ProviderRefreshOutcome::Superseded { - generation: 41, - current_generation: 42 - } - ); - assert_eq!( - active, - ProviderRefreshOutcome::Skipped { - reason: ProviderRefreshSkipReason::Active { generation: 42 } - } - ); - } -} diff --git a/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs b/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs new file mode 100644 index 0000000000..fb16e64976 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs @@ -0,0 +1,228 @@ +use codexbar::core::ProviderId; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum WarningSourceLane { + ClaudeOauth, + ClaudeCli, + Named(String), +} + +impl WarningSourceLane { + fn from_label(provider: ProviderId, source_label: &str) -> Option { + let source = source_label.trim().to_ascii_lowercase(); + if source.is_empty() { + return None; + } + if provider == ProviderId::Claude { + if source == "oauth" { + return Some(Self::ClaudeOauth); + } + if source == "cli" || source.starts_with("cli ") { + return Some(Self::ClaudeCli); + } + } + Some(Self::Named(source)) + } + + fn key(&self) -> &str { + match self { + Self::ClaudeOauth => "oauth", + Self::ClaudeCli => "cli", + Self::Named(source) => source, + } + } + + fn supports_unresolved_account(&self) -> bool { + matches!(self, Self::ClaudeOauth | Self::ClaudeCli) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum WarningAccountState { + Token(uuid::Uuid), + Email(String), + Organization(String), + Unresolved, + Missing, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct WarningIdentity { + provider: ProviderId, + source_lane: Option, + account: WarningAccountState, +} + +impl WarningIdentity { + pub(super) fn new( + provider: ProviderId, + source_label: &str, + account_email: Option<&str>, + account_organization: Option<&str>, + token_account_id: Option, + ) -> Self { + let source_lane = WarningSourceLane::from_label(provider, source_label); + let normalized = |value: Option<&str>| { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase) + }; + let account = if let Some(id) = token_account_id { + WarningAccountState::Token(id) + } else if let Some(email) = normalized(account_email) { + WarningAccountState::Email(email) + } else if let Some(organization) = normalized(account_organization) { + WarningAccountState::Organization(organization) + } else if provider == ProviderId::Claude + && source_lane + .as_ref() + .is_some_and(WarningSourceLane::supports_unresolved_account) + { + WarningAccountState::Unresolved + } else { + WarningAccountState::Missing + }; + Self { + provider, + source_lane, + account, + } + } + + pub(super) fn unresolved_key(&self) -> Option { + let lane = self.source_lane.as_ref()?; + (self.provider == ProviderId::Claude && lane.supports_unresolved_account()) + .then(|| format!("{}:{}:unknown", self.provider.cli_name(), lane.key())) + } + + pub(super) fn threshold_key(&self) -> String { + match &self.account { + WarningAccountState::Token(id) => format!("token-account:{}", id.as_hyphenated()), + WarningAccountState::Email(email) => email.clone(), + WarningAccountState::Organization(organization) => format!("org:{organization}"), + WarningAccountState::Unresolved => self.unresolved_key().unwrap_or_default(), + WarningAccountState::Missing => String::new(), + } + } + + pub(super) fn predictive_key(&self) -> Option { + match &self.account { + WarningAccountState::Token(id) => Some(format!("token-account:{}", id.as_hyphenated())), + WarningAccountState::Email(email) => self + .source_lane + .as_ref() + .map(|lane| format!("{}:{email}", lane.key())), + WarningAccountState::Unresolved => self.unresolved_key(), + WarningAccountState::Organization(_) | WarningAccountState::Missing => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_lanes_and_token_accounts_remain_separate() { + let account_id = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(); + + assert_eq!( + WarningIdentity::new( + ProviderId::Claude, + "cli", + Some("Person@Example.com"), + None, + None, + ) + .predictive_key() + .as_deref(), + Some("cli:person@example.com") + ); + assert_eq!( + WarningIdentity::new( + ProviderId::Claude, + "oauth", + Some("Person@Example.com"), + None, + None, + ) + .predictive_key() + .as_deref(), + Some("oauth:person@example.com") + ); + assert_eq!( + WarningIdentity::new( + ProviderId::Claude, + "oauth", + Some("Person@Example.com"), + None, + Some(account_id), + ) + .predictive_key() + .as_deref(), + Some("token-account:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + ); + } + + #[test] + fn unresolved_claude_sources_are_scoped_to_their_lane() { + assert_eq!( + WarningIdentity::new(ProviderId::Claude, "oauth", None, None, None).predictive_key(), + Some("claude:oauth:unknown".to_string()) + ); + assert_eq!( + WarningIdentity::new( + ProviderId::Claude, + "cli (reduced fidelity)", + None, + None, + None, + ) + .predictive_key(), + Some("claude:cli:unknown".to_string()) + ); + assert_eq!( + WarningIdentity::new(ProviderId::Claude, "web", None, None, None).predictive_key(), + None + ); + assert_eq!( + WarningIdentity::new(ProviderId::Codex, "cli", Some(" "), None, None).predictive_key(), + None + ); + } + + #[test] + fn resolved_identity_adopts_only_its_matching_unresolved_lane() { + let oauth = WarningIdentity::new(ProviderId::Claude, "oauth", None, None, None); + let cli = WarningIdentity::new( + ProviderId::Claude, + "cli (reduced fidelity)", + None, + None, + None, + ); + let resolved_cli = WarningIdentity::new( + ProviderId::Claude, + "cli", + Some("person@example.com"), + None, + None, + ); + + assert_eq!( + oauth.unresolved_key().as_deref(), + Some("claude:oauth:unknown") + ); + assert_eq!(cli.unresolved_key().as_deref(), Some("claude:cli:unknown")); + assert_eq!( + resolved_cli.unresolved_key().as_deref(), + Some("claude:cli:unknown") + ); + assert_ne!(oauth.unresolved_key(), resolved_cli.unresolved_key()); + assert_eq!( + resolved_cli.predictive_key().as_deref(), + Some("cli:person@example.com") + ); + } +} From 839e5222b47f31c17a72271f7d0949b49b0218b8 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:47:22 +0700 Subject: [PATCH 06/12] Fix provider refresh export --- apps/desktop-tauri/src-tauri/src/commands/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index 484a2ea74b..529952ba64 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -64,9 +64,7 @@ pub use diagnostics::*; pub use grok_accounts::*; pub use locale_cmd::*; pub use provider_detail::*; -pub(crate) use provider_refresh::{ - ProviderRefreshOutcome, ProviderRefreshSkipReason, is_provider_cache_fresh, -}; +pub(crate) use provider_refresh::{ProviderRefreshOutcome, ProviderRefreshSkipReason}; pub use provider_settings::*; pub use providers::*; pub use settings::*; From 7b6cb50293964f9b32b5a4d17c370804585098aa Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:58:21 +0700 Subject: [PATCH 07/12] Expose refresh helper to tests --- apps/desktop-tauri/src-tauri/src/commands/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index 529952ba64..4a2174137d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -64,6 +64,8 @@ pub use diagnostics::*; pub use grok_accounts::*; pub use locale_cmd::*; pub use provider_detail::*; +#[cfg(test)] +pub(crate) use provider_refresh::is_provider_cache_fresh; pub(crate) use provider_refresh::{ProviderRefreshOutcome, ProviderRefreshSkipReason}; pub use provider_settings::*; pub use providers::*; From bfea04da137124f35e5ece8c3eb0970a5d9558d9 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:31:17 +0700 Subject: [PATCH 08/12] Redesign Claude reconciliation state --- .../src-tauri/src/commands/claude_accounts.rs | 114 +++-- .../src/commands/claude_reconciliation.rs | 450 ++++-------------- .../src-tauri/src/commands/mod.rs | 2 +- .../src-tauri/src/commands/providers.rs | 14 - .../src/commands/warning_identity.rs | 10 +- apps/desktop-tauri/src-tauri/src/main.rs | 1 + apps/desktop-tauri/src-tauri/src/state.rs | 4 + .../components/ClaudeAccountsMenu.test.tsx | 56 ++- .../src/components/ClaudeAccountsMenu.tsx | 52 +- .../src/hooks/useClaudeReconciliation.test.ts | 66 +++ .../src/hooks/useClaudeReconciliation.ts | 48 ++ apps/desktop-tauri/src/lib/tauri.ts | 10 +- .../ClaudeAccountsSection.test.tsx | 7 +- .../credentials/ClaudeAccountsSection.tsx | 39 +- .../ClaudeSwapAccountsSection.test.tsx | 10 +- .../credentials/ClaudeSwapAccountsSection.tsx | 46 +- apps/desktop-tauri/src/types/bridge.ts | 7 + rust/src/notifications.rs | 98 +--- 18 files changed, 448 insertions(+), 586 deletions(-) create mode 100644 apps/desktop-tauri/src/hooks/useClaudeReconciliation.test.ts create mode 100644 apps/desktop-tauri/src/hooks/useClaudeReconciliation.ts diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs index e3bb6e9bad..317cdc9f5e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -101,47 +101,66 @@ fn account_row_for_slot( .ok_or_else(|| "claude-swap did not report that account slot.".to_string()) } -/// Emitted when a Claude account change needs a provider refresh before the -/// settle. Event order is load-bearing for every listener: -/// -/// 1. `claude-accounts-reconciling` — listeners show the reconciling phase. -/// 2. the bounded provider refresh runs with a typed ownership outcome. -/// 3. `claude-accounts-reconciled` — the success/failure terminal marker; -/// settling must be event-driven, never inferred from a switch promise. -/// 4. `claude-accounts-updated` + tray rebuild — the settled reload. -/// -/// The Claude reconciliation coordinator serializes begin/terminal emission. -/// Detached stale workers become superseded terminals internally and cannot -/// settle a newer operation. Every current outcome clears the reconciling UI. -async fn refresh_after_claude_change(app: tauri::AppHandle) -> Result<(), String> { - let pending = { +fn grace_outcome( + completed: Option, + pending: &claude_reconciliation::ClaudeReconciliationSnapshot, +) -> claude_reconciliation::ClaudeReconciliationSnapshot { + completed.unwrap_or_else(|| pending.clone()) +} + +/// Start a generation-owned provider refresh and return its authoritative +/// snapshot. A slow refresh returns `pending`; its detached worker later +/// updates application state and emits one best-effort state-change event. +async fn refresh_after_claude_change( + app: tauri::AppHandle, +) -> Result { + let (usage_pending, reconciliation, reconciliation_pending) = { let state = app.state::>(); let mut state = state.lock().map_err(|e| e.to_string())?; - invalidate_account_usage(&mut state, ProviderId::Claude) + let usage_pending = invalidate_account_usage(&mut state, ProviderId::Claude); + let (token, snapshot) = state.claude_reconciliation.begin(); + (usage_pending, token, snapshot) }; - crate::events::emit_provider_updated(&app, &pending); - let reconciliation = claude_reconciliation::begin(&app); + crate::events::emit_provider_updated(&app, &usage_pending); + claude_reconciliation::emit(&app, &reconciliation_pending); let refresh_app = app.clone(); let mut ambient = tauri::async_runtime::spawn(async move { - let terminal = claude_reconciliation::ClaudeReconciliationResult::from_refresh( + let result = claude_reconciliation::ClaudeReconciliationResult::from_refresh( super::do_refresh_providers_with_outcome(&refresh_app).await, ); - let command_result = terminal.command_result(); - if claude_reconciliation::complete(&refresh_app, reconciliation, terminal) { + let (is_current, snapshot) = { + let state = refresh_app.state::>(); + let mut state = state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.claude_reconciliation.complete(reconciliation, result) + }; + if is_current { + claude_reconciliation::emit(&refresh_app, &snapshot); changed(&refresh_app); + } else { + tracing::debug!( + generation = snapshot.generation, + detail = %snapshot.detail, + "Claude reconciliation completed after a newer generation" + ); } - command_result + snapshot }); match tokio::time::timeout(AMBIENT_RECONCILIATION_GRACE, &mut ambient).await { - Ok(joined) => joined.map_err(|error| error.to_string())?, - Err(_) => { - // Dropping only the JoinHandle waiter detaches the owning refresh. - // The task emits the terminal event only after its refresh settles. - Ok(()) - } + Ok(joined) => joined.map_err(|error| error.to_string()), + Err(_) => Ok(grace_outcome(None, &reconciliation_pending)), } } +#[tauri::command] +pub fn claude_reconciliation_state( + state: tauri::State<'_, Mutex>, +) -> Result, String> { + let state = state.lock().map_err(|error| error.to_string())?; + Ok(state.claude_reconciliation.snapshot()) +} + #[derive(Debug, Clone, Copy)] enum ClaudeSwapAccountOperation { Switch, @@ -246,18 +265,16 @@ fn run_claude_swap_operation( async fn finish_claude_swap_mutation( app: tauri::AppHandle, outcome: ClaudeSwapMutationOutcome, -) -> Result<(), String> { +) -> Result { if !outcome.applied { - return outcome.error.map_or(Ok(()), Err); + return Err(outcome + .error + .unwrap_or_else(|| "Claude account operation was not applied.".to_string())); } - let refresh_error = refresh_after_claude_change(app).await.err(); - match (outcome.error, refresh_error) { - (None, None) => Ok(()), - (Some(operation_error), None) => Err(operation_error), - (None, Some(refresh_error)) => Err(refresh_error), - (Some(operation_error), Some(refresh_error)) => Err(format!( - "{operation_error} Refresh also failed: {refresh_error}" - )), + let reconciliation = refresh_after_claude_change(app).await?; + match outcome.error { + Some(operation_error) => Err(operation_error), + None => Ok(reconciliation), } } @@ -269,7 +286,10 @@ pub async fn claude_swap_accounts_list() -> Result Result<(), String> { +pub async fn claude_swap_account_switch( + app: tauri::AppHandle, + slot: u32, +) -> Result { // MUTATION is held for the whole command body, including // `finish_claude_swap_mutation`'s awaited provider refresh below. This is // deliberate: it serializes account mutations across the full @@ -297,7 +317,7 @@ pub async fn claude_swap_account_switch(app: tauri::AppHandle, slot: u32) -> Res pub async fn claude_swap_account_reauthenticate( app: tauri::AppHandle, slot: u32, -) -> Result<(), String> { +) -> Result { let _mutation = MUTATION .try_lock() .map_err(|_| "A Claude account operation is already in progress.")?; @@ -370,7 +390,10 @@ pub async fn claude_account_remove(app: tauri::AppHandle, id: String) -> Result< } #[tauri::command] -pub async fn claude_account_switch(app: tauri::AppHandle, id: String) -> Result<(), String> { +pub async fn claude_account_switch( + app: tauri::AppHandle, + id: String, +) -> Result { let _mutation = MUTATION .try_lock() .map_err(|_| "A Claude account operation is already in progress.")?; @@ -447,6 +470,17 @@ mod tests { assert_eq!(ClaudeSwapMutationOutcome::confirmed().error, None); } + #[test] + fn grace_timeout_returns_the_explicit_pending_snapshot() { + let pending = claude_reconciliation::ClaudeReconciliationSnapshot { + generation: 7, + status: claude_reconciliation::ClaudeReconciliationStatus::Pending, + provider_refresh_generation: None, + detail: "refreshing".to_string(), + }; + assert_eq!(grace_outcome(None, &pending), pending); + } + #[test] fn switching_invalidates_old_identity_usage_and_inflight_results() { let mut state = AppState::new(); diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs index 9622cae816..959eda035c 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs @@ -1,64 +1,32 @@ use super::{ProviderRefreshOutcome, ProviderRefreshSkipReason}; use serde::Serialize; -use std::collections::VecDeque; -use std::sync::{LazyLock, Mutex, MutexGuard}; -use std::time::Duration; use tauri::Emitter; -static COORDINATOR: LazyLock> = - LazyLock::new(|| Mutex::new(ClaudeReconciliationCoordinator::default())); -const REPLAY_DELAY_MIN: Duration = Duration::from_millis(250); -const REPLAY_DELAY_MAX: Duration = Duration::from_secs(5); - #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct ClaudeReconciliationToken(u64); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct ClaudeReconciliationResult { - status: ClaudeReconciliationStatus, - provider_refresh_generation: Option, - detail: String, -} +pub(crate) struct ClaudeReconciliationToken(u64); -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] -struct ClaudeReconciliationTerminal { - generation: u64, - status: ClaudeReconciliationStatus, - provider_refresh_generation: Option, - detail: String, +pub enum ClaudeReconciliationStatus { + Pending, + Succeeded, + Failed, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] -struct ClaudeReconciliationStarted { - generation: u64, +pub struct ClaudeReconciliationSnapshot { + pub generation: u64, + pub status: ClaudeReconciliationStatus, + pub provider_refresh_generation: Option, + pub detail: String, } #[derive(Debug, Clone, PartialEq, Eq)] -enum PendingEvent { - Reconciling(ClaudeReconciliationStarted), - Reconciled(ClaudeReconciliationTerminal), -} - -impl PendingEvent { - fn publish(&self, app: &tauri::AppHandle) -> Result<(), String> { - match self { - Self::Reconciling(payload) => app - .emit("claude-accounts-reconciling", payload) - .map_err(|error| error.to_string()), - Self::Reconciled(payload) => app - .emit("claude-accounts-reconciled", payload) - .map_err(|error| error.to_string()), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -enum ClaudeReconciliationStatus { - Succeeded, - Failed, +pub(super) struct ClaudeReconciliationResult { + status: ClaudeReconciliationStatus, + provider_refresh_generation: Option, + detail: String, } impl ClaudeReconciliationResult { @@ -113,355 +81,129 @@ impl ClaudeReconciliationResult { detail: detail.into(), } } - - pub(super) fn command_result(&self) -> Result<(), String> { - match self.status { - ClaudeReconciliationStatus::Succeeded => Ok(()), - ClaudeReconciliationStatus::Failed => Err(self.detail.clone()), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum CompletionDisposition { - Current(ClaudeReconciliationTerminal), - Superseded(ClaudeReconciliationTerminal), } #[derive(Debug, Default)] -struct ClaudeReconciliationCoordinator { +pub struct ClaudeReconciliationState { next_generation: u64, - active_generation: Option, - pending_events: VecDeque, - publisher_active: bool, - replay_scheduled: bool, + current: Option, } -impl ClaudeReconciliationCoordinator { - fn begin(&mut self) -> ClaudeReconciliationToken { +impl ClaudeReconciliationState { + pub(crate) fn begin(&mut self) -> (ClaudeReconciliationToken, ClaudeReconciliationSnapshot) { self.next_generation = self.next_generation.wrapping_add(1); let token = ClaudeReconciliationToken(self.next_generation); - self.active_generation = Some(token.0); - self.pending_events - .push_back(PendingEvent::Reconciling(ClaudeReconciliationStarted { - generation: token.0, - })); - token + let snapshot = ClaudeReconciliationSnapshot { + generation: token.0, + status: ClaudeReconciliationStatus::Pending, + provider_refresh_generation: None, + detail: "refreshing".to_string(), + }; + self.current = Some(snapshot.clone()); + (token, snapshot) } - fn complete( + pub(crate) fn complete( &mut self, token: ClaudeReconciliationToken, result: ClaudeReconciliationResult, - ) -> CompletionDisposition { - if self.active_generation != Some(token.0) { - let successor = self.active_generation.unwrap_or(self.next_generation); - return CompletionDisposition::Superseded(ClaudeReconciliationTerminal { + ) -> (bool, ClaudeReconciliationSnapshot) { + let is_current = self.current.as_ref().is_some_and(|snapshot| { + snapshot.generation == token.0 && snapshot.status == ClaudeReconciliationStatus::Pending + }); + let snapshot = if is_current { + ClaudeReconciliationSnapshot { + generation: token.0, + status: result.status, + provider_refresh_generation: result.provider_refresh_generation, + detail: result.detail, + } + } else { + let successor = self + .current + .as_ref() + .map(|snapshot| snapshot.generation) + .unwrap_or(self.next_generation); + ClaudeReconciliationSnapshot { generation: token.0, status: ClaudeReconciliationStatus::Failed, provider_refresh_generation: result.provider_refresh_generation, detail: format!("superseded by Claude reconciliation generation {successor}"), - }); - } - self.active_generation = None; - let terminal = ClaudeReconciliationTerminal { - generation: token.0, - status: result.status, - provider_refresh_generation: result.provider_refresh_generation, - detail: result.detail, + } }; - self.pending_events - .push_back(PendingEvent::Reconciled(terminal.clone())); - CompletionDisposition::Current(terminal) - } - - fn claim_pending_event(&mut self) -> Option { - if self.publisher_active { - return None; - } - let event = self.pending_events.front()?.clone(); - self.publisher_active = true; - Some(event) - } - - fn finish_publish(&mut self, event: &PendingEvent, succeeded: bool) { - debug_assert!(self.publisher_active); - self.publisher_active = false; - if succeeded { - let published = self.pending_events.pop_front(); - debug_assert_eq!(published.as_ref(), Some(event)); + if is_current { + self.current = Some(snapshot.clone()); } + (is_current, snapshot) } - fn schedule_replay(&mut self) -> bool { - if self.replay_scheduled { - return false; - } - self.replay_scheduled = true; - true - } - - fn finish_replay_if_idle(&mut self) -> bool { - if self.pending_events.is_empty() && !self.publisher_active { - self.replay_scheduled = false; - true - } else { - false - } + pub(crate) fn snapshot(&self) -> Option { + self.current.clone() } } -fn coordinator() -> MutexGuard<'static, ClaudeReconciliationCoordinator> { - COORDINATOR - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -fn publish_pending_with( - state: &Mutex, - mut publish: F, -) -> Result<(), String> -where - F: FnMut(&PendingEvent) -> Result<(), String>, -{ - loop { - let Some(event) = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .claim_pending_event() - else { - return Ok(()); - }; - - let result = publish(&event); - state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .finish_publish(&event, result.is_ok()); - result?; - } -} - -fn publish_pending(app: &tauri::AppHandle) -> Result<(), String> { - publish_pending_with(&COORDINATOR, |event| event.publish(app)) -} - -fn schedule_replay(app: tauri::AppHandle) { - if !coordinator().schedule_replay() { - return; - } - - tauri::async_runtime::spawn(async move { - let mut delay = REPLAY_DELAY_MIN; - loop { - tokio::time::sleep(delay).await; - match publish_pending(&app) { - Ok(()) => { - if coordinator().finish_replay_if_idle() { - break; - } - delay = REPLAY_DELAY_MIN; - } - Err(error) => { - tracing::debug!( - %error, - "Claude reconciliation event replay remains pending" - ); - delay = delay.saturating_mul(2).min(REPLAY_DELAY_MAX); - } - } - } - }); -} - -fn publish_or_schedule_replay(app: &tauri::AppHandle) { - if let Err(error) = publish_pending(app) { - tracing::warn!(%error, "Claude reconciliation event queued for replay"); - schedule_replay(app.clone()); +pub(super) fn emit(app: &tauri::AppHandle, snapshot: &ClaudeReconciliationSnapshot) { + if let Err(error) = app.emit("claude-reconciliation-changed", snapshot) { + tracing::warn!( + %error, + generation = snapshot.generation, + "Failed to emit Claude reconciliation state" + ); } } -/// Begin a Claude reconciliation and queue its event in generation order. -/// Publication occurs after releasing the coordinator lock. Failed events stay -/// at the front of the queue and are retried before newer generations. -pub(super) fn begin(app: &tauri::AppHandle) -> ClaudeReconciliationToken { - let token = coordinator().begin(); - publish_or_schedule_replay(app); - token -} - -/// Queue a terminal event only when `token` still owns the current Claude -/// reconciliation. The coordinator orders state transitions; the outbox orders -/// external publication without holding the global mutex across `app.emit`. -pub(super) fn complete( - app: &tauri::AppHandle, - token: ClaudeReconciliationToken, - result: ClaudeReconciliationResult, -) -> bool { - let disposition = coordinator().complete(token, result); - let is_current = match disposition { - CompletionDisposition::Current(_) => true, - CompletionDisposition::Superseded(terminal) => { - tracing::debug!( - generation = terminal.generation, - detail = %terminal.detail, - "Claude reconciliation completed after it was superseded" - ); - false - } - }; - publish_or_schedule_replay(app); - is_current -} - #[cfg(test)] mod tests { use super::*; #[test] - fn overlapping_reconciliations_only_allow_the_latest_terminal() { - let mut coordinator = ClaudeReconciliationCoordinator::default(); - let first = coordinator.begin(); - let second = coordinator.begin(); - - assert!(matches!( - coordinator.complete(first, ClaudeReconciliationResult::succeeded(None, "first")), - CompletionDisposition::Superseded(ClaudeReconciliationTerminal { - generation, - status: ClaudeReconciliationStatus::Failed, - .. - }) if generation == first.0 - )); - assert!(matches!( - coordinator.complete(second, ClaudeReconciliationResult::succeeded(None, "second")), - CompletionDisposition::Current(ClaudeReconciliationTerminal { - generation, - status: ClaudeReconciliationStatus::Succeeded, - .. - }) if generation == second.0 - )); - } - - #[test] - fn failed_terminal_publish_is_retained_and_replayed_before_a_new_generation() { - let state = Mutex::new(ClaudeReconciliationCoordinator::default()); - let first = state.lock().unwrap().begin(); - publish_pending_with(&state, |_| Ok(())).expect("begin event should publish"); - state.lock().unwrap().complete( - first, - ClaudeReconciliationResult::succeeded(Some(7), "published"), + fn overlapping_reconciliations_keep_the_latest_generation_authoritative() { + let mut state = ClaudeReconciliationState::default(); + let (first, _) = state.begin(); + let (second, second_pending) = state.begin(); + + let (accepted, stale) = + state.complete(first, ClaudeReconciliationResult::succeeded(None, "first")); + assert!(!accepted); + assert_eq!(stale.generation, first.0); + assert_eq!(stale.status, ClaudeReconciliationStatus::Failed); + assert_eq!(state.snapshot(), Some(second_pending)); + + let (accepted, terminal) = state.complete( + second, + ClaudeReconciliationResult::succeeded(Some(8), "second"), ); - - assert_eq!( - publish_pending_with(&state, |_| Err("event transport unavailable".into())), - Err("event transport unavailable".into()) - ); - { - let coordinator = state.lock().unwrap(); - assert_eq!(coordinator.pending_events.len(), 1); - assert!(!coordinator.publisher_active); - } - - let second = state.lock().unwrap().begin(); - let mut replayed = Vec::new(); - publish_pending_with(&state, |event| { - replayed.push(event.clone()); - Ok(()) - }) - .expect("queued events should replay"); - - assert!(matches!( - replayed.as_slice(), - [ - PendingEvent::Reconciled(ClaudeReconciliationTerminal { - generation: first_generation, - .. - }), - PendingEvent::Reconciling(ClaudeReconciliationStarted { - generation: second_generation, - }), - ] if *first_generation == first.0 && *second_generation == second.0 - )); - assert!(state.lock().unwrap().pending_events.is_empty()); + assert!(accepted); + assert_eq!(terminal.generation, second.0); + assert_eq!(terminal.status, ClaudeReconciliationStatus::Succeeded); + assert_eq!(state.snapshot(), Some(terminal)); } #[test] - fn publisher_runs_without_the_coordinator_lock_and_drains_reentrant_work_in_order() { - let state = Mutex::new(ClaudeReconciliationCoordinator::default()); - let first = state.lock().unwrap().begin(); - let mut injected = false; - let mut published = Vec::new(); - - publish_pending_with(&state, |event| { - let guard = state - .try_lock() - .expect("external publisher must run outside the coordinator lock"); - drop(guard); - published.push(event.clone()); - if !injected { - injected = true; - state.lock().unwrap().begin(); - } - Ok(()) - }) - .expect("reentrant enqueue should drain"); - - assert!(matches!( - published.as_slice(), - [ - PendingEvent::Reconciling(ClaudeReconciliationStarted { - generation: first_generation, - }), - PendingEvent::Reconciling(ClaudeReconciliationStarted { - generation: second_generation, - }), - ] if *first_generation == first.0 && *second_generation == first.0.wrapping_add(1) - )); + fn pending_snapshot_survives_until_late_failure_replaces_it() { + let mut state = ClaudeReconciliationState::default(); + let (token, pending) = state.begin(); + assert_eq!(pending.status, ClaudeReconciliationStatus::Pending); + assert_eq!(state.snapshot(), Some(pending)); + + let (accepted, failed) = state.complete( + token, + ClaudeReconciliationResult::failed(None, "late failure"), + ); + assert!(accepted); + assert_eq!(failed.status, ClaudeReconciliationStatus::Failed); + assert_eq!(failed.detail, "late failure"); + assert_eq!(state.snapshot(), Some(failed)); } #[test] - fn superseded_provider_refresh_is_an_explicit_failure() { - let terminal = + fn provider_refresh_supersession_is_an_explicit_failure() { + let result = ClaudeReconciliationResult::from_refresh(Ok(ProviderRefreshOutcome::Superseded { generation: 4, current_generation: 5, })); - - assert_eq!(terminal.status, ClaudeReconciliationStatus::Failed); - assert_eq!(terminal.provider_refresh_generation, Some(5)); - assert!(terminal.command_result().is_err()); - } - - #[test] - fn active_provider_refresh_is_an_explicit_failure() { - let terminal = - ClaudeReconciliationResult::from_refresh(Ok(ProviderRefreshOutcome::Skipped { - reason: ProviderRefreshSkipReason::Active { generation: 9 }, - })); - - assert_eq!(terminal.status, ClaudeReconciliationStatus::Failed); - assert_eq!(terminal.provider_refresh_generation, Some(9)); - assert!(terminal.command_result().is_err()); - } - - #[test] - fn no_enabled_provider_is_an_explicit_success() { - let terminal = - ClaudeReconciliationResult::from_refresh(Ok(ProviderRefreshOutcome::Skipped { - reason: ProviderRefreshSkipReason::NoEnabledProviders, - })); - - assert_eq!(terminal.status, ClaudeReconciliationStatus::Succeeded); - assert_eq!(terminal.detail, "noEnabledProviders"); - assert_eq!(terminal.command_result(), Ok(())); - } - - #[test] - fn provider_refresh_error_is_an_explicit_failure() { - let terminal = ClaudeReconciliationResult::from_refresh(Err("refresh failed".into())); - - assert_eq!(terminal.status, ClaudeReconciliationStatus::Failed); - assert_eq!(terminal.detail, "refresh failed"); - assert_eq!(terminal.command_result(), Err("refresh failed".into())); + assert_eq!(result.status, ClaudeReconciliationStatus::Failed); + assert_eq!(result.provider_refresh_generation, Some(5)); } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index 4a2174137d..fdfaeda967 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -33,7 +33,7 @@ mod agent_sessions; mod bridge; mod browser_import; mod claude_accounts; -mod claude_reconciliation; +pub(crate) mod claude_reconciliation; mod codex_accounts; mod codex_workspaces; mod credential_detection; diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index cbdd389e47..103d47b869 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -977,15 +977,6 @@ fn notify_usage_thresholds( token_account_id, ); let account = warning_identity.threshold_key(); - if let Some(unresolved) = warning_identity.unresolved_key() - && unresolved != account - { - guard.notification_manager.adopt_threshold_account_identity( - provider, - &unresolved, - &account, - ); - } // Skip all session consumers for synthetic/no-session // placeholders (e.g. Claude OAuth five_hour: null). if guard.notification_manager.check_session_lane( @@ -1107,11 +1098,6 @@ fn notify_predictive_pace( let Some(identity) = warning_identity.predictive_key() else { return; }; - if let Some(unresolved) = warning_identity.unresolved_key() - && unresolved != identity - { - manager.adopt_predictive_account_identity(provider, &unresolved, &identity); - } let observed_at = chrono::DateTime::parse_from_rfc3339(&snapshot.updated_at) .ok() .map(|date| date.with_timezone(&chrono::Utc)); diff --git a/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs b/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs index fb16e64976..7a9bd92b88 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs @@ -193,7 +193,7 @@ mod tests { } #[test] - fn resolved_identity_adopts_only_its_matching_unresolved_lane() { + fn unresolved_and_resolved_accounts_keep_separate_warning_history() { let oauth = WarningIdentity::new(ProviderId::Claude, "oauth", None, None, None); let cli = WarningIdentity::new( ProviderId::Claude, @@ -215,11 +215,9 @@ mod tests { Some("claude:oauth:unknown") ); assert_eq!(cli.unresolved_key().as_deref(), Some("claude:cli:unknown")); - assert_eq!( - resolved_cli.unresolved_key().as_deref(), - Some("claude:cli:unknown") - ); - assert_ne!(oauth.unresolved_key(), resolved_cli.unresolved_key()); + assert_ne!(cli.threshold_key(), resolved_cli.threshold_key()); + assert_ne!(cli.predictive_key(), resolved_cli.predictive_key()); + assert_ne!(oauth.predictive_key(), resolved_cli.predictive_key()); assert_eq!( resolved_cli.predictive_key().as_deref(), Some("cli:person@example.com") diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 83720c9d6b..02f48672b6 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -179,6 +179,7 @@ fn main() { commands::get_deepseek_pricing_status, commands::codex_accounts_list, commands::claude_accounts_list, + commands::claude_reconciliation_state, commands::claude_account_add, commands::claude_account_cancel_login, commands::claude_account_save_current, diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index 04baf239e6..10d626ec43 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -131,6 +131,9 @@ pub struct AppState { /// refresh starts or enablement changes so superseded results are ignored. pub provider_refresh_generation: u64, pub is_refreshing: bool, + /// Authoritative Claude account reconciliation state for mounted and + /// newly-mounted frontend surfaces. + pub claude_reconciliation: crate::commands::claude_reconciliation::ClaudeReconciliationState, pub update_state: UpdateState, /// Full update metadata from the last successful check. pub update_info: Option, @@ -195,6 +198,7 @@ impl AppState { provider_refresh_started_at: None, provider_refresh_generation: 0, is_refreshing: false, + claude_reconciliation: Default::default(), update_state: UpdateState::Idle, update_info: None, last_update_check_ms: None, diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx index 8fba1f54f7..a9fa68f320 100644 --- a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx +++ b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx @@ -3,13 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ClaudeAccount } from "../types/bridge"; const mocks = vi.hoisted(() => { - const listeners = new Map void>(); + const listeners = new Map void>(); return { claudeAccountsList: vi.fn(), claudeAccountSwitch: vi.fn(), + claudeReconciliationState: vi.fn(), refreshProviders: vi.fn(), listeners, - listen: vi.fn((event: string, callback: () => void) => { + listen: vi.fn((event: string, callback: (event: { payload: unknown }) => void) => { listeners.set(event, callback); return Promise.resolve(() => listeners.delete(event)); }), @@ -22,13 +23,17 @@ import ClaudeAccountsMenu from "./ClaudeAccountsMenu"; const first: ClaudeAccount = { id: "first:org", email: "first@example.com", organization: "Personal", plan: "max", isActive: true, isSaved: true }; const second: ClaudeAccount = { ...first, id: "second:org", email: "second@example.com", organization: "Work", isActive: false }; +const reconciliation = (generation: number, status: "pending" | "succeeded" | "failed", detail: string = status) => ({ + generation, status, detail, providerRefreshGeneration: null, +}); describe("ClaudeAccountsMenu", () => { beforeEach(() => { vi.clearAllMocks(); mocks.listeners.clear(); mocks.claudeAccountsList.mockResolvedValue([first, second]); - mocks.claudeAccountSwitch.mockResolvedValue(undefined); + mocks.claudeReconciliationState.mockResolvedValue(null); + mocks.claudeAccountSwitch.mockResolvedValue(reconciliation(1, "succeeded")); mocks.refreshProviders.mockResolvedValue(undefined); }); @@ -54,8 +59,8 @@ describe("ClaudeAccountsMenu", () => { }); it("keeps the menu in activating and reconciling phases until the switch settles", async () => { - let resolveSwitch: (() => void) | undefined; - mocks.claudeAccountSwitch.mockImplementation(() => new Promise(resolve => { + let resolveSwitch: ((value: ReturnType) => void) | undefined; + mocks.claudeAccountSwitch.mockImplementation(() => new Promise(resolve => { resolveSwitch = resolve; })); render(); @@ -69,18 +74,18 @@ describe("ClaudeAccountsMenu", () => { expect(details()).toHaveAttribute("aria-busy", "true"); await act(async () => { - mocks.listeners.get("claude-accounts-reconciling")?.(); + mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(1, "pending") }); }); expect(details().dataset.claudeAccountPhase).toBe("reconciling"); await act(async () => { - resolveSwitch?.(); + resolveSwitch?.(reconciliation(1, "pending")); }); // Settling is event-driven; the resolving switch promise alone stays in // the reconciling phase until the backend emits the terminal event. await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("reconciling")); await act(async () => { - mocks.listeners.get("claude-accounts-reconciled")?.(); + mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(1, "succeeded") }); }); await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("settled")); expect(details()).toHaveAttribute("aria-busy", "false"); @@ -94,14 +99,14 @@ describe("ClaudeAccountsMenu", () => { const button = within(row).getByRole("button"); await act(async () => { - mocks.listeners.get("claude-accounts-reconciling")?.(); + mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(2, "pending") }); }); expect(details().dataset.claudeAccountPhase).toBe("reconciling"); expect(details()).toHaveAttribute("aria-busy", "true"); expect(button).toBeDisabled(); await act(async () => { - mocks.listeners.get("claude-accounts-reconciled")?.(); + mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(2, "succeeded") }); }); expect(details().dataset.claudeAccountPhase).toBe("settled"); expect(details()).toHaveAttribute("aria-busy", "false"); @@ -109,8 +114,8 @@ describe("ClaudeAccountsMenu", () => { }); it("waits for the reconciled event before settling a local switch", async () => { - let resolveSwitch: (() => void) | undefined; - mocks.claudeAccountSwitch.mockImplementation(() => new Promise(resolve => { + let resolveSwitch: ((value: ReturnType) => void) | undefined; + mocks.claudeAccountSwitch.mockImplementation(() => new Promise(resolve => { resolveSwitch = resolve; })); render(); @@ -121,19 +126,40 @@ describe("ClaudeAccountsMenu", () => { const button = within(row).getByRole("button"); await act(async () => fireEvent.click(button)); await act(async () => { - mocks.listeners.get("claude-accounts-reconciling")?.(); + mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(3, "pending") }); }); await act(async () => { - resolveSwitch?.(); + resolveSwitch?.(reconciliation(3, "pending")); }); // The switch promise resolving is not enough: settling is event-driven. await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("reconciling")); await act(async () => { - mocks.listeners.get("claude-accounts-reconciled")?.(); + mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(3, "succeeded") }); }); await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("settled")); }); + it("surfaces a late failure for the matching pending generation", async () => { + mocks.claudeAccountSwitch.mockResolvedValue(reconciliation(8, "pending")); + render(); + await screen.findByText(second.email); + const row = screen.getByText(second.email).closest("li") as HTMLElement; + await act(async () => fireEvent.click(within(row).getByRole("button"))); + await waitFor(() => { + expect(document.querySelector("details")?.dataset.claudeAccountPhase).toBe("reconciling"); + }); + + await act(async () => { + mocks.listeners.get("claude-reconciliation-changed")?.({ + payload: reconciliation(8, "failed", "late refresh failed"), + }); + }); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain("late refresh failed"); + }); + expect(screen.queryByText("ClaudeAccountsSwitched")).toBeNull(); + }); + it("uses stable opaque account labels and redacts tooltips when hideEmail is enabled", async () => { mocks.claudeAccountsList.mockResolvedValue([first, { ...second, organization: `${second.email}'s Organization` }]); const { container } = render(); diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx index d8afc5c5e9..2b7b9d1c74 100644 --- a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx +++ b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx @@ -3,13 +3,12 @@ import { listen } from "@tauri-apps/api/event"; import type { ClaudeAccount } from "../types/bridge"; import { claudeAccountsList, claudeAccountSwitch } from "../lib/tauri"; import { useLocale } from "../hooks/useLocale"; +import { useClaudeReconciliation } from "../hooks/useClaudeReconciliation"; import { buildClaudeAccountOrdinals, buildPrivateClaudeAccountLabel, } from "./claudeAccountDisplay"; -type ClaudeAccountPhase = "idle" | "activating" | "reconciling" | "settled"; - export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { hideEmail: boolean; onLayoutChange?: () => void; @@ -18,7 +17,9 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { const [accounts, setAccounts] = useState([]); const [error, setError] = useState(null); const [switched, setSwitched] = useState(false); - const [phase, setPhase] = useState("idle"); + const [activating, setActivating] = useState(false); + const [operationGeneration, setOperationGeneration] = useState(null); + const { snapshot, accept, reconciling } = useClaudeReconciliation(); const mounted = useRef(false); const load = useCallback(async () => { const next = await claudeAccountsList(); @@ -35,25 +36,31 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { reload(); window.addEventListener("focus", reload); const unlisten = listen("claude-accounts-updated", reload); - const unlistenReconciling = listen("claude-accounts-reconciling", () => { - if (mounted.current) { - setPhase("reconciling"); - setSwitched(false); - } - }); - const unlistenReconciled = listen("claude-accounts-reconciled", () => { - if (mounted.current) { - setPhase("settled"); - } - }); return () => { mounted.current = false; window.removeEventListener("focus", reload); void unlisten.then(fn => fn()).catch(() => {}); - void unlistenReconciling.then(fn => fn()).catch(() => {}); - void unlistenReconciled.then(fn => fn()).catch(() => {}); }; }, [load]); + useEffect(() => { + if (!snapshot || snapshot.status === "pending") { + return; + } + if (snapshot.status === "failed") { + setSwitched(false); + setError(snapshot.detail); + } else if (snapshot.generation === operationGeneration) { + setSwitched(true); + setError(null); + } + }, [operationGeneration, snapshot]); + const phase = reconciling + ? "reconciling" + : activating + ? "activating" + : snapshot + ? "settled" + : "idle"; useEffect(() => { onLayoutChange?.(); }, [accounts.length, error, phase, switched, onLayoutChange]); @@ -61,20 +68,21 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { const accountOrdinals = buildClaudeAccountOrdinals(accounts); const switchAccount = async (id: string) => { - setPhase("activating"); + setActivating(true); + setOperationGeneration(null); setError(null); setSwitched(false); try { - await claudeAccountSwitch(id); + const result = await claudeAccountSwitch(id); + setOperationGeneration(result.generation); + accept(result); await load(); - // Settling is event-driven: the backend emits claude-accounts-reconciled - // after the awaited refresh. The promise resolving does not settle. - if (mounted.current) setSwitched(true); } catch (e) { if (mounted.current) { - setPhase("idle"); setError(String(e)); } + } finally { + if (mounted.current) setActivating(false); } }; diff --git a/apps/desktop-tauri/src/hooks/useClaudeReconciliation.test.ts b/apps/desktop-tauri/src/hooks/useClaudeReconciliation.test.ts new file mode 100644 index 0000000000..4b791f08a3 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useClaudeReconciliation.test.ts @@ -0,0 +1,66 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClaudeReconciliationSnapshot } from "../types/bridge"; + +const mocks = vi.hoisted(() => ({ + claudeReconciliationState: vi.fn(), + listen: vi.fn(), +})); +vi.mock("../lib/tauri", () => ({ + claudeReconciliationState: mocks.claudeReconciliationState, +})); +vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); +import { + selectClaudeReconciliation, + useClaudeReconciliation, +} from "./useClaudeReconciliation"; + +const snapshot = ( + generation: number, + status: ClaudeReconciliationSnapshot["status"], + detail: string = status, +): ClaudeReconciliationSnapshot => ({ + generation, + status, + detail, + providerRefreshGeneration: null, +}); + +describe("selectClaudeReconciliation", () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.listen.mockResolvedValue(() => {}); + mocks.claudeReconciliationState.mockResolvedValue(null); + }); + + it("hydrates a newly mounted surface from the authoritative query", async () => { + mocks.claudeReconciliationState.mockResolvedValue(snapshot(7, "pending")); + const { result } = renderHook(() => useClaudeReconciliation()); + await waitFor(() => expect(result.current.snapshot).toEqual(snapshot(7, "pending"))); + expect(result.current.reconciling).toBe(true); + }); + + it("recovers a pending operation from the mount-time query", () => { + expect(selectClaudeReconciliation(null, snapshot(4, "pending"))).toEqual( + snapshot(4, "pending"), + ); + }); + + it("accepts the matching late failure", () => { + expect( + selectClaudeReconciliation(snapshot(4, "pending"), snapshot(4, "failed", "late")), + ).toEqual(snapshot(4, "failed", "late")); + }); + + it("ignores stale and duplicate terminal states", () => { + const current = snapshot(5, "succeeded"); + expect(selectClaudeReconciliation(current, snapshot(4, "failed"))).toBe(current); + expect(selectClaudeReconciliation(current, snapshot(5, "failed"))).toBe(current); + }); + + it("lets a newer generation supersede an older terminal", () => { + expect( + selectClaudeReconciliation(snapshot(4, "failed"), snapshot(5, "pending")), + ).toEqual(snapshot(5, "pending")); + }); +}); diff --git a/apps/desktop-tauri/src/hooks/useClaudeReconciliation.ts b/apps/desktop-tauri/src/hooks/useClaudeReconciliation.ts new file mode 100644 index 0000000000..541fc91857 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useClaudeReconciliation.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { claudeReconciliationState } from "../lib/tauri"; +import type { ClaudeReconciliationSnapshot } from "../types/bridge"; + +const isTerminal = (snapshot: ClaudeReconciliationSnapshot) => snapshot.status !== "pending"; + +export function selectClaudeReconciliation( + current: ClaudeReconciliationSnapshot | null, + candidate: ClaudeReconciliationSnapshot, +): ClaudeReconciliationSnapshot { + if (!current || candidate.generation > current.generation) return candidate; + if (candidate.generation < current.generation) return current; + if (isTerminal(current) || candidate.status === "pending") return current; + return candidate; +} + +export function useClaudeReconciliation() { + const [snapshot, setSnapshot] = useState(null); + const accept = useCallback((candidate: ClaudeReconciliationSnapshot) => { + setSnapshot(current => selectClaudeReconciliation(current, candidate)); + }, []); + + useEffect(() => { + let mounted = true; + const unlisten = listen( + "claude-reconciliation-changed", + event => { + if (mounted) accept(event.payload); + }, + ); + void claudeReconciliationState() + .then(current => { + if (mounted && current) accept(current); + }) + .catch(() => {}); + return () => { + mounted = false; + void unlisten.then(dispose => dispose()).catch(() => {}); + }; + }, [accept]); + + return { + snapshot, + accept, + reconciling: snapshot?.status === "pending", + }; +} diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 7de5475e02..6ccd63c0de 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { ClaudeAccount, + ClaudeReconciliationSnapshot, GrokAccount, GrokAccountUsage, ClaudeSwapAccountsState, @@ -47,11 +48,14 @@ import type { } from "../types/bridge"; export const claudeAccountsList = () => invoke("claude_accounts_list"); +export const claudeReconciliationState = () => + invoke("claude_reconciliation_state"); export const claudeAccountAdd = () => invoke("claude_account_add"); export const claudeAccountCancelLogin = () => invoke("claude_account_cancel_login"); export const claudeAccountSaveCurrent = () => invoke("claude_account_save_current"); export const claudeAccountRemove = (id: string) => invoke("claude_account_remove", { id }); -export const claudeAccountSwitch = (id: string) => invoke("claude_account_switch", { id }); +export const claudeAccountSwitch = (id: string) => + invoke("claude_account_switch", { id }); export const grokAccountsList = () => invoke("grok_accounts_list"); export const grokAccountAdd = () => invoke("grok_account_add"); export const grokAccountCancelLogin = () => invoke("grok_account_cancel_login"); @@ -63,9 +67,9 @@ export const grokAccountFetch = (id: string) => export const claudeSwapAccountsList = () => invoke("claude_swap_accounts_list"); export const claudeSwapAccountSwitch = (slot: number) => - invoke("claude_swap_account_switch", { slot }); + invoke("claude_swap_account_switch", { slot }); export const claudeSwapAccountReauthenticate = (slot: number) => - invoke("claude_swap_account_reauthenticate", { slot }); + invoke("claude_swap_account_reauthenticate", { slot }); export function getBootstrapState(): Promise { return invoke("get_bootstrap_state"); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.test.tsx index c7e7b9929d..4e993b752e 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.test.tsx @@ -5,6 +5,7 @@ import type { ClaudeAccount } from "../../../../../types/bridge"; const mocks = vi.hoisted(() => ({ claudeAccountsList: vi.fn(), claudeAccountAdd: vi.fn(), claudeAccountCancelLogin: vi.fn(), claudeAccountSaveCurrent: vi.fn(), claudeAccountRemove: vi.fn(), claudeAccountSwitch: vi.fn(), + claudeReconciliationState: vi.fn(), claudeSwapAccountsList: vi.fn(), claudeSwapAccountSwitch: vi.fn(), getSettingsSnapshot: vi.fn(), updateSettings: vi.fn(), })); @@ -21,6 +22,10 @@ describe("ClaudeAccountsSection", () => { beforeEach(() => { vi.resetAllMocks(); events.listen.mockResolvedValue(() => {}); + mocks.claudeReconciliationState.mockResolvedValue(null); + mocks.claudeAccountSwitch.mockResolvedValue({ + generation: 1, status: "succeeded", providerRefreshGeneration: 1, detail: "published", + }); mocks.claudeAccountsList.mockResolvedValue([current, other]); mocks.claudeSwapAccountsList.mockResolvedValue({ enabled: false, @@ -52,7 +57,7 @@ describe("ClaudeAccountsSection", () => { render(); await screen.findByText(current.email); fireEvent.click(screen.getByText("CodexAccountsAddButton")); - const eventCallback = events.listen.mock.calls[0][1] as unknown as () => void; + const eventCallback = events.listen.mock.calls.find(([event]) => event === "claude-accounts-updated")?.[1] as unknown as () => void; await act(async () => eventCallback()); expect((screen.getByText("CodexAccountsSwitchButton") as HTMLButtonElement).disabled).toBe(true); await act(async () => fireEvent.click(screen.getByText("ClaudeAccountsCancelLogin"))); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx index acb8b7da44..99149d482c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import type { ClaudeAccount } from "../../../../../types/bridge"; +import type { ClaudeReconciliationSnapshot } from "../../../../../types/bridge"; import type { Language } from "../../../../../types/bridge"; import type { LocaleKey } from "../../../../../i18n/keys"; import { @@ -12,6 +13,7 @@ import { claudeAccountSwitch, } from "../../../../../lib/tauri"; import { ClaudeSwapAccountsSection } from "./ClaudeSwapAccountsSection"; +import { useClaudeReconciliation } from "../../../../../hooks/useClaudeReconciliation"; export function ClaudeAccountsSection({ t, @@ -25,7 +27,8 @@ export function ClaudeAccountsSection({ const [loggingIn, setLoggingIn] = useState(false); const [error, setError] = useState(null); const [message, setMessage] = useState(null); - const [reconciling, setReconciling] = useState(false); + const [operation, setOperation] = useState<{ generation: number; success?: LocaleKey } | null>(null); + const { snapshot, accept, reconciling } = useClaudeReconciliation(); const mounted = useRef(false); const load = useCallback(async () => { const next = await claudeAccountsList(); @@ -40,27 +43,39 @@ export function ClaudeAccountsSection({ }; reload(); const unlisten = listen("claude-accounts-updated", reload); - const unlistenReconciling = listen("claude-accounts-reconciling", () => { - if (mounted.current) setReconciling(true); - }); - const unlistenReconciled = listen("claude-accounts-reconciled", () => { - if (mounted.current) setReconciling(false); - }); return () => { mounted.current = false; void unlisten.then(fn => fn()); - void unlistenReconciling.then(fn => fn()).catch(() => {}); - void unlistenReconciled.then(fn => fn()).catch(() => {}); }; }, [load]); - const run = async (operation: () => Promise, success?: LocaleKey) => { + useEffect(() => { + if (!snapshot || snapshot.status === "pending") { + return; + } + if (snapshot.status === "failed") { + setMessage(null); + setError(snapshot.detail); + } else if (operation && snapshot.generation === operation.generation) { + if (operation.success) setMessage(t(operation.success)); + setError(null); + } + }, [operation, snapshot, t]); + const run = async ( + action: () => Promise, + success?: LocaleKey, + ) => { setBusy(true); setError(null); setMessage(null); try { - await operation(); + const result = await action(); await load(); - if (mounted.current && success) setMessage(t(success)); + if (result) { + setOperation({ generation: result.generation, success }); + accept(result); + } else if (mounted.current && success) { + setMessage(t(success)); + } } catch (e) { if (mounted.current) setError(String(e)); } finally { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx index 2b8a764438..9e1a0643fe 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ claudeSwapAccountsList: vi.fn(), claudeSwapAccountSwitch: vi.fn(), claudeSwapAccountReauthenticate: vi.fn(), + claudeReconciliationState: vi.fn(), getSettingsSnapshot: vi.fn(), updateSettings: vi.fn(), })); @@ -65,6 +66,7 @@ describe("ClaudeSwapAccountsSection", () => { beforeEach(() => { vi.resetAllMocks(); events.listen.mockResolvedValue(() => {}); + mocks.claudeReconciliationState.mockResolvedValue(null); mocks.getSettingsSnapshot.mockResolvedValue({ claudeSwapEnabled: false, claudeSwapExecutablePath: "", @@ -97,7 +99,9 @@ describe("ClaudeSwapAccountsSection", () => { mocks.claudeSwapAccountsList.mockResolvedValue( enabledState([active, switchable, blocked]), ); - mocks.claudeSwapAccountSwitch.mockResolvedValue(undefined); + mocks.claudeSwapAccountSwitch.mockResolvedValue({ + generation: 1, status: "succeeded", providerRefreshGeneration: 1, detail: "published", + }); render(); await screen.findByText("work@example.com"); @@ -179,7 +183,9 @@ describe("ClaudeSwapAccountsSection", () => { claudeSwapExecutablePath: "~/bin/cswap", }); mocks.claudeSwapAccountsList.mockResolvedValue(enabledState([foreign])); - mocks.claudeSwapAccountReauthenticate.mockResolvedValue(undefined); + mocks.claudeSwapAccountReauthenticate.mockResolvedValue({ + generation: 2, status: "succeeded", providerRefreshGeneration: 2, detail: "published", + }); render(); await screen.findByText("work@example.com"); await act(async () => diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx index 8925558f03..e660f0ab28 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx @@ -15,6 +15,7 @@ import { getSettingsSnapshot, updateSettings, } from "../../../../../lib/tauri"; +import { useClaudeReconciliation } from "../../../../../hooks/useClaudeReconciliation"; interface Props { t: (key: LocaleKey) => string; @@ -99,7 +100,8 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { const [state, setState] = useState(EMPTY_STATE); const locale = languageLocale(language); const [busy, setBusy] = useState(false); - const [reconciling, setReconciling] = useState(false); + const [operation, setOperation] = useState<{ generation: number; success: LocaleKey } | null>(null); + const { snapshot, accept, reconciling } = useClaudeReconciliation(); const [message, setMessage] = useState(null); const [error, setError] = useState(null); const mounted = useRef(false); @@ -132,20 +134,25 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { }; load(); const unlisten = listen("claude-accounts-updated", load); - const unlistenReconciling = listen("claude-accounts-reconciling", () => { - if (mounted.current) setReconciling(true); - }); - const unlistenReconciled = listen("claude-accounts-reconciled", () => { - if (mounted.current) setReconciling(false); - }); return () => { mounted.current = false; void unlisten.then((fn) => fn()).catch(() => {}); - void unlistenReconciling.then((fn) => fn()).catch(() => {}); - void unlistenReconciled.then((fn) => fn()).catch(() => {}); }; }, [reload]); + useEffect(() => { + if (!snapshot || snapshot.status === "pending") { + return; + } + if (snapshot.status === "failed") { + setMessage(null); + setError(snapshot.detail); + } else if (operation && snapshot.generation === operation.generation) { + setMessage(t(operation.success)); + setError(null); + } + }, [operation, snapshot, t]); + const runSettings = async ( patch: { claudeSwapEnabled?: boolean; claudeSwapExecutablePath?: string }, ) => { @@ -179,24 +186,19 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { setError(null); setMessage(null); try { + const success = account.action === "reauthenticate" + ? "ClaudeSwapReauthenticated" + : "ClaudeSwapSwitched"; + let result; if (account.action === "reauthenticate") { - await claudeSwapAccountReauthenticate(account.slot); + result = await claudeSwapAccountReauthenticate(account.slot); } else if (account.action === "switch") { - await claudeSwapAccountSwitch(account.slot); + result = await claudeSwapAccountSwitch(account.slot); } else { throw new Error("This claude-swap account is not actionable."); } - // The backend emits `claude-accounts-updated` after reconciliation; - // the listener above performs the single reload. - if (mounted.current) { - setMessage( - t( - account.action === "reauthenticate" - ? "ClaudeSwapReauthenticated" - : "ClaudeSwapSwitched", - ), - ); - } + setOperation({ generation: result.generation, success }); + accept(result); } catch (e) { if (mounted.current) setError(String(e)); } finally { diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 5aa0a40f27..88754baa7d 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -1082,6 +1082,13 @@ export interface ClaudeAccount { isSaved: boolean; } +export interface ClaudeReconciliationSnapshot { + generation: number; + status: "pending" | "succeeded" | "failed"; + providerRefreshGeneration: number | null; + detail: string; +} + export interface GrokAccount { id: string; email: string; diff --git a/rust/src/notifications.rs b/rust/src/notifications.rs index ba3fe34668..94a109bf40 100755 --- a/rust/src/notifications.rs +++ b/rust/src/notifications.rs @@ -229,65 +229,6 @@ impl NotificationManager { } } - /// Move threshold and session-transition history from a temporary account - /// discriminator to a verified one. This is intentionally explicit: the - /// caller must establish provider-owned account continuity first. - pub fn adopt_threshold_account_identity(&mut self, provider: ProviderId, from: &str, to: &str) { - if from.is_empty() || to.is_empty() || from == to { - return; - } - - let moved = self - .sent_notifications - .iter() - .filter(|(key_provider, account, _, _)| *key_provider == provider && account == from) - .cloned() - .collect::>(); - self.sent_notifications - .retain(|(key_provider, account, _, _)| *key_provider != provider || account != from); - self.sent_notifications.extend( - moved.into_iter().map(|(key_provider, _, window, kind)| { - (key_provider, to.to_string(), window, kind) - }), - ); - - if let Some(previous) = self - .previous_session_percent - .remove(&(provider, from.to_string())) - { - self.previous_session_percent - .insert((provider, to.to_string()), previous); - } - } - - /// Move predictive warning history independently from threshold history. - /// Predictive identities include the fetch source, so callers must never - /// use this to collapse OAuth and CLI histories into one known-account key. - pub fn adopt_predictive_account_identity( - &mut self, - provider: ProviderId, - from: &str, - to: &str, - ) { - if from.is_empty() || to.is_empty() || from == to { - return; - } - - let moved = self - .predictive_warning_keys - .iter() - .filter(|key| key.provider == provider && key.identity == from) - .cloned() - .collect::>(); - self.predictive_warning_keys - .retain(|key| key.provider != provider || key.identity != from); - self.predictive_warning_keys - .extend(moved.into_iter().map(|mut key| { - key.identity = to.to_string(); - key - })); - } - pub fn check_predictive_pace( &mut self, provider: ProviderId, @@ -915,47 +856,21 @@ mod tests { } #[test] - fn unresolved_warning_history_adopts_verified_identity_without_crossing_providers() { + fn unresolved_and_resolved_warning_histories_remain_separate() { let now = DateTime::from_timestamp(1_800_000_000, 0).unwrap(); let reset = window(now, Duration::hours(3), 300); let risk = pace(false, Some(3600.0)); - let settings = Settings::default(); let mut manager = NotificationManager::new(); - manager.check_and_notify( - ProviderId::Claude, - "claude-account:unknown", - "session", - 80.0, - &settings, - ); assert!(manager.record_predictive_observation( true, ProviderId::Claude, - "claude-account:unknown", + "claude:oauth:unknown", PredictiveWarningWindow::Session, &reset, &risk, )); - - manager.adopt_threshold_account_identity( - ProviderId::Claude, - "claude-account:unknown", - "person@example.com", - ); - manager.adopt_predictive_account_identity( - ProviderId::Claude, - "claude-account:unknown", - "oauth:person@example.com", - ); - - assert!(manager.sent_notifications.contains(&( - ProviderId::Claude, - "person@example.com".to_string(), - "session".to_string(), - NotificationType::HighUsage, - ))); - assert!(!manager.record_predictive_observation( + assert!(manager.record_predictive_observation( true, ProviderId::Claude, "oauth:person@example.com", @@ -963,12 +878,7 @@ mod tests { &reset, &risk, )); - assert!( - manager - .predictive_warning_keys - .iter() - .all(|key| key.identity != "claude-account:unknown") - ); + assert_eq!(manager.predictive_warning_keys.len(), 2); } #[test] From e4c93b6cc7282b506a8f6de5154fb8a11408a33f Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:48:31 +0700 Subject: [PATCH 09/12] Gate Claude reconciliation outcomes by operation --- .../src-tauri/src/tray_accounts.rs | 4 +++- .../src/components/ClaudeAccountsMenu.tsx | 16 ++++++++------- .../src/hooks/useClaudeReconciliation.test.ts | 20 +++++++++++++++++++ .../src/hooks/useClaudeReconciliation.ts | 14 +++++++++++++ .../credentials/ClaudeAccountsSection.tsx | 16 ++++++++------- .../credentials/ClaudeSwapAccountsSection.tsx | 16 ++++++++------- 6 files changed, 64 insertions(+), 22 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs index aeb51580cf..c2ffcea9b6 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs @@ -184,7 +184,9 @@ pub(crate) fn handle_action(app: &AppHandle, action: AccountMenuAction) { "Current Claude Code account saved.", ), AccountMenuAction::SwitchClaudeAccount(id) => ( - crate::commands::claude_account_switch(handle.clone(), id).await, + crate::commands::claude_account_switch(handle.clone(), id) + .await + .map(|_| ()), "Claude Code account switched. Reopen the Claude Code CLI to use it.", ), _ => unreachable!(), diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx index 2b7b9d1c74..b57e6a1af4 100644 --- a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx +++ b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx @@ -3,7 +3,10 @@ import { listen } from "@tauri-apps/api/event"; import type { ClaudeAccount } from "../types/bridge"; import { claudeAccountsList, claudeAccountSwitch } from "../lib/tauri"; import { useLocale } from "../hooks/useLocale"; -import { useClaudeReconciliation } from "../hooks/useClaudeReconciliation"; +import { + localClaudeReconciliationOutcome, + useClaudeReconciliation, +} from "../hooks/useClaudeReconciliation"; import { buildClaudeAccountOrdinals, buildPrivateClaudeAccountLabel, @@ -43,13 +46,12 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { }; }, [load]); useEffect(() => { - if (!snapshot || snapshot.status === "pending") { - return; - } - if (snapshot.status === "failed") { + const outcome = localClaudeReconciliationOutcome(snapshot, operationGeneration); + if (!outcome) return; + if (outcome.status === "failed") { setSwitched(false); - setError(snapshot.detail); - } else if (snapshot.generation === operationGeneration) { + setError(outcome.detail); + } else { setSwitched(true); setError(null); } diff --git a/apps/desktop-tauri/src/hooks/useClaudeReconciliation.test.ts b/apps/desktop-tauri/src/hooks/useClaudeReconciliation.test.ts index 4b791f08a3..fd88547703 100644 --- a/apps/desktop-tauri/src/hooks/useClaudeReconciliation.test.ts +++ b/apps/desktop-tauri/src/hooks/useClaudeReconciliation.test.ts @@ -11,6 +11,7 @@ vi.mock("../lib/tauri", () => ({ })); vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); import { + localClaudeReconciliationOutcome, selectClaudeReconciliation, useClaudeReconciliation, } from "./useClaudeReconciliation"; @@ -64,3 +65,22 @@ describe("selectClaudeReconciliation", () => { ).toEqual(snapshot(5, "pending")); }); }); + +describe("localClaudeReconciliationOutcome", () => { + it("ignores an old hydrated failure when no local operation owns it", () => { + expect( + localClaudeReconciliationOutcome(snapshot(7, "failed", "old failure"), null), + ).toBeNull(); + }); + + it("ignores a terminal failure from a different generation", () => { + expect( + localClaudeReconciliationOutcome(snapshot(8, "failed", "other failure"), 9), + ).toBeNull(); + }); + + it("returns a matching late failure", () => { + const failure = snapshot(10, "failed", "late failure"); + expect(localClaudeReconciliationOutcome(failure, 10)).toBe(failure); + }); +}); diff --git a/apps/desktop-tauri/src/hooks/useClaudeReconciliation.ts b/apps/desktop-tauri/src/hooks/useClaudeReconciliation.ts index 541fc91857..d5f375e2be 100644 --- a/apps/desktop-tauri/src/hooks/useClaudeReconciliation.ts +++ b/apps/desktop-tauri/src/hooks/useClaudeReconciliation.ts @@ -5,6 +5,20 @@ import type { ClaudeReconciliationSnapshot } from "../types/bridge"; const isTerminal = (snapshot: ClaudeReconciliationSnapshot) => snapshot.status !== "pending"; +export function localClaudeReconciliationOutcome( + snapshot: ClaudeReconciliationSnapshot | null, + operationGeneration: number | null, +): ClaudeReconciliationSnapshot | null { + if ( + !snapshot + || snapshot.status === "pending" + || snapshot.generation !== operationGeneration + ) { + return null; + } + return snapshot; +} + export function selectClaudeReconciliation( current: ClaudeReconciliationSnapshot | null, candidate: ClaudeReconciliationSnapshot, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx index 99149d482c..467e676b18 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx @@ -13,7 +13,10 @@ import { claudeAccountSwitch, } from "../../../../../lib/tauri"; import { ClaudeSwapAccountsSection } from "./ClaudeSwapAccountsSection"; -import { useClaudeReconciliation } from "../../../../../hooks/useClaudeReconciliation"; +import { + localClaudeReconciliationOutcome, + useClaudeReconciliation, +} from "../../../../../hooks/useClaudeReconciliation"; export function ClaudeAccountsSection({ t, @@ -49,13 +52,12 @@ export function ClaudeAccountsSection({ }; }, [load]); useEffect(() => { - if (!snapshot || snapshot.status === "pending") { - return; - } - if (snapshot.status === "failed") { + const outcome = localClaudeReconciliationOutcome(snapshot, operation?.generation ?? null); + if (!outcome) return; + if (outcome.status === "failed") { setMessage(null); - setError(snapshot.detail); - } else if (operation && snapshot.generation === operation.generation) { + setError(outcome.detail); + } else if (operation) { if (operation.success) setMessage(t(operation.success)); setError(null); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx index e660f0ab28..ef7509ad63 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx @@ -15,7 +15,10 @@ import { getSettingsSnapshot, updateSettings, } from "../../../../../lib/tauri"; -import { useClaudeReconciliation } from "../../../../../hooks/useClaudeReconciliation"; +import { + localClaudeReconciliationOutcome, + useClaudeReconciliation, +} from "../../../../../hooks/useClaudeReconciliation"; interface Props { t: (key: LocaleKey) => string; @@ -141,13 +144,12 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { }, [reload]); useEffect(() => { - if (!snapshot || snapshot.status === "pending") { - return; - } - if (snapshot.status === "failed") { + const outcome = localClaudeReconciliationOutcome(snapshot, operation?.generation ?? null); + if (!outcome) return; + if (outcome.status === "failed") { setMessage(null); - setError(snapshot.detail); - } else if (operation && snapshot.generation === operation.generation) { + setError(outcome.detail); + } else if (operation) { setMessage(t(operation.success)); setError(null); } From b7aa3aca1a972bc58da725eeb378c36f39dd564f Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:57:32 +0700 Subject: [PATCH 10/12] Align reconciliation method visibility --- .../src-tauri/src/commands/claude_reconciliation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs index 959eda035c..4396284692 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs @@ -103,7 +103,7 @@ impl ClaudeReconciliationState { (token, snapshot) } - pub(crate) fn complete( + pub(super) fn complete( &mut self, token: ClaudeReconciliationToken, result: ClaudeReconciliationResult, From f7bd2a1686df18622dfe20ed4cf0eac22c2ffe54 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:12:24 +0700 Subject: [PATCH 11/12] Fix reconciliation panel test mocks --- apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx | 1 + apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index b287b35754..195f6b0da0 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -19,6 +19,7 @@ const tauriMocks = vi.hoisted(() => ({ getLocaleStrings: vi.fn(), setUiLanguage: vi.fn(), getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null), + claudeReconciliationState: vi.fn().mockResolvedValue(null), })); const eventMocks = vi.hoisted(() => ({ diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 09a5b01e1c..00e7bb6441 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -32,6 +32,7 @@ const tauriMocks = vi.hoisted(() => ({ setUiLanguage: vi.fn(), getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null), getUsageSpendSummary: vi.fn(), + claudeReconciliationState: vi.fn().mockResolvedValue(null), })); const eventMocks = vi.hoisted(() => ({ From 1788fb10ab132c32d6015488ce38203663c1c9d1 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:37:05 +0700 Subject: [PATCH 12/12] Reset reconciliation panel mocks --- apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx | 1 + apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index 195f6b0da0..0e7f3053a9 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -233,6 +233,7 @@ function renderPopOut( describe("PopOutPanel", () => { beforeEach(() => { vi.clearAllMocks(); + tauriMocks.claudeReconciliationState.mockResolvedValue(null); tauriMocks.refreshProviders.mockResolvedValue(undefined); tauriMocks.refreshProvidersIfStale.mockResolvedValue(undefined); tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 00e7bb6441..8e156a34bf 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -229,6 +229,7 @@ describe("TrayPanel provider grid", () => { beforeEach(() => { vi.clearAllMocks(); eventMocks.listeners.clear(); + tauriMocks.claudeReconciliationState.mockResolvedValue(null); tauriMocks.getDeepSeekPricingStatus.mockResolvedValue(null); tauriMocks.getUsageSpendSummary.mockResolvedValue({ rows: [], models: [] }); tauriMocks.flyoutStoredSize.mockResolvedValue(null);