From 4f4445ec2a93c48a6c4ab94401495a5e0076dd52 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 19:35:38 -0700 Subject: [PATCH 01/17] fix(macos): consume settings deep links reactively - Observe one-shot Settings deep-link changes while the window is already open - Share a single SettingsView consumption path for appear and focus-time updates - Clear pending targets only after a real target is consumed to avoid replay loops --- .../Screens/Settings/SettingsView.swift | 15 ++++++++++----- .../Screens/Settings/SettingsViewModel.swift | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/macos/Codescribe/Screens/Settings/SettingsView.swift b/macos/Codescribe/Screens/Settings/SettingsView.swift index 35d5b27d..019ed138 100644 --- a/macos/Codescribe/Screens/Settings/SettingsView.swift +++ b/macos/Codescribe/Screens/Settings/SettingsView.swift @@ -1,3 +1,4 @@ +import Combine import SwiftUI // Settings window: NavigationSplitView with the 212px section rail (Creator · Keys @@ -28,14 +29,18 @@ struct SettingsView: View { .preferredColorScheme(.dark) .onAppear { model.refresh() - // Honour a one-shot deep-link (e.g. onboarding routing to MCP setup) - // so the window lands on the requested section instead of the default. - if let target = SettingsDeepLink.consume() { - model.select(target) - } + consumePendingDeepLink() + } + .onReceive(NotificationCenter.default.publisher(for: SettingsDeepLink.pendingSectionDidChange)) { _ in + consumePendingDeepLink() } } + private func consumePendingDeepLink() { + guard let target = SettingsDeepLink.consume() else { return } + model.select(target) + } + @ViewBuilder private var detail: some View { ScrollView { diff --git a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift index 9d1e2e5d..9f469266 100644 --- a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift +++ b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift @@ -24,16 +24,24 @@ enum SettingsSection: String, CaseIterable, Identifiable { /// One-shot deep-link target for the Settings window. A surface outside Settings /// (e.g. the onboarding wizard routing the user to MCP setup) sets this before -/// opening the window; `SettingsView` consumes it once on appear and navigates to -/// the requested section. Nil means "open on the last/default section". +/// opening or focusing the window; `SettingsView` consumes it once on appear and +/// whenever an already-open window receives a new target. Nil means "open on the +/// last/default section". @MainActor enum SettingsDeepLink { - static var pendingSection: SettingsSection? + static let pendingSectionDidChange = Notification.Name("codescribe.settingsDeepLink.pendingSectionDidChange") + + static var pendingSection: SettingsSection? { + didSet { + NotificationCenter.default.post(name: pendingSectionDidChange, object: nil) + } + } /// Take the pending target (if any), clearing it so a later open is unaffected. static func consume() -> SettingsSection? { - defer { pendingSection = nil } - return pendingSection + guard let target = pendingSection else { return nil } + pendingSection = nil + return target } } From c5ee47f18976b47e4efa283987b5f20c286999af Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 19:45:50 -0700 Subject: [PATCH 02/17] fix(stt): populate Apple SpeechAnalyzer segments - Use the time-indexed SpeechTranscriber preset and emit bridge segments from audioTimeRange attributes. - Map bridge segment fixtures into RawTranscript with validation before downstream pipeline use. - Cover the Apple segment path with a Rust fixture that reaches the Silero tail-drop filter. --- .../stt/apple_stt/codescribe-stt-bridge.swift | 62 ++++++++++++++- core/stt/apple_stt/mod.rs | 78 +++++++++++++++++-- 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/core/stt/apple_stt/codescribe-stt-bridge.swift b/core/stt/apple_stt/codescribe-stt-bridge.swift index 1ab3a197..f57a4db6 100644 --- a/core/stt/apple_stt/codescribe-stt-bridge.swift +++ b/core/stt/apple_stt/codescribe-stt-bridge.swift @@ -147,7 +147,7 @@ private func probe(locale: Locale, allowDownload: Bool) async throws -> BridgeRe error: nil ) } - let transcriber = SpeechTranscriber(locale: effectiveLocale, preset: .transcription) + let transcriber = makeTranscriber(locale: effectiveLocale) let isSupported = true var isInstalled = containsLocale(installed, locale: effectiveLocale) @@ -188,12 +188,16 @@ private struct TranscriptionPayload { let segments: [BridgeSegment] } +private func makeTranscriber(locale: Locale) -> SpeechTranscriber { + SpeechTranscriber(locale: locale, preset: .timeIndexedTranscriptionWithAlternatives) +} + private func transcribe(audioPath: String, locale: Locale) async throws -> TranscriptionPayload { let supportedLocales = await SpeechTranscriber.supportedLocales guard let effectiveLocale = bestAvailableLocale(requested: locale, available: supportedLocales) else { throw BridgeError.runtime("locale \(locale.identifier) is not supported") } - let transcriber = SpeechTranscriber(locale: effectiveLocale, preset: .transcription) + let transcriber = makeTranscriber(locale: effectiveLocale) let analyzer = SpeechAnalyzer(modules: [transcriber]) guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber]) else { throw BridgeError.runtime("no compatible analyzer audio format available") @@ -201,15 +205,22 @@ private func transcribe(audioPath: String, locale: Locale) async throws -> Trans let (inputSequence, inputBuilder) = AsyncStream.makeStream() var volatileText = "" + var volatileSegments: [BridgeSegment] = [] var finalTextParts: [String] = [] + var finalSegments: [BridgeSegment] = [] let collector = Task { for try await result in transcriber.results { let text = String(result.text.characters) + let segments = segmentsFromAttributedText(result.text) if result.isFinal { finalTextParts.append(text) + finalSegments.append(contentsOf: segments) + volatileText = "" + volatileSegments = [] } else { volatileText = text + volatileSegments = segments } } } @@ -227,7 +238,52 @@ private func transcribe(audioPath: String, locale: Locale) async throws -> Trans let combined = finalTextParts.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) let fallback = volatileText.trimmingCharacters(in: .whitespacesAndNewlines) let text = combined.isEmpty ? fallback : combined - return TranscriptionPayload(text: text, segments: []) + let segments = normalizeSegments(finalSegments.isEmpty ? volatileSegments : finalSegments) + return TranscriptionPayload(text: text, segments: segments) +} + +private func segmentsFromAttributedText(_ attributedText: AttributedString) -> [BridgeSegment] { + attributedText.runs.compactMap { run in + guard let timeRange = run.audioTimeRange else { + return nil + } + let text = String(attributedText[run.range].characters) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + return nil + } + let start = timeRange.start.seconds + let end = timeRange.end.seconds + guard start.isFinite, end.isFinite, end >= start else { + return nil + } + return BridgeSegment(text: text, startTs: start, endTs: end) + } +} + +private func normalizeSegments(_ segments: [BridgeSegment]) -> [BridgeSegment] { + let sorted = segments.sorted { + if $0.startTs == $1.startTs { + return $0.endTs < $1.endTs + } + return $0.startTs < $1.startTs + } + var normalized: [BridgeSegment] = [] + var previousEnd = -Double.infinity + for segment in sorted { + let text = segment.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, + segment.startTs.isFinite, + segment.endTs.isFinite, + segment.endTs >= segment.startTs, + segment.startTs >= previousEnd + else { + continue + } + normalized.append(BridgeSegment(text: text, startTs: segment.startTs, endTs: segment.endTs)) + previousEnd = segment.endTs + } + return normalized } private func streamAudio( diff --git a/core/stt/apple_stt/mod.rs b/core/stt/apple_stt/mod.rs index fad6a435..624697e3 100644 --- a/core/stt/apple_stt/mod.rs +++ b/core/stt/apple_stt/mod.rs @@ -200,20 +200,35 @@ fn transcribe_via_bridge( let response = run_bridge_with_timeout(&request, Some(BRIDGE_TRANSCRIBE_TIMEOUT)) .context("Apple STT bridge transcribe failed")?; + Ok(raw_transcript_from_bridge_response(response)) +} + +fn raw_transcript_from_bridge_response(response: BridgeResponse) -> RawTranscript { let segments = response .segments .into_iter() - .map(|seg| TranscriptSegment { - text: seg.text, - start_ts: seg.start_ts, - end_ts: seg.end_ts, - }) + .filter_map(bridge_segment_to_transcript_segment) .collect(); - - Ok(RawTranscript { + RawTranscript { text: response.text.trim().to_string(), segments, ..Default::default() + } +} + +fn bridge_segment_to_transcript_segment(seg: BridgeSegment) -> Option { + let text = seg.text.trim().to_string(); + if text.is_empty() + || !seg.start_ts.is_finite() + || !seg.end_ts.is_finite() + || seg.end_ts < seg.start_ts + { + return None; + } + Some(TranscriptSegment { + text, + start_ts: seg.start_ts, + end_ts: seg.end_ts, }) } @@ -747,4 +762,53 @@ mod tests { assert_eq!(parse_bool_flag("0"), Some(false)); assert_eq!(parse_bool_flag("maybe"), None); } + + #[test] + fn bridge_response_segments_flow_to_raw_transcript_and_silero_tail_drop() { + let response: BridgeResponse = serde_json::from_str( + r#"{ + "ok": true, + "status": "ok", + "text": "To jest początek Dziękuję za uwagę", + "segments": [ + {"text": "To jest początek", "start_ts": 0.0, "end_ts": 0.4}, + {"text": "Dziękuję za uwagę", "start_ts": 2.0, "end_ts": 2.4} + ] + }"#, + ) + .expect("fixture bridge response must parse"); + + let raw = raw_transcript_from_bridge_response(response); + + assert_eq!(raw.segments.len(), 2); + assert!( + raw.segments + .windows(2) + .all(|pair| pair[0].end_ts <= pair[1].start_ts), + "Apple bridge segments must preserve a monotonic timeline" + ); + + let timeline = crate::vad::discriminator::VadTimeline { + classes: vec![ + crate::pipeline::contracts::VadClass::Speech, + crate::pipeline::contracts::VadClass::Speech, + crate::pipeline::contracts::VadClass::TrailingSilence, + crate::pipeline::contracts::VadClass::TrailingSilence, + crate::pipeline::contracts::VadClass::TrailingSilence, + ], + window_sec: 0.5, + }; + let vad_config = crate::vad::VadConfig { + tail_drop_enabled: true, + ..Default::default() + }; + let outcome = crate::stt::whisper::map_whisper_segments_to_silero( + &raw.segments, + &timeline, + &vad_config, + ); + + assert_eq!(outcome.dropped_count, 1); + assert_eq!(outcome.text, "To jest początek"); + } } From ba369848117e2d29db7224256e9ca9416847126b Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 19:46:49 -0700 Subject: [PATCH 03/17] fix(macos): surface composer dictation engine errors and leave recording state - Route composer dictation listener engine errors back to the main adapter. - Report active engine failures through AgentChatStore so the composer leaves .recording. - Release a stale dictationBlocked flag on the terminal composer error path. - Keep successful stop-and-insert dictation flow unchanged. --- macos/Codescribe/Core/ComposerDictation.swift | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/macos/Codescribe/Core/ComposerDictation.swift b/macos/Codescribe/Core/ComposerDictation.swift index 69a92c24..6078a358 100644 --- a/macos/Codescribe/Core/ComposerDictation.swift +++ b/macos/Codescribe/Core/ComposerDictation.swift @@ -71,7 +71,11 @@ final class RealComposerDictation: ComposerDictating { } // Register a fresh listener (held strongly here) before starting; the // bridge rejects `startRecording` without one. - let listener = ComposerDictationListener(store: store) + let listener = ComposerDictationListener(store: store) { [weak self] message in + Task { @MainActor [weak self] in + self?.handleEngineError(message: message) + } + } self.listener = listener dictation.setListener(listener: listener) do { @@ -116,6 +120,17 @@ final class RealComposerDictation: ComposerDictating { } } + private func handleEngineError(message: String) { + dictationLog.error("composer dictation engine error: \(message, privacy: .public)") + guard let store else { return } + guard transitioning || store.dictationPhase == .preparing || store.dictationPhase == .recording else { return } + + transitioning = false + listener = nil + store.dictationBlocked = false + store.reportDictationFailure("Dictation stopped: \(message)") + } + /// Check (and, if undetermined, request) microphone access. The request wrapper /// blocks on the system prompt, so it runs off the main actor. private static func ensureMicPermission() async -> Bool { @@ -130,12 +145,14 @@ final class RealComposerDictation: ComposerDictating { /// final transcript from `stopRecording()` before mutating the draft. final class ComposerDictationListener: CsTranscriptionListener, @unchecked Sendable { private weak var store: AgentChatStore? + private let onError: (String) -> Void private let lock = NSLock() private var committedSegments: [(utteranceId: UInt64, text: String)] = [] private var activePreview = "" - init(store: AgentChatStore) { + init(store: AgentChatStore, onError: @escaping (String) -> Void) { self.store = store + self.onError = onError } func onRecordingPreparing() {} @@ -171,7 +188,7 @@ final class ComposerDictationListener: CsTranscriptionListener, @unchecked Senda dictationLog.info("composer dictation: no speech (\(reason, privacy: .public))") } func onError(message: String) { - dictationLog.error("composer dictation engine warning: \(message, privacy: .public)") + onError(message) } private func publishPreview(_ update: () -> Void) { From 63dc78629b63cf0a33c9c48f0a7c675e0625e2f6 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 21:21:07 -0700 Subject: [PATCH 04/17] chore(macos): guard #Preview blocks with #if DEBUG - Wrap remaining SwiftUI #Preview blocks in debug-only compilation guards - Match the existing guarded preview pattern without changing runtime code - Keep batch scope limited to macOS Swift preview guard lines --- macos/Codescribe/DesignSystem/DesignGallery.swift | 2 ++ macos/Codescribe/Screens/Onboarding/OnboardingView.swift | 2 ++ macos/Codescribe/Screens/Overlay/WaveformView.swift | 2 ++ macos/Codescribe/Screens/Settings/CreatorPanel.swift | 2 ++ macos/Codescribe/Screens/Settings/EnginePanel.swift | 2 ++ macos/Codescribe/Screens/Settings/PromptPanel.swift | 2 ++ macos/Codescribe/Screens/Settings/SettingsView.swift | 2 ++ 7 files changed, 14 insertions(+) diff --git a/macos/Codescribe/DesignSystem/DesignGallery.swift b/macos/Codescribe/DesignSystem/DesignGallery.swift index b2f06562..c67459e3 100644 --- a/macos/Codescribe/DesignSystem/DesignGallery.swift +++ b/macos/Codescribe/DesignSystem/DesignGallery.swift @@ -72,6 +72,8 @@ struct DesignGallery: View { } } +#if DEBUG #Preview("Design Gallery") { DesignGallery() } +#endif diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingView.swift b/macos/Codescribe/Screens/Onboarding/OnboardingView.swift index c90433fa..1e504e6c 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingView.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingView.swift @@ -151,6 +151,7 @@ struct OnboardingButton: View { } } +#if DEBUG #Preview("Onboarding — Welcome") { OnboardingView(model: OnboardingViewModel( engine: MockOnboardingEngine(progress: 0), @@ -225,3 +226,4 @@ struct OnboardingButton: View { .frame(width: 720, height: 620) .preferredColorScheme(.dark) } +#endif diff --git a/macos/Codescribe/Screens/Overlay/WaveformView.swift b/macos/Codescribe/Screens/Overlay/WaveformView.swift index 9414deb6..88e45183 100644 --- a/macos/Codescribe/Screens/Overlay/WaveformView.swift +++ b/macos/Codescribe/Screens/Overlay/WaveformView.swift @@ -84,8 +84,10 @@ struct WaveformView: View { } } +#if DEBUG #Preview("Waveform — active") { WaveformView(active: true) .padding(40) .background(CSColor.glassUnder) } +#endif diff --git a/macos/Codescribe/Screens/Settings/CreatorPanel.swift b/macos/Codescribe/Screens/Settings/CreatorPanel.swift index d0e42824..2e386fc3 100644 --- a/macos/Codescribe/Screens/Settings/CreatorPanel.swift +++ b/macos/Codescribe/Screens/Settings/CreatorPanel.swift @@ -256,9 +256,11 @@ private struct LaunchpadChips: View { } } +#if DEBUG #Preview("Creator panel") { ScrollView { CreatorPanel(model: .preview) } .frame(width: 720, height: 620) .background(SettingsView.windowGradient) .preferredColorScheme(.dark) } +#endif diff --git a/macos/Codescribe/Screens/Settings/EnginePanel.swift b/macos/Codescribe/Screens/Settings/EnginePanel.swift index f3f1916a..be5d8dc1 100644 --- a/macos/Codescribe/Screens/Settings/EnginePanel.swift +++ b/macos/Codescribe/Screens/Settings/EnginePanel.swift @@ -492,9 +492,11 @@ struct SettingsSectionLabel: View { } } +#if DEBUG #Preview("Engine panel") { ScrollView { EnginePanel(model: .preview(.engine)) } .frame(width: 720, height: 620) .background(SettingsView.windowGradient) .preferredColorScheme(.dark) } +#endif diff --git a/macos/Codescribe/Screens/Settings/PromptPanel.swift b/macos/Codescribe/Screens/Settings/PromptPanel.swift index 3fb8e060..dcc92720 100644 --- a/macos/Codescribe/Screens/Settings/PromptPanel.swift +++ b/macos/Codescribe/Screens/Settings/PromptPanel.swift @@ -178,9 +178,11 @@ private struct PromptEditor: View { } } +#if DEBUG #Preview("Prompt panel") { ScrollView { PromptPanel(model: .preview(.prompts)) } .frame(width: 720, height: 620) .background(SettingsView.windowGradient) .preferredColorScheme(.dark) } +#endif diff --git a/macos/Codescribe/Screens/Settings/SettingsView.swift b/macos/Codescribe/Screens/Settings/SettingsView.swift index 019ed138..e19e738f 100644 --- a/macos/Codescribe/Screens/Settings/SettingsView.swift +++ b/macos/Codescribe/Screens/Settings/SettingsView.swift @@ -181,6 +181,7 @@ private struct SettingsRail: View { } } +#if DEBUG #Preview("Settings — Creator") { SettingsView(model: SettingsViewModel.preview(.creator)) .frame(width: 960, height: 620) @@ -200,3 +201,4 @@ private struct SettingsRail: View { SettingsView(model: SettingsViewModel.preview(.prompts)) .frame(width: 960, height: 620) } +#endif From d366842a8f8c39df8526065f02e09f034b0f2ef2 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 21:25:37 -0700 Subject: [PATCH 05/17] fix(stt): preserve whisper text integrity - Keep full raw Whisper text when Silero did not drop segments but segment text is a strict subset - Reduce decoder control-token suppression from 16 generated tokens to the pre-first-token guard - Add focused regression tests for Silero text preservation and short utterance termination - Avoid pre-commit hook side effects by committing only the STT engine file --- core/stt/whisper/engine.rs | 66 +++++++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/core/stt/whisper/engine.rs b/core/stt/whisper/engine.rs index 1e1960a7..da7768fe 100644 --- a/core/stt/whisper/engine.rs +++ b/core/stt/whisper/engine.rs @@ -188,6 +188,36 @@ fn apply_requested_final_pass( } } +fn apply_silero_filter_outcome( + raw: &RawTranscript, + filtered_text: String, + filtered_segments: Vec, + dropped_count: u32, +) -> RawTranscript { + let mut filtered = raw.clone(); + filtered.text = if dropped_count == 0 && is_strict_text_subset(&filtered_text, &raw.text) { + raw.text.clone() + } else { + filtered_text + }; + filtered.segments = filtered_segments; + filtered +} + +fn is_strict_text_subset(candidate: &str, full_text: &str) -> bool { + let candidate = normalize_transcript_text(candidate); + let full_text = normalize_transcript_text(full_text); + !candidate.is_empty() && candidate != full_text && full_text.contains(&candidate) +} + +fn normalize_transcript_text(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn should_suppress_decoder_control_tokens(generated_tokens: usize) -> bool { + generated_tokens == 0 +} + pub struct LocalWhisperEngine { model: Model, tokenizer: Tokenizer, @@ -537,10 +567,10 @@ impl LocalWhisperEngine { ); } - let mut filtered = raw.clone(); - filtered.text = outcome.text; - filtered.segments = outcome.segments; - (filtered, outcome.dropped_count) + let dropped_count = outcome.dropped_count; + let filtered = + apply_silero_filter_outcome(&raw, outcome.text, outcome.segments, dropped_count); + (filtered, dropped_count) }; let (text, final_pass) = apply_requested_final_pass(&raw_for_final_pass, options); @@ -1047,7 +1077,7 @@ impl LocalWhisperEngine { } // Avoid terminating immediately when nothing has been emitted yet - let suppress_tokens = all_tokens.len() < 16; + let suppress_tokens = should_suppress_decoder_control_tokens(all_tokens.len()); if suppress_tokens { if (eot_token as usize) < logits_vec.len() { logits_vec[eot_token as usize] = f32::NEG_INFINITY; @@ -1936,6 +1966,32 @@ mod dedup_tests { assert!(should_drop_for_quality_gate(Some(-3.0), 3.0, ¶ms)); } + #[test] + fn silero_filter_preserves_raw_text_when_no_segments_were_dropped() { + let segments = vec![crate::pipeline::contracts::TranscriptSegment { + text: "close chart".to_string(), + start_ts: 0.0, + end_ts: 1.2, + }]; + let raw = RawTranscript { + text: "close chart and add plan".to_string(), + segments: segments.clone(), + ..Default::default() + }; + + let filtered = apply_silero_filter_outcome(&raw, "close chart".to_string(), segments, 0); + + assert_eq!(filtered.text, raw.text); + assert_eq!(filtered.segments, raw.segments); + } + + #[test] + fn decoder_control_tokens_are_only_suppressed_before_first_token() { + assert!(should_suppress_decoder_control_tokens(0)); + assert!(!should_suppress_decoder_control_tokens(1)); + assert!(!should_suppress_decoder_control_tokens(15)); + } + #[test] fn requested_final_pass_reports_embedded_lexicon_changes() { let raw = RawTranscript { From 8fdbec1504661f4764c01bb3489862d0fe69192e Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 21:34:52 -0700 Subject: [PATCH 06/17] fix(config): stop runtime env writes from settings paths - Confine config-owned process env seeding behind a bootstrap gate - Persist runtime settings updates to settings/json env stores without set_var - Read bridge settings snapshots from persisted stores instead of live env side effects - Update lifecycle tests for the single-writer env contract --- bridge/src/config.rs | 287 ++++++++++++++++++++++++------ core/config/loader.rs | 302 +++++++++++++++++++++----------- tests/e2e_settings_lifecycle.rs | 12 +- 3 files changed, 445 insertions(+), 156 deletions(-) diff --git a/bridge/src/config.rs b/bridge/src/config.rs index 3fd0e126..604651fa 100644 --- a/bridge/src/config.rs +++ b/bridge/src/config.rs @@ -7,11 +7,12 @@ //! Secrets NEVER cross the FFI boundary — only `CsKeyStatus` booleans report //! whether a key is present. +use std::collections::HashMap; use std::fs; use std::str::FromStr; use std::sync::{Mutex, OnceLock}; -use codescribe_core::config::keychain::{KEYCHAIN_ACCOUNTS, delete_key, save_key}; +use codescribe_core::config::keychain::{KEYCHAIN_ACCOUNTS, delete_key, load_key, save_key}; use codescribe_core::config::prompts::{get_assistive_prompt_path, get_formatting_prompt_path}; use codescribe_core::config::{ Config, DEFAULT_ASSISTIVE_PROMPT, DEFAULT_FORMATTING_PROMPT, UserSettings, reset_to_defaults, @@ -29,8 +30,8 @@ use crate::{CsError, CsLanguage}; /// Full settings snapshot pushed to the Swift Settings UI. Combines real /// `Config` struct fields (settings.json / .env / defaults already merged by -/// `Config::load()`) with env-only knobs the core reads via `std::env::var` -/// after load (LLM model/endpoint overrides, formatting level, voice-lab). +/// `Config::load()`) with env-only knobs read from persisted settings / .env +/// without relying on runtime process-env mutation. /// /// API keys are intentionally absent — they live only in `CsKeyStatus` as /// booleans. Write back through `update_config` / `update_config_many` using the @@ -122,8 +123,7 @@ pub struct CsSettings { /// Presence-only view of the Keychain-backed API keys. Booleans only — the /// secret values themselves never cross FFI. A key counts as "set" when its -/// account env var is present and non-empty after `Config::load()` (which calls -/// `populate_env_from_keychain`). +/// account env var or Keychain account is present and non-empty. #[derive(uniffi::Record)] pub struct CsKeyStatus { pub llm_api_key_set: bool, @@ -251,6 +251,8 @@ impl CodescribeConfig { /// reflects any writes made since construction. pub fn load_settings(&self) -> CsSettings { let config = Config::load(); + let settings = UserSettings::load(); + let env_file = load_config_env_file(); CsSettings { hold_exclusive: config.hold_exclusive, hold_start_delay_ms: config.hold_start_delay_ms, @@ -283,29 +285,91 @@ impl CodescribeConfig { use_local_stt: config.use_local_stt, local_model: config.local_model.clone(), stt_endpoint: config.stt_endpoint.clone(), - stt_engine: env_string("CODESCRIBE_STT_ENGINE"), + stt_engine: effective_env_string( + "CODESCRIBE_STT_ENGINE", + settings.stt_engine.clone(), + &env_file, + ), llm_endpoint: config.llm_endpoint.clone(), restore_clipboard: config.restore_clipboard, restore_clipboard_delay_ms: config.restore_clipboard_delay_ms, start_at_login: config.start_at_login, agent_enter_sends: config.agent_enter_sends, dump_audio_logs: config.dump_audio_logs, - // Env-only knobs (read after Config::load populated the env). - llm_model: env_string("LLM_MODEL"), - llm_formatting_endpoint: env_string("LLM_FORMATTING_ENDPOINT"), - llm_formatting_model: env_string("LLM_FORMATTING_MODEL"), - llm_assistive_endpoint: env_string("LLM_ASSISTIVE_ENDPOINT"), - llm_assistive_model: env_string("LLM_ASSISTIVE_MODEL"), - llm_assistive_provider: env_string("LLM_ASSISTIVE_PROVIDER"), - formatting_level: env_string("FORMATTING_LEVEL"), - whisper_model: env_string("WHISPER_MODEL"), - layered_transcription: env_string("CODESCRIBE_LAYERED_TRANSCRIPTION"), - agent_workspace_roots: env_list("AGENT_WORKSPACE_ROOTS", DEFAULT_AGENT_WORKSPACE_ROOT), - buffer_delay_ms: env_parse("CODESCRIBE_BUFFER_DELAY_MS"), - typing_cps: env_parse("CODESCRIBE_TYPING_CPS"), - emit_words_max: env_parse("CODESCRIBE_EMIT_WORDS_MAX"), - buffered_interim_sec: env_parse("CODESCRIBE_BUFFERED_INTERIM_SEC"), - backend_max_upload_mb: env_parse("BACKEND_MAX_UPLOAD_MB"), + // Env-only knobs: read the persisted stores first so a runtime UI + // write is visible without mutating the process environment. + llm_model: effective_settings_string( + "LLM_MODEL", + settings.llm_model.clone(), + &env_file, + ), + llm_formatting_endpoint: effective_settings_string( + "LLM_FORMATTING_ENDPOINT", + settings.llm_formatting_endpoint.clone(), + &env_file, + ), + llm_formatting_model: effective_settings_string( + "LLM_FORMATTING_MODEL", + settings.llm_formatting_model.clone(), + &env_file, + ), + llm_assistive_endpoint: effective_settings_string( + "LLM_ASSISTIVE_ENDPOINT", + settings.llm_assistive_endpoint.clone(), + &env_file, + ), + llm_assistive_model: effective_settings_string( + "LLM_ASSISTIVE_MODEL", + settings.llm_assistive_model.clone(), + &env_file, + ), + llm_assistive_provider: effective_file_env_string("LLM_ASSISTIVE_PROVIDER", &env_file), + formatting_level: effective_settings_string( + "FORMATTING_LEVEL", + settings.formatting_level.clone(), + &env_file, + ), + whisper_model: effective_settings_string( + "WHISPER_MODEL", + settings.whisper_model.clone(), + &env_file, + ), + layered_transcription: effective_env_string( + "CODESCRIBE_LAYERED_TRANSCRIPTION", + settings.layered_transcription.clone(), + &env_file, + ), + agent_workspace_roots: effective_env_list( + "AGENT_WORKSPACE_ROOTS", + settings.agent_workspace_roots.clone(), + &env_file, + DEFAULT_AGENT_WORKSPACE_ROOT, + ), + buffer_delay_ms: effective_settings_parse( + "CODESCRIBE_BUFFER_DELAY_MS", + settings.buffer_delay_ms, + &env_file, + ), + typing_cps: effective_settings_parse( + "CODESCRIBE_TYPING_CPS", + settings.typing_cps, + &env_file, + ), + emit_words_max: effective_settings_parse( + "CODESCRIBE_EMIT_WORDS_MAX", + settings.emit_words_max, + &env_file, + ), + buffered_interim_sec: effective_settings_parse( + "CODESCRIBE_BUFFERED_INTERIM_SEC", + settings.buffered_interim_sec, + &env_file, + ), + backend_max_upload_mb: effective_settings_parse( + "BACKEND_MAX_UPLOAD_MB", + settings.backend_max_upload_mb, + &env_file, + ), } } @@ -329,7 +393,8 @@ impl CodescribeConfig { /// Persist one config value, auto-tiered by the core router /// (`save_to_env`): API keys → Keychain, promoted keys → settings.json, - /// power-user keys → `.env`. Also updates the live process env. + /// power-user keys → `.env`. Runtime readers reload persisted snapshots + /// instead of mutating the process env. pub fn update_config(&self, key: String, value: String) -> Result<(), CsError> { Config::load() .save_to_env(&key, &value) @@ -526,31 +591,24 @@ impl CodescribeConfig { KEYCHAIN_ACCOUNTS.iter().map(|a| a.to_string()).collect() } - /// Store an API key in the Keychain and sync the live process env, exactly - /// like the core's `save_to_env` path. `account` must be a known + /// Store an API key in the Keychain. `account` must be a known /// `KEYCHAIN_ACCOUNTS` entry. The secret is never echoed back. pub fn set_api_key(&self, account: String, secret: String) -> Result<(), CsError> { ensure_known_account(&account)?; save_key(&account, &secret).map_err(|error| CsError::Config { msg: error.to_string(), })?; - // SAFETY: settings writes are serialized on a single Swift actor (W3 - // contract) and runtime readers consume refreshed Config snapshots, so - // this mirrors the core's `ui_thread_set_env` after `save_key`. - unsafe { std::env::set_var(&account, &secret) }; crate::hotkeys::refresh_live_controller_config(); Ok(()) } - /// Delete an API key from the Keychain and clear it from the live process - /// env. `account` must be a known `KEYCHAIN_ACCOUNTS` entry. + /// Delete an API key from the Keychain. `account` must be a known + /// `KEYCHAIN_ACCOUNTS` entry. pub fn clear_api_key(&self, account: String) -> Result<(), CsError> { ensure_known_account(&account)?; delete_key(&account).map_err(|error| CsError::Config { msg: error.to_string(), })?; - // SAFETY: same single-writer invariant as `set_api_key`. - unsafe { std::env::remove_var(&account) }; Ok(()) } @@ -653,8 +711,7 @@ impl CodescribeConfig { /// `CODESCRIBE_DATA_DIR` override is honored and no `~` is ever hardcoded. /// /// When `include_keys` is true, also removes every Keychain-backed API key - /// (`KEYCHAIN_ACCOUNTS`) and clears it from the live process env. Deleting the - /// data trees clears the `setup_done` sentinel, so the next launch replays the + /// (`KEYCHAIN_ACCOUNTS`). Deleting the data trees clears the `setup_done` sentinel, so the next launch replays the /// first-run wizard. TCC / permission grants are deliberately NOT touched — /// those are the user's to manage in System Settings. /// @@ -671,9 +728,6 @@ impl CodescribeConfig { delete_key(account).map_err(|error| CsError::Config { msg: format!("failed to remove keychain key {account}: {error}"), })?; - // SAFETY: same single-writer invariant as `set_api_key` / - // `clear_api_key` — settings writes are serialized on one Swift actor. - unsafe { std::env::remove_var(account) }; } } Ok(()) @@ -727,19 +781,98 @@ fn remove_dir_all_tolerant(dir: &std::path::Path) -> std::io::Result<()> { /// sync with the `list_projects` tool default (`app/agent/tools/workspace.rs`). const DEFAULT_AGENT_WORKSPACE_ROOT: &str = "~/Git"; +fn load_config_env_file() -> HashMap { + let path = Config::env_path(); + if path.exists() { + Config::parse_env_file(&path).unwrap_or_default() + } else { + HashMap::new() + } +} + +fn non_empty(value: String) -> Option { + let value = value.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +fn setting_string(value: Option) -> Option { + value.and_then(non_empty) +} + +fn file_env_string(key: &str, env_file: &HashMap) -> Option { + env_file.get(key).cloned().and_then(non_empty) +} + +/// Promoted settings are settings.json-owned; prefer that store over process +/// env so stale bootstrap-seeded env does not mask a fresh UI write. +fn effective_settings_string( + key: &str, + setting: Option, + env_file: &HashMap, +) -> Option { + setting_string(setting) + .or_else(|| file_env_string(key, env_file)) + .or_else(|| env_string(key)) +} + +/// Env-managed settings are persisted to .env when changed from the UI. Read +/// that file before process env so runtime writes are visible without set_var. +fn effective_env_string( + key: &str, + setting: Option, + env_file: &HashMap, +) -> Option { + file_env_string(key, env_file) + .or_else(|| setting_string(setting)) + .or_else(|| env_string(key)) +} + +fn effective_file_env_string(key: &str, env_file: &HashMap) -> Option { + file_env_string(key, env_file).or_else(|| env_string(key)) +} + +fn effective_settings_parse( + key: &str, + setting: Option, + env_file: &HashMap, +) -> Option +where + T: std::str::FromStr, +{ + setting + .or_else(|| file_env_string(key, env_file).and_then(|value| value.parse().ok())) + .or_else(|| env_parse(key)) +} + +fn parse_roots(value: &str) -> Vec { + value + .split(':') + .map(|segment| segment.trim().to_string()) + .filter(|segment| !segment.is_empty()) + .collect() +} + /// Colon-separated env var into a trimmed, non-empty `Vec`. Falls back to /// a single-element `[default]` when the var is unset/empty, so the Settings UI /// always renders the effective root the agent tool will scan. -fn env_list(key: &str, default: &str) -> Vec { - let roots: Vec = std::env::var(key) - .ok() - .map(|value| { - value - .split(':') - .map(|segment| segment.trim().to_string()) - .filter(|segment| !segment.is_empty()) - .collect() +fn effective_env_list( + key: &str, + setting: Option>, + env_file: &HashMap, + default: &str, +) -> Vec { + let roots = file_env_string(key, env_file) + .map(|value| parse_roots(&value)) + .or_else(|| { + setting.map(|roots| { + roots + .into_iter() + .map(|segment| segment.trim().to_string()) + .filter(|segment| !segment.is_empty()) + .collect() + }) }) + .or_else(|| std::env::var(key).ok().map(|value| parse_roots(&value))) .unwrap_or_default(); if roots.is_empty() { vec![default.to_string()] @@ -763,9 +896,9 @@ fn env_parse(key: &str) -> Option { .and_then(|value| value.trim().parse().ok()) } -/// True when the account env var is present and non-empty. +/// True when the account env var or Keychain account is present and non-empty. fn key_present(account: &str) -> bool { - env_string(account).is_some() + env_string(account).is_some() || load_key(account).and_then(non_empty).is_some() } fn account_auth_runtime() -> &'static tokio::runtime::Runtime { @@ -901,3 +1034,59 @@ mod reset_tests { } } } + +#[cfg(test)] +mod settings_snapshot_tests { + use super::CodescribeConfig; + use codescribe_core::config::UserSettings; + use serial_test::serial; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn scratch(tag: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "cs_settings_snapshot_{}_{tag}_{nanos}", + std::process::id() + )) + } + + #[test] + #[serial] + fn load_settings_prefers_persisted_model_over_stale_process_env() { + let root = scratch("llm_model"); + std::fs::create_dir_all(&root).unwrap(); + + let previous_data_dir = std::env::var("CODESCRIBE_DATA_DIR").ok(); + let previous_model = std::env::var("LLM_MODEL").ok(); + // SAFETY: serialized test body; no background workers are started. + unsafe { + std::env::set_var("CODESCRIBE_DATA_DIR", &root); + std::env::set_var("LLM_MODEL", "stale-bootstrap-model"); + } + + let settings = UserSettings { + llm_model: Some("fresh-runtime-model".to_string()), + ..Default::default() + }; + settings.save().unwrap(); + + let snapshot = CodescribeConfig::new().load_settings(); + assert_eq!(snapshot.llm_model.as_deref(), Some("fresh-runtime-model")); + + // SAFETY: restore prior env, same serialized single-thread context. + unsafe { + match previous_data_dir { + Some(value) => std::env::set_var("CODESCRIBE_DATA_DIR", value), + None => std::env::remove_var("CODESCRIBE_DATA_DIR"), + } + match previous_model { + Some(value) => std::env::set_var("LLM_MODEL", value), + None => std::env::remove_var("LLM_MODEL"), + } + } + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/core/config/loader.rs b/core/config/loader.rs index 12605838..c7c3865f 100644 --- a/core/config/loader.rs +++ b/core/config/loader.rs @@ -9,9 +9,12 @@ //! - explicit process env can still override for tests and developer runs. use directories::BaseDirs; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::env::VarError; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; use tracing::{info, warn}; use super::defaults::{ @@ -20,6 +23,10 @@ use super::defaults::{ }; use super::types::{Config, Language, OverlayPositionMode, TranscriptSendMode}; +static CONFIG_ENV_BOOTSTRAPPED: AtomicBool = AtomicBool::new(false); +static CONFIG_ENV_BOOTSTRAP_LOCK: OnceLock> = OnceLock::new(); +static CONFIG_SEEDED_ENV_KEYS: OnceLock>> = OnceLock::new(); + impl Config { /// Load configuration from disk or environment. /// @@ -44,6 +51,8 @@ impl Config { } fn load_with_keychain_population(populate_keychain: bool) -> Self { + let _bootstrap_guard = Self::config_env_bootstrap_guard(); + let seed_process_env = Self::can_seed_process_env(); let env_path = Self::env_path(); let mut file_env_vars: Option> = None; @@ -70,7 +79,7 @@ impl Config { } // Load API keys from Keychain (only if not already set by .env). - if populate_keychain { + if populate_keychain && seed_process_env { super::keychain::populate_env_from_keychain(); } @@ -86,9 +95,63 @@ impl Config { config.load_from_env(); config.apply_default_llm_runtime_env(); config.sanitize(); + Self::mark_process_env_bootstrapped(seed_process_env); config } + fn config_env_bootstrap_guard() -> Option> { + if cfg!(test) { + None + } else { + Some( + CONFIG_ENV_BOOTSTRAP_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("config env bootstrap lock poisoned"), + ) + } + } + + fn can_seed_process_env() -> bool { + cfg!(test) || !CONFIG_ENV_BOOTSTRAPPED.load(Ordering::SeqCst) + } + + fn mark_process_env_bootstrapped(seed_process_env: bool) { + if seed_process_env && !cfg!(test) { + CONFIG_ENV_BOOTSTRAPPED.store(true, Ordering::SeqCst); + } + } + + fn seeded_env_keys() -> &'static Mutex> { + CONFIG_SEEDED_ENV_KEYS.get_or_init(|| Mutex::new(HashSet::new())) + } + + fn remember_seeded_env_key(key: &str) { + if cfg!(test) { + return; + } + if let Ok(mut keys) = Self::seeded_env_keys().lock() { + keys.insert(key.to_string()); + } + } + + fn was_seeded_env_key(key: &str) -> bool { + if cfg!(test) { + return false; + } + Self::seeded_env_keys() + .lock() + .map(|keys| keys.contains(key)) + .unwrap_or(false) + } + + fn config_runtime_env_var(key: &str) -> Result { + if !Self::can_seed_process_env() && Self::was_seeded_env_key(key) { + return Err(VarError::NotPresent); + } + std::env::var(key) + } + /// Inject optional .env values into the process environment without allowing /// legacy file overrides to shadow promoted settings.json-backed keys. fn inject_file_env_for_runtime(file_env: &HashMap) { @@ -107,7 +170,7 @@ impl Config { } fn env_missing_or_empty(key: &str) -> bool { - std::env::var(key) + Self::config_runtime_env_var(key) .ok() .is_none_or(|value| value.trim().is_empty()) } @@ -147,127 +210,127 @@ impl Config { /// Load configuration values from environment variables. pub fn load_from_env(&mut self) { // Hotkeys - if let Ok(val) = std::env::var("HOLD_EXCLUSIVE") { + if let Ok(val) = Self::config_runtime_env_var("HOLD_EXCLUSIVE") { self.hold_exclusive = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("HOLD_START_DELAY_MS") + if let Ok(val) = Self::config_runtime_env_var("HOLD_START_DELAY_MS") && let Ok(ms) = val.parse() { self.hold_start_delay_ms = ms; } - if let Ok(val) = std::env::var("DOUBLE_TAP_INTERVAL_MS") + if let Ok(val) = Self::config_runtime_env_var("DOUBLE_TAP_INTERVAL_MS") && let Ok(ms) = val.parse() { self.double_tap_interval_ms = ms; } - if let Ok(val) = std::env::var("TOGGLE_SILENCE_SEC") + if let Ok(val) = Self::config_runtime_env_var("TOGGLE_SILENCE_SEC") && let Ok(sec) = val.parse() { self.toggle_silence_sec = sec; } // Language - if let Ok(val) = std::env::var("WHISPER_LANGUAGE") + if let Ok(val) = Self::config_runtime_env_var("WHISPER_LANGUAGE") && let Ok(lang) = val.parse::() { self.whisper_language = lang; } // AI Formatting - if let Ok(val) = std::env::var("AI_FORMATTING_ENABLED") { + if let Ok(val) = Self::config_runtime_env_var("AI_FORMATTING_ENABLED") { self.ai_formatting_enabled = matches!(val.as_str(), "1" | "true" | "yes" | "on" | "enabled"); } - if let Ok(val) = std::env::var("TRANSCRIPT_SEND_MODE") + if let Ok(val) = Self::config_runtime_env_var("TRANSCRIPT_SEND_MODE") && let Ok(mode) = val.parse::() { self.transcript_send_mode = mode; } - if let Ok(val) = std::env::var("CODESCRIBE_TRANSCRIPT_TAGGING") { + if let Ok(val) = Self::config_runtime_env_var("CODESCRIBE_TRANSCRIPT_TAGGING") { self.transcript_tagging_enabled = matches!(val.as_str(), "1" | "true" | "yes" | "on" | "enabled"); } - if let Ok(val) = std::env::var("CODESCRIBE_TRANSCRIPT_TAG_TEMPLATE") { + if let Ok(val) = Self::config_runtime_env_var("CODESCRIBE_TRANSCRIPT_TAG_TEMPLATE") { self.transcript_tag_template = val; } - if let Ok(val) = std::env::var("AI_MAX_TOKENS") + if let Ok(val) = Self::config_runtime_env_var("AI_MAX_TOKENS") && let Ok(tokens) = val.parse() { self.ai_max_tokens = tokens; } - if let Ok(val) = std::env::var("AI_ASSISTIVE_MAX_TOKENS") + if let Ok(val) = Self::config_runtime_env_var("AI_ASSISTIVE_MAX_TOKENS") && let Ok(tokens) = val.parse() { self.ai_assistive_max_tokens = tokens; } // UI - if let Ok(val) = std::env::var("SHOW_TRAY_GLYPH") { + if let Ok(val) = Self::config_runtime_env_var("SHOW_TRAY_GLYPH") { self.show_tray_glyph = val.parse().unwrap_or(true); } - if let Ok(val) = std::env::var("SHOW_DOCK_ICON") { + if let Ok(val) = Self::config_runtime_env_var("SHOW_DOCK_ICON") { self.show_dock_icon = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("TRANSCRIPTION_OVERLAY_ENABLED") { + if let Ok(val) = Self::config_runtime_env_var("TRANSCRIPTION_OVERLAY_ENABLED") { self.transcription_overlay_enabled = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("TRAY_START_ASSISTIVE") { + if let Ok(val) = Self::config_runtime_env_var("TRAY_START_ASSISTIVE") { self.tray_start_assistive = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("HOLD_INDICATOR") { + if let Ok(val) = Self::config_runtime_env_var("HOLD_INDICATOR") { self.hold_indicator = val.parse().unwrap_or(true); } - if let Ok(val) = std::env::var("HOLD_BADGE_SIZE") + if let Ok(val) = Self::config_runtime_env_var("HOLD_BADGE_SIZE") && let Ok(size) = val.parse() { self.hold_badge_size = size; } - if let Ok(val) = std::env::var("HOLD_BADGE_OFFSET_X") + if let Ok(val) = Self::config_runtime_env_var("HOLD_BADGE_OFFSET_X") && let Ok(offset) = val.parse() { self.hold_badge_offset_x = offset; } - if let Ok(val) = std::env::var("HOLD_BADGE_OFFSET_Y") + if let Ok(val) = Self::config_runtime_env_var("HOLD_BADGE_OFFSET_Y") && let Ok(offset) = val.parse() { self.hold_badge_offset_y = offset; } - if let Ok(val) = std::env::var("OVERLAY_POSITION_MODE") + if let Ok(val) = Self::config_runtime_env_var("OVERLAY_POSITION_MODE") && let Ok(mode) = val.parse::() { self.overlay_position_mode = mode; } - if let Ok(val) = std::env::var("OVERLAY_CUSTOM_X") + if let Ok(val) = Self::config_runtime_env_var("OVERLAY_CUSTOM_X") && let Ok(x) = val.parse() { self.overlay_custom_x = Some(x); } - if let Ok(val) = std::env::var("OVERLAY_CUSTOM_Y") + if let Ok(val) = Self::config_runtime_env_var("OVERLAY_CUSTOM_Y") && let Ok(y) = val.parse() { self.overlay_custom_y = Some(y); } // Sound - if let Ok(val) = std::env::var("BEEP_ON_START") { + if let Ok(val) = Self::config_runtime_env_var("BEEP_ON_START") { self.beep_on_start = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("AGENT_ENTER_SENDS") { + if let Ok(val) = Self::config_runtime_env_var("AGENT_ENTER_SENDS") { self.agent_enter_sends = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("SOUND_NAME") { + if let Ok(val) = Self::config_runtime_env_var("SOUND_NAME") { self.sound_name = val; } - if let Ok(val) = std::env::var("SOUND_VOLUME") + if let Ok(val) = Self::config_runtime_env_var("SOUND_VOLUME") && let Ok(volume) = val.parse() { self.sound_volume = volume; } // Audio - if let Ok(val) = std::env::var("AUDIO_INPUT_DEVICE") { + if let Ok(val) = Self::config_runtime_env_var("AUDIO_INPUT_DEVICE") { self.audio_input_device = (!val.trim().is_empty()).then_some(val); } // VAD config lives in `core/vad/config.rs` with hardcoded defaults and @@ -276,65 +339,65 @@ impl Config { // No legacy SILENCE_* variables - single source of truth. // History (default: on to avoid data loss) - if let Ok(val) = std::env::var("HISTORY_ENABLED") { + if let Ok(val) = Self::config_runtime_env_var("HISTORY_ENABLED") { self.history_enabled = val.parse().unwrap_or(true); } // Quick Notes (default: off) - if let Ok(val) = std::env::var("QUICK_NOTES_ENABLED") { + if let Ok(val) = Self::config_runtime_env_var("QUICK_NOTES_ENABLED") { self.quick_notes_enabled = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("QUICK_NOTES_SAVE_ONLY") { + if let Ok(val) = Self::config_runtime_env_var("QUICK_NOTES_SAVE_ONLY") { self.quick_notes_save_only = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } // Backends - LLM // LLM_API_KEY for cloud providers - if let Ok(val) = std::env::var("LLM_API_KEY") { + if let Ok(val) = Self::config_runtime_env_var("LLM_API_KEY") { self.llm_api_key = Some(val); } - if let Ok(val) = std::env::var("LLM_ENDPOINT") { + if let Ok(val) = Self::config_runtime_env_var("LLM_ENDPOINT") { self.llm_endpoint = Some(val); } // Backends - STT - if let Ok(val) = std::env::var("STT_ENDPOINT") { + if let Ok(val) = Self::config_runtime_env_var("STT_ENDPOINT") { self.stt_endpoint = Some(val); } - if let Ok(val) = std::env::var("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED") { + if let Ok(val) = Self::config_runtime_env_var("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED") { self.stt_initial_prompt_enabled = matches!(val.as_str(), "1" | "true" | "yes" | "on" | "enabled"); } // STT_API_KEY for cloud STT - if let Ok(val) = std::env::var("STT_API_KEY") { + if let Ok(val) = Self::config_runtime_env_var("STT_API_KEY") { self.stt_api_key = Some(val); } // Local STT (Pure Rust Whisper) - if let Ok(val) = std::env::var("USE_LOCAL_STT") { + if let Ok(val) = Self::config_runtime_env_var("USE_LOCAL_STT") { self.use_local_stt = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("LOCAL_MODEL") { + if let Ok(val) = Self::config_runtime_env_var("LOCAL_MODEL") { self.local_model = val; } // Clipboard - if let Ok(val) = std::env::var("RESTORE_CLIPBOARD") { + if let Ok(val) = Self::config_runtime_env_var("RESTORE_CLIPBOARD") { self.restore_clipboard = val.parse().unwrap_or(true); } - if let Ok(val) = std::env::var("RESTORE_CLIPBOARD_DELAY_MS") + if let Ok(val) = Self::config_runtime_env_var("RESTORE_CLIPBOARD_DELAY_MS") && let Ok(delay) = val.parse() { self.restore_clipboard_delay_ms = delay; } // System - if let Ok(val) = std::env::var("START_AT_LOGIN") { + if let Ok(val) = Self::config_runtime_env_var("START_AT_LOGIN") { self.start_at_login = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } // Debugging (default: on to keep paired .wav with transcripts) - if let Ok(val) = std::env::var("DUMP_AUDIO_LOGS") { + if let Ok(val) = Self::config_runtime_env_var("DUMP_AUDIO_LOGS") { self.dump_audio_logs = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } } @@ -353,15 +416,13 @@ impl Config { } fn config_init_set_env(key: &str, value: impl AsRef) { - // SAFETY: config init happens before background workers consume configuration, - // so process-env mutation is confined to a single writer during bootstrap. + if !Self::can_seed_process_env() { + return; + } + // SAFETY: a process-wide bootstrap lock confines config env mutation to + // the one pre-runtime writer; later loads read settings snapshots instead. unsafe { std::env::set_var(key, value.as_ref()) }; - } - - fn ui_thread_set_env(key: &str, value: &str) { - // SAFETY: settings writes originate from the main UI thread; runtime readers - // consume refreshed Config snapshots rather than racing direct env access. - unsafe { std::env::set_var(key, value) }; + Self::remember_seeded_env_key(key); } /// Apply user settings from JSON (lower priority than .env). @@ -370,7 +431,7 @@ impl Config { // Helper: only apply if the env var is NOT set macro_rules! apply_parsed_if_no_env { ($env_key:expr, $field:expr, $val:expr) => { - if std::env::var($env_key).is_err() { + if Self::config_runtime_env_var($env_key).is_err() { if let Some(ref v) = $val { if let Ok(parsed) = v.parse() { $field = parsed; @@ -387,66 +448,66 @@ impl Config { settings.whisper_language ); // Hotkeys - if std::env::var("HOLD_START_DELAY_MS").is_err() + if Self::config_runtime_env_var("HOLD_START_DELAY_MS").is_err() && let Some(v) = settings.hold_start_delay_ms { self.hold_start_delay_ms = v; } - if std::env::var("DOUBLE_TAP_INTERVAL_MS").is_err() + if Self::config_runtime_env_var("DOUBLE_TAP_INTERVAL_MS").is_err() && let Some(v) = settings.double_tap_interval_ms { self.double_tap_interval_ms = v; } - if std::env::var("TOGGLE_SILENCE_SEC").is_err() + if Self::config_runtime_env_var("TOGGLE_SILENCE_SEC").is_err() && let Some(v) = settings.toggle_silence_sec { self.toggle_silence_sec = v; } - if std::env::var("HOLD_EXCLUSIVE").is_err() + if Self::config_runtime_env_var("HOLD_EXCLUSIVE").is_err() && let Some(v) = settings.hold_exclusive { self.hold_exclusive = v; } // AI - if std::env::var("AI_FORMATTING_ENABLED").is_err() + if Self::config_runtime_env_var("AI_FORMATTING_ENABLED").is_err() && let Some(v) = settings.ai_formatting_enabled { self.ai_formatting_enabled = v; } - if std::env::var("CODESCRIBE_TRANSCRIPT_TAGGING").is_err() + if Self::config_runtime_env_var("CODESCRIBE_TRANSCRIPT_TAGGING").is_err() && let Some(v) = settings.transcript_tagging_enabled { self.transcript_tagging_enabled = v; } - if std::env::var("CODESCRIBE_TRANSCRIPT_TAG_TEMPLATE").is_err() + if Self::config_runtime_env_var("CODESCRIBE_TRANSCRIPT_TAG_TEMPLATE").is_err() && let Some(ref v) = settings.transcript_tag_template { self.transcript_tag_template = v.clone(); } - if std::env::var("FORMATTING_LEVEL").is_err() + if Self::config_runtime_env_var("FORMATTING_LEVEL").is_err() && let Some(ref v) = settings.formatting_level { // FORMATTING_LEVEL is read from env at runtime (not a Config field). Self::safe_set_env("FORMATTING_LEVEL", v); } // Sound - if std::env::var("BEEP_ON_START").is_err() + if Self::config_runtime_env_var("BEEP_ON_START").is_err() && let Some(v) = settings.beep_on_start { self.beep_on_start = v; } - if std::env::var("SHOW_DOCK_ICON").is_err() + if Self::config_runtime_env_var("SHOW_DOCK_ICON").is_err() && let Some(v) = settings.show_dock_icon { self.show_dock_icon = v; } - if std::env::var("TRANSCRIPTION_OVERLAY_ENABLED").is_err() + if Self::config_runtime_env_var("TRANSCRIPTION_OVERLAY_ENABLED").is_err() && let Some(v) = settings.transcription_overlay_enabled { self.transcription_overlay_enabled = v; Self::safe_set_env("TRANSCRIPTION_OVERLAY_ENABLED", if v { "1" } else { "0" }); } - if std::env::var("TRAY_START_ASSISTIVE").is_err() + if Self::config_runtime_env_var("TRAY_START_ASSISTIVE").is_err() && let Some(v) = settings.tray_start_assistive { // `tray_start_assistive` is a Config struct field; downstream reads it @@ -456,18 +517,18 @@ impl Config { // background threads. self.tray_start_assistive = v; } - if std::env::var("SOUND_VOLUME").is_err() + if Self::config_runtime_env_var("SOUND_VOLUME").is_err() && let Some(v) = settings.sound_volume { self.sound_volume = v; } // LLM endpoints (from JSON, lower priority than .env) - if std::env::var("LLM_ENDPOINT").is_err() + if Self::config_runtime_env_var("LLM_ENDPOINT").is_err() && let Some(ref v) = settings.llm_endpoint { self.llm_endpoint = Some(v.clone()); } - if std::env::var("LLM_MODEL").is_err() + if Self::config_runtime_env_var("LLM_MODEL").is_err() && let Some(ref v) = settings.llm_model { // LLM_MODEL is not in Config struct but read from env at runtime @@ -475,12 +536,12 @@ impl Config { Self::safe_set_env("LLM_MODEL", v); } // Assistive LLM (not in Config struct, read from env at runtime) - if std::env::var("LLM_ASSISTIVE_ENDPOINT").is_err() + if Self::config_runtime_env_var("LLM_ASSISTIVE_ENDPOINT").is_err() && let Some(ref v) = settings.llm_assistive_endpoint { Self::safe_set_env("LLM_ASSISTIVE_ENDPOINT", v); } - if std::env::var("LLM_ASSISTIVE_MODEL").is_err() + if Self::config_runtime_env_var("LLM_ASSISTIVE_MODEL").is_err() && let Some(ref v) = settings.llm_assistive_model { Self::safe_set_env("LLM_ASSISTIVE_MODEL", v); @@ -488,32 +549,32 @@ impl Config { // ── Promoted fields (previously .env only) ── // LLM formatting (not in Config struct, read from env at runtime) - if std::env::var("LLM_FORMATTING_ENDPOINT").is_err() + if Self::config_runtime_env_var("LLM_FORMATTING_ENDPOINT").is_err() && let Some(ref v) = settings.llm_formatting_endpoint { Self::safe_set_env("LLM_FORMATTING_ENDPOINT", v); } - if std::env::var("LLM_FORMATTING_MODEL").is_err() + if Self::config_runtime_env_var("LLM_FORMATTING_MODEL").is_err() && let Some(ref v) = settings.llm_formatting_model { Self::safe_set_env("LLM_FORMATTING_MODEL", v); } // Local STT - if std::env::var("USE_LOCAL_STT").is_err() + if Self::config_runtime_env_var("USE_LOCAL_STT").is_err() && let Some(v) = settings.use_local_stt { self.use_local_stt = v; Self::config_init_set_env("USE_LOCAL_STT", if v { "1" } else { "0" }); } - if std::env::var("LOCAL_MODEL").is_err() + if Self::config_runtime_env_var("LOCAL_MODEL").is_err() && let Some(ref v) = settings.local_model { self.local_model = v.clone(); } // STT endpoint - if std::env::var("STT_ENDPOINT").is_err() + if Self::config_runtime_env_var("STT_ENDPOINT").is_err() && let Some(ref v) = settings.stt_endpoint { self.stt_endpoint = Some(v.clone()); @@ -527,82 +588,82 @@ impl Config { ); // Audio input device - if std::env::var("AUDIO_INPUT_DEVICE").is_err() + if Self::config_runtime_env_var("AUDIO_INPUT_DEVICE").is_err() && let Some(ref v) = settings.audio_input_device { self.audio_input_device = Some(v.clone()); } // Sound name - if std::env::var("SOUND_NAME").is_err() + if Self::config_runtime_env_var("SOUND_NAME").is_err() && let Some(ref v) = settings.sound_name { self.sound_name = v.clone(); } // History - if std::env::var("HISTORY_ENABLED").is_err() + if Self::config_runtime_env_var("HISTORY_ENABLED").is_err() && let Some(v) = settings.history_enabled { self.history_enabled = v; } // Quick Notes - if std::env::var("QUICK_NOTES_ENABLED").is_err() + if Self::config_runtime_env_var("QUICK_NOTES_ENABLED").is_err() && let Some(v) = settings.quick_notes_enabled { self.quick_notes_enabled = v; } - if std::env::var("QUICK_NOTES_SAVE_ONLY").is_err() + if Self::config_runtime_env_var("QUICK_NOTES_SAVE_ONLY").is_err() && let Some(v) = settings.quick_notes_save_only { self.quick_notes_save_only = v; } // System - if std::env::var("START_AT_LOGIN").is_err() + if Self::config_runtime_env_var("START_AT_LOGIN").is_err() && let Some(v) = settings.start_at_login { self.start_at_login = v; } - if std::env::var("QUBE_DAEMON_AUTOSTART").is_err() + if Self::config_runtime_env_var("QUBE_DAEMON_AUTOSTART").is_err() && let Some(v) = settings.qube_daemon_autostart { Self::config_init_set_env("QUBE_DAEMON_AUTOSTART", if v { "1" } else { "0" }); } - if std::env::var("AGENT_ENTER_SENDS").is_err() + if Self::config_runtime_env_var("AGENT_ENTER_SENDS").is_err() && let Some(v) = settings.agent_enter_sends { self.agent_enter_sends = v; } // ── Voice Lab survivors (runtime env vars, not Config struct fields) ── - if std::env::var("CODESCRIBE_BUFFER_DELAY_MS").is_err() + if Self::config_runtime_env_var("CODESCRIBE_BUFFER_DELAY_MS").is_err() && let Some(v) = settings.buffer_delay_ms { Self::config_init_set_env("CODESCRIBE_BUFFER_DELAY_MS", v.to_string()); } - if std::env::var("CODESCRIBE_TYPING_CPS").is_err() + if Self::config_runtime_env_var("CODESCRIBE_TYPING_CPS").is_err() && let Some(v) = settings.typing_cps { Self::config_init_set_env("CODESCRIBE_TYPING_CPS", v.to_string()); } - if std::env::var("CODESCRIBE_EMIT_WORDS_MAX").is_err() + if Self::config_runtime_env_var("CODESCRIBE_EMIT_WORDS_MAX").is_err() && let Some(v) = settings.emit_words_max { Self::config_init_set_env("CODESCRIBE_EMIT_WORDS_MAX", v.to_string()); } - if std::env::var("CODESCRIBE_BUFFERED_INTERIM_SEC").is_err() + if Self::config_runtime_env_var("CODESCRIBE_BUFFERED_INTERIM_SEC").is_err() && let Some(v) = settings.buffered_interim_sec { Self::config_init_set_env("CODESCRIBE_BUFFERED_INTERIM_SEC", format!("{v:.1}")); } - if std::env::var("WHISPER_MODEL").is_err() + if Self::config_runtime_env_var("WHISPER_MODEL").is_err() && let Some(ref v) = settings.whisper_model { Self::safe_set_env("WHISPER_MODEL", v); } - if std::env::var("BACKEND_MAX_UPLOAD_MB").is_err() + if Self::config_runtime_env_var("BACKEND_MAX_UPLOAD_MB").is_err() && let Some(v) = settings.backend_max_upload_mb { Self::config_init_set_env("BACKEND_MAX_UPLOAD_MB", v.to_string()); @@ -611,17 +672,17 @@ impl Config { // ── STT engine / layered transcription (F1) ── // Explicit process env wins; settings.json seeds the env-only knobs that // core/stt reads per-call (selected_engine / layered_phase). - if std::env::var("CODESCRIBE_STT_ENGINE").is_err() + if Self::config_runtime_env_var("CODESCRIBE_STT_ENGINE").is_err() && let Some(ref v) = settings.stt_engine { Self::safe_set_env("CODESCRIBE_STT_ENGINE", v); } - if std::env::var("CODESCRIBE_LAYERED_TRANSCRIPTION").is_err() + if Self::config_runtime_env_var("CODESCRIBE_LAYERED_TRANSCRIPTION").is_err() && let Some(ref v) = settings.layered_transcription { Self::safe_set_env("CODESCRIBE_LAYERED_TRANSCRIPTION", v); } - if std::env::var("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED").is_err() + if Self::config_runtime_env_var("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED").is_err() && let Some(v) = settings.stt_initial_prompt_enabled { self.stt_initial_prompt_enabled = v; @@ -634,7 +695,7 @@ impl Config { // ── Agent workspace roots ── // Colon-joined (PATH-style); the `list_projects` tool reads and splits it. // Explicit process env / .env wins; settings.json only seeds when absent. - if std::env::var("AGENT_WORKSPACE_ROOTS").is_err() + if Self::config_runtime_env_var("AGENT_WORKSPACE_ROOTS").is_err() && let Some(ref roots) = settings.agent_workspace_roots && !roots.is_empty() { @@ -646,12 +707,13 @@ impl Config { /// - API keys → Keychain /// - Regular-user fields → settings.json /// - Everything else → .env + /// + /// This is a persistence write only. Process-env seeding is restricted to + /// bootstrap loads; live readers must reload the config/settings snapshot. pub fn save_to_env(&self, key: &str, value: &str) -> anyhow::Result<()> { // API keys → Keychain if super::keychain::KEYCHAIN_ACCOUNTS.contains(&key) { super::keychain::save_key(key, value)?; - // Also update runtime env var. - Self::ui_thread_set_env(key, value); return Ok(()); } @@ -701,8 +763,6 @@ impl Config { settings.set_string(key, value); } } - // Also update runtime env var. - Self::ui_thread_set_env(key, value); return Ok(()); } @@ -718,7 +778,6 @@ impl Config { }; env_vars.insert(key.to_string(), value.to_string()); Self::write_env_file(&env_path, &env_vars)?; - Self::ui_thread_set_env(key, value); Ok(()) } @@ -739,7 +798,6 @@ impl Config { // API keys → Keychain if super::keychain::KEYCHAIN_ACCOUNTS.contains(key) { super::keychain::save_key(key, value)?; - Self::ui_thread_set_env(key, value); continue; } @@ -877,7 +935,6 @@ impl Config { } _ => {} } - Self::ui_thread_set_env(key, value); continue; } @@ -891,7 +948,6 @@ impl Config { } }); vars_ref.insert((*key).to_string(), (*value).to_string()); - Self::ui_thread_set_env(key, value); } if let Some(settings) = settings @@ -1195,6 +1251,50 @@ mod tests { tmp } + #[test] + #[serial] + fn save_to_env_persists_promoted_setting_without_process_env_mutation() { + let _tmp = setup_isolated_data_dir(); + let _model = TestEnvGuard::unset("LLM_MODEL"); + + Config::default() + .save_to_env("LLM_MODEL", "runtime-model") + .expect("save setting"); + + assert!(std::env::var("LLM_MODEL").is_err()); + assert_eq!( + UserSettings::load().llm_model.as_deref(), + Some("runtime-model") + ); + } + + #[test] + #[serial] + fn save_to_env_many_persists_batch_without_process_env_mutation() { + let _tmp = setup_isolated_data_dir(); + let _model = TestEnvGuard::unset("LLM_MODEL"); + let _workspace_roots = TestEnvGuard::unset("AGENT_WORKSPACE_ROOTS"); + + Config::default() + .save_to_env_many(&[ + ("LLM_MODEL", "batch-model"), + ("AGENT_WORKSPACE_ROOTS", "/tmp/a:/tmp/b"), + ]) + .expect("save settings batch"); + + assert!(std::env::var("LLM_MODEL").is_err()); + assert!(std::env::var("AGENT_WORKSPACE_ROOTS").is_err()); + assert_eq!( + UserSettings::load().llm_model.as_deref(), + Some("batch-model") + ); + let env_vars = Config::parse_env_file(&Config::env_path()).expect("parse .env"); + assert_eq!( + env_vars.get("AGENT_WORKSPACE_ROOTS").map(String::as_str), + Some("/tmp/a:/tmp/b") + ); + } + #[test] #[serial] fn load_injects_openai_responses_defaults_without_api_key() { diff --git a/tests/e2e_settings_lifecycle.rs b/tests/e2e_settings_lifecycle.rs index 607b5acd..2b8d02f1 100644 --- a/tests/e2e_settings_lifecycle.rs +++ b/tests/e2e_settings_lifecycle.rs @@ -270,10 +270,9 @@ fn test_settings_typing_cps_decimal_persistence() { .save_to_env("CODESCRIBE_TYPING_CPS", "36.5") .expect("save typing cps"); - assert_eq!( - std::env::var("CODESCRIBE_TYPING_CPS").expect("CODESCRIBE_TYPING_CPS env"), - "36.5", - "runtime env should preserve decimal value" + assert!( + std::env::var("CODESCRIBE_TYPING_CPS").is_err(), + "runtime writes must not mutate process env" ); let settings = UserSettings::load(); @@ -350,8 +349,9 @@ fn test_settings_full_round_trip() { assert_eq!(r.whisper_language.as_str(), "en"); assert!(!r.ai_formatting_enabled); assert_eq!( - std::env::var("CODESCRIBE_TYPING_CPS").expect("CODESCRIBE_TYPING_CPS env"), - "90" + settings.typing_cps, + Some(90.0), + "typing cps should round-trip through settings.json, not live env" ); } From e026dbe66c1a0e99b4d3b781d7dcfdeb8f4a9915 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 21:38:45 -0700 Subject: [PATCH 07/17] fix(stt): prioritize commit lane under critical thermal - Run Commit requests before Live requests at Critical thermal level. - Keep Live requests throttled at Critical while preserving coalescing behavior. - Deregister scheduler senders on shutdown and drop to prevent registry growth. - Add scheduler tests for Critical thermal priority and registry cleanup. --- core/stt/scheduler.rs | 100 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 14 deletions(-) diff --git a/core/stt/scheduler.rs b/core/stt/scheduler.rs index 3b0fde82..ce96ae0a 100644 --- a/core/stt/scheduler.rs +++ b/core/stt/scheduler.rs @@ -64,8 +64,8 @@ impl ThermalLevel { } static PROCESS_THERMAL_LEVEL: AtomicU64 = AtomicU64::new(0); -static SCHEDULER_REGISTRY: OnceLock>>> = - OnceLock::new(); +static NEXT_SCHEDULER_ID: AtomicU64 = AtomicU64::new(0); +static SCHEDULER_REGISTRY: OnceLock>> = OnceLock::new(); pub fn current_process_thermal_level() -> ThermalLevel { ThermalLevel::from_u8(PROCESS_THERMAL_LEVEL.load(Ordering::Relaxed) as u8) @@ -74,11 +74,21 @@ pub fn current_process_thermal_level() -> ThermalLevel { pub fn set_process_thermal_level(level: ThermalLevel) { PROCESS_THERMAL_LEVEL.store(u64::from(level.as_u8()), Ordering::Relaxed); if let Some(registry) = SCHEDULER_REGISTRY.get() { - let mut senders = registry.lock().unwrap_or_else(|e| e.into_inner()); - senders.retain(|tx| tx.send(SchedulerCommand::SetThermalLevel(level)).is_ok()); + let mut registrations = registry.lock().unwrap_or_else(|e| e.into_inner()); + registrations.retain(|registration| { + registration + .tx + .send(SchedulerCommand::SetThermalLevel(level)) + .is_ok() + }); } } +struct SchedulerRegistration { + id: u64, + tx: mpsc::UnboundedSender, +} + #[derive(Debug)] pub(crate) struct SttTaskHandle { id: u64, @@ -108,6 +118,7 @@ impl SttTaskHandle { } pub(crate) struct SttScheduler { + registry_id: u64, command_tx: mpsc::UnboundedSender, worker_handle: Option>, next_request_id: AtomicU64, @@ -187,7 +198,7 @@ impl SttScheduler { pub(crate) async fn shutdown(mut self) -> Result<()> { let (ack_tx, ack_rx) = oneshot::channel(); let _ = self.command_tx.send(SchedulerCommand::Shutdown { ack_tx }); - drop(self.command_tx); + deregister_scheduler_sender(self.registry_id); let _ = ack_rx.await; @@ -243,11 +254,15 @@ impl SttScheduler { governor: SttGovernorConfig, ) -> Self { let (command_tx, command_rx) = mpsc::unbounded_channel(); + let registry_id = NEXT_SCHEDULER_ID.fetch_add(1, Ordering::Relaxed) + 1; SCHEDULER_REGISTRY .get_or_init(|| StdMutex::new(Vec::new())) .lock() .unwrap_or_else(|e| e.into_inner()) - .push(command_tx.clone()); + .push(SchedulerRegistration { + id: registry_id, + tx: command_tx.clone(), + }); let worker_handle = tokio::spawn(scheduler_worker( command_rx, infer_fn, @@ -255,6 +270,7 @@ impl SttScheduler { governor, )); Self { + registry_id, command_tx, worker_handle: Some(worker_handle), next_request_id: AtomicU64::new(0), @@ -262,6 +278,19 @@ impl SttScheduler { } } +impl Drop for SttScheduler { + fn drop(&mut self) { + deregister_scheduler_sender(self.registry_id); + } +} + +fn deregister_scheduler_sender(registry_id: u64) { + if let Some(registry) = SCHEDULER_REGISTRY.get() { + let mut registrations = registry.lock().unwrap_or_else(|e| e.into_inner()); + registrations.retain(|registration| registration.id != registry_id); + } +} + fn default_infer( samples: Vec, sample_rate: u32, @@ -655,12 +684,13 @@ fn pop_next_request( refine_pending: &mut Option, thermal_level: ThermalLevel, ) -> Option { + if thermal_level == ThermalLevel::Critical { + return commit_queue.pop_front(); + } if let Some(req) = live_queue.pop_front() { return Some(req); } - if thermal_level != ThermalLevel::Critical - && let Some(req) = commit_queue.pop_front() - { + if let Some(req) = commit_queue.pop_front() { return Some(req); } if matches!(thermal_level, ThermalLevel::Nominal | ThermalLevel::Fair) { @@ -760,6 +790,19 @@ mod tests { samples.to_vec() } + fn scheduler_registry_contains(registry_id: u64) -> bool { + SCHEDULER_REGISTRY + .get() + .map(|registry| { + registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .any(|registration| registration.id == registry_id) + }) + .unwrap_or(false) + } + fn assert_duration_near(actual: Duration, expected: Duration) { let diff = actual.abs_diff(expected); assert!( @@ -1219,7 +1262,7 @@ mod tests { } #[tokio::test] - async fn scheduler_critical_thermal_runs_only_latest_live() { + async fn scheduler_critical_thermal_runs_commit_and_throttles_live() { let started = Arc::new(StdMutex::new(Vec::::new())); let started_ref = Arc::clone(&started); let infer = Arc::new( @@ -1265,14 +1308,14 @@ mod tests { old_err.to_string().contains("superseded"), "unexpected old live error: {old_err}" ); - assert_eq!(new_live.recv().await.expect("new live ok").text, "job-11"); + assert_eq!(commit.recv().await.expect("commit ok").text, "job-2"); let shutdown_task = tokio::spawn(async move { scheduler.shutdown().await }); assert!( - commit + new_live .recv() .await - .expect_err("commit should remain paused until shutdown") + .expect_err("live should remain throttled until shutdown") .to_string() .contains(SHUTDOWN_ERR) ); @@ -1291,10 +1334,39 @@ mod tests { assert_eq!( started.lock().unwrap_or_else(|e| e.into_inner()).clone(), - vec![11] + vec![2] ); } + #[tokio::test] + async fn scheduler_registry_deregisters_senders_on_drop() { + let mut dropped_ids = Vec::new(); + + for _ in 0..5 { + let scheduler = SttScheduler::with_infer_fn(Arc::new( + |_samples, _sample_rate, _language, _initial_prompt| Ok(RawTranscript::default()), + )); + let registry_id = scheduler.registry_id; + assert!( + scheduler_registry_contains(registry_id), + "scheduler sender should be registered after creation" + ); + drop(scheduler); + assert!( + !scheduler_registry_contains(registry_id), + "scheduler sender should be deregistered on drop" + ); + dropped_ids.push(registry_id); + } + + let leaked = dropped_ids + .iter() + .filter(|id| scheduler_registry_contains(**id)) + .count(); + assert_eq!(dropped_ids.len(), 5, "expected create/drop proof count"); + assert_eq!(leaked, 0, "dropped scheduler senders leaked in registry"); + } + #[tokio::test] async fn scheduler_commit_returns_empty_when_prefilter_finds_no_speech() { let started = Arc::new(StdMutex::new(Vec::>::new())); From 9bb633820da57ee712333d64c5238995f48deaa9 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 21:52:52 -0700 Subject: [PATCH 08/17] fix(config): keep env migration retryable on key save failure Save migrated Keychain secrets before writing the settings.json completion sentinel.\n\n- Return early on migrated secret persistence failure so startup retries migration\n- Add a test-only save_key failure injector without changing the env registry\n- Cover retry success and one-time completion behavior for migrated API keys --- core/config/migrate.rs | 133 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 124 insertions(+), 9 deletions(-) diff --git a/core/config/migrate.rs b/core/config/migrate.rs index c2fac161..b88706a2 100644 --- a/core/config/migrate.rs +++ b/core/config/migrate.rs @@ -156,22 +156,27 @@ pub fn migrate_if_needed(file_env: Option<&HashMap>) { settings.backend_max_upload_mb = Some(n); } - // Save settings.json - if let Err(e) = settings.save() { - tracing::warn!("Migration: failed to save settings.json: {e}"); - return; - } - - // Migrate API keys to Keychain + // Migrate API keys to Keychain before writing settings.json. The existence + // of settings.json is the migration-complete sentinel, so a failed secret + // write must leave the migration retryable on the next launch. for &account in keychain::KEYCHAIN_ACCOUNTS { if let Some(secret) = migrated_value(file_env, account) && !secret.is_empty() - && let Err(e) = keychain::save_key(account, &secret) + && let Err(e) = save_migrated_key(account, &secret) { - tracing::warn!("Migration: failed to save {account} to Keychain: {e}"); + tracing::warn!( + "Migration: failed to save {account} to Keychain; will retry on next launch: {e}" + ); + return; } } + // Save settings.json last because its presence marks the one-time import as complete. + if let Err(e) = settings.save() { + tracing::warn!("Migration: failed to save settings.json: {e}"); + return; + } + info!("Migrated config to settings.json + Keychain"); } @@ -179,6 +184,38 @@ fn migrated_value(file_env: Option<&HashMap>, key: &str) -> Opti file_env.and_then(|vars| vars.get(key).cloned()) } +fn save_migrated_key(account: &str, secret: &str) -> anyhow::Result<()> { + #[cfg(test)] + if test_save_key_failure_account(account) { + anyhow::bail!("injected save_key failure for {account}"); + } + + keychain::save_key(account, secret) +} + +#[cfg(test)] +static TEST_SAVE_KEY_FAILURE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +#[cfg(test)] +fn test_save_key_failure_account(account: &str) -> bool { + TEST_SAVE_KEY_FAILURE + .get_or_init(|| std::sync::Mutex::new(None)) + .lock() + .map(|guard| guard.as_deref() == Some(account)) + .unwrap_or(false) +} + +#[cfg(test)] +fn set_test_save_key_failure(account: Option<&str>) { + if let Ok(mut guard) = TEST_SAVE_KEY_FAILURE + .get_or_init(|| std::sync::Mutex::new(None)) + .lock() + { + *guard = account.map(str::to_owned); + } +} + #[cfg(test)] mod tests { use super::*; @@ -260,4 +297,82 @@ mod tests { remove_env_for_test("CODESCRIBE_DATA_DIR"); } + + #[test] + #[serial] + fn migrate_retries_when_keychain_save_fails() { + let _tmp = setup_isolated_data_dir(); + let mut file_env = HashMap::new(); + file_env.insert("WHISPER_LANGUAGE".to_string(), "en".to_string()); + file_env.insert("LLM_API_KEY".to_string(), "retry-secret".to_string()); + + set_test_save_key_failure(Some("LLM_API_KEY")); + remove_env_for_test("LLM_API_KEY"); + + migrate_if_needed(Some(&file_env)); + + assert!( + !UserSettings::settings_path().exists(), + "failed keychain save must not mark migration complete" + ); + assert!( + std::env::var("LLM_API_KEY").is_err(), + "injected failure happens before test key persistence" + ); + + set_test_save_key_failure(None); + migrate_if_needed(Some(&file_env)); + + assert!( + UserSettings::settings_path().exists(), + "retry after keychain recovery should complete migration" + ); + assert_eq!( + std::env::var("LLM_API_KEY").as_deref(), + Ok("retry-secret"), + "retry writes the migrated secret" + ); + + remove_env_for_test("LLM_API_KEY"); + remove_env_for_test("CODESCRIBE_DATA_DIR"); + } + + #[test] + #[serial] + fn successful_migration_marks_complete_once() { + let _tmp = setup_isolated_data_dir(); + let mut first_env = HashMap::new(); + first_env.insert("WHISPER_LANGUAGE".to_string(), "en".to_string()); + first_env.insert("LLM_API_KEY".to_string(), "first-secret".to_string()); + + migrate_if_needed(Some(&first_env)); + + let path = UserSettings::settings_path(); + assert!( + path.exists(), + "successful migration writes completion sentinel" + ); + assert_eq!(std::env::var("LLM_API_KEY").as_deref(), Ok("first-secret")); + + let mut second_env = HashMap::new(); + second_env.insert("WHISPER_LANGUAGE".to_string(), "pl".to_string()); + second_env.insert("LLM_API_KEY".to_string(), "second-secret".to_string()); + + migrate_if_needed(Some(&second_env)); + + let persisted = UserSettings::load(); + assert_eq!( + persisted.whisper_language.as_deref(), + Some("en"), + "existing settings.json skips re-migration" + ); + assert_eq!( + std::env::var("LLM_API_KEY").as_deref(), + Ok("first-secret"), + "existing completion sentinel skips duplicate key migration" + ); + + remove_env_for_test("LLM_API_KEY"); + remove_env_for_test("CODESCRIBE_DATA_DIR"); + } } From a630af6f2d80560387a6064bf60558efdcc6e7d4 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 21:52:57 -0700 Subject: [PATCH 09/17] fix(bridge): return CsError for account login runtime init Convert account auth runtime creation into a bridge Config error instead of panicking.\n\n- Propagate runtime initialization failure through start_account_login\n- Preserve the existing CsError contract for Swift callers\n- Keep account login server failures on the existing account_auth_to_cs path --- bridge/src/config.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/bridge/src/config.rs b/bridge/src/config.rs index 604651fa..60958e60 100644 --- a/bridge/src/config.rs +++ b/bridge/src/config.rs @@ -511,7 +511,7 @@ impl CodescribeConfig { let mut opts = account_auth::ServerOptions::new(client_id); opts.issuer = account_auth::issuer_from_env(); - let login = account_auth_runtime() + let login = account_auth_runtime()? .block_on(account_auth::run_login_server(opts)) .map_err(account_auth_to_cs)?; let auth_url = login.auth_url.clone(); @@ -901,15 +901,18 @@ fn key_present(account: &str) -> bool { env_string(account).is_some() || load_key(account).and_then(non_empty).is_some() } -fn account_auth_runtime() -> &'static tokio::runtime::Runtime { - static RUNTIME: OnceLock = OnceLock::new(); - RUNTIME.get_or_init(|| { +fn account_auth_runtime() -> Result<&'static tokio::runtime::Runtime, CsError> { + static RUNTIME: OnceLock> = OnceLock::new(); + match RUNTIME.get_or_init(|| { tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_name("codescribe-account-auth") .build() - .expect("account auth runtime should initialize") - }) + .map_err(|error| format!("account auth runtime initialization failed: {error}")) + }) { + Ok(runtime) => Ok(runtime), + Err(msg) => Err(CsError::Config { msg: msg.clone() }), + } } fn active_account_login() -> &'static Mutex> { From 56d3a9f1147ea276753afc69dd75887026101688 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 22:03:02 -0700 Subject: [PATCH 10/17] fix(bench): default to reproducible repo corpus and stamp fixture_source - Default STT bench fixture selection to repo assets so bare runs ignore private ~/.codescribe corpora. - Keep historical corpora available only through explicit --fixtures historical. - Add --list-fixtures for deterministic no-model fixture-selection smoke checks. - Stamp fixture_source in both honest failure reports and full benchmark reports. --- scripts/bench-stt.sh | 132 +++++++++++++++++++++++++++++-------------- 1 file changed, 90 insertions(+), 42 deletions(-) diff --git a/scripts/bench-stt.sh b/scripts/bench-stt.sh index 1cd7673b..acbb85d5 100755 --- a/scripts/bench-stt.sh +++ b/scripts/bench-stt.sh @@ -4,10 +4,11 @@ set -Eeuo pipefail usage() { cat <<'EOF' Usage: - scripts/bench-stt.sh [--fixtures auto|historical|repo] [--limit N] [--out DIR] [--language LANG] + scripts/bench-stt.sh [--fixtures repo|historical] [--limit N] [--out DIR] [--language LANG] + [--list-fixtures] Environment: - BENCH_STT_FIXTURES auto|historical|repo (default: auto) + BENCH_STT_FIXTURES repo|historical (default: repo) BENCH_STT_LIMIT fixture limit for historical corpus (default: 10) BENCH_STT_OUT output directory under ~/.codescribe (default: timestamped report dir) BENCH_STT_LANGUAGE Whisper language code (default: pl) @@ -32,9 +33,10 @@ else fi home_dir="${HOME:-}" -fixture_mode="${BENCH_STT_FIXTURES:-auto}" +fixture_mode="${BENCH_STT_FIXTURES:-repo}" fixture_limit="${BENCH_STT_LIMIT:-10}" language="${BENCH_STT_LANGUAGE:-pl}" +list_fixtures=false run_id="$(date '+%Y%m%d-%H%M%S')-$$" out_dir="${BENCH_STT_OUT:-$home_dir/.codescribe/reports/bench-stt-baseline-$run_id}" @@ -56,6 +58,10 @@ while [[ $# -gt 0 ]]; do language="${2:-}" shift 2 ;; + --list-fixtures) + list_fixtures=true + shift + ;; -h|--help) usage exit 0 @@ -69,7 +75,7 @@ while [[ $# -gt 0 ]]; do done case "$fixture_mode" in - auto|historical|repo) ;; + historical|repo) ;; *) printf 'Invalid --fixtures value: %s\n' "$fixture_mode" >&2 exit 2 @@ -179,10 +185,35 @@ discover_model() { return 1 } +fixture_source_label() { + local source_file="${1:-$selected_tsv}" + local sources + if [[ -s "$source_file" ]]; then + sources="$(awk -F '\t' ' + NR == 1 { + for (i = 1; i <= NF; i++) { + if ($i == "source") { + source_col = i + next + } + } + source_col = 4 + } + NF >= source_col && $source_col != "" {print $source_col} + ' "$source_file" | sort -u | paste -sd, -)" + if [[ -n "$sources" ]]; then + printf '%s\n' "$sources" + return 0 + fi + fi + printf '%s\n' "$fixture_mode" +} + write_honest_report() { local reason="$1" - local head_short + local head_short fixture_source head_short="$(git -C "$repo_root" rev-parse --short=8 HEAD 2>/dev/null || printf 'unknown')" + fixture_source="$(fixture_source_label)" { printf '# CodeScribe STT Baseline Bench\n\n' printf '[!] %s\n\n' "$reason" @@ -194,6 +225,7 @@ write_honest_report() { printf '%s\n' "- repo: \`$repo_root\`" printf '%s\n' "- head: \`$head_short\`" printf '%s\n' "- fixture mode: \`$fixture_mode\`" + printf '%s\n' "- fixture_source: \`$fixture_source\`" printf '%s\n' "- output: \`$out_dir\`" printf '\n## Fixture manifest\n\n' if [[ -s "$manifest_tsv" ]]; then @@ -260,35 +292,31 @@ PY select_historical_pairs() { : > "$selected_tsv" - if [[ "$fixture_mode" != "repo" ]]; then - select_from_benchmark_report - fi + select_from_benchmark_report if [[ "$(count_lines "$selected_tsv")" -gt 0 ]]; then return 0 fi - if [[ "$fixture_mode" == "historical" || "$fixture_mode" == "auto" ]]; then - local all_tsv="$out_dir/historical-candidates.tsv" - : > "$all_tsv" - local dir wav ref id - for dir in \ - "$home_dir/.codescribe/transcriptions/2026-02-11" \ - "$home_dir/.codescribe/transcriptions/2026-01-17"; do - [[ -d "$dir" ]] || continue - for wav in "$dir"/*.wav; do - [[ -f "$wav" ]] || continue - ref="${wav%.wav}.txt" - [[ -f "$ref" ]] || continue - id="$(basename "$dir")__$(basename "${wav%.wav}")" - printf '%s\t%s\t%s\thistorical_scan\n' "$id" "$wav" "$ref" >> "$all_tsv" - done + local all_tsv="$out_dir/historical-candidates.tsv" + : > "$all_tsv" + local dir wav ref id + for dir in \ + "$home_dir/.codescribe/transcriptions/2026-02-11" \ + "$home_dir/.codescribe/transcriptions/2026-01-17"; do + [[ -d "$dir" ]] || continue + for wav in "$dir"/*.wav; do + [[ -f "$wav" ]] || continue + ref="${wav%.wav}.txt" + [[ -f "$ref" ]] || continue + id="$(basename "$dir")__$(basename "${wav%.wav}")" + printf '%s\t%s\t%s\thistorical_scan\n' "$id" "$wav" "$ref" >> "$all_tsv" done - if [[ "$fixture_limit" -eq 0 ]]; then - sort "$all_tsv" > "$selected_tsv" - else - sort "$all_tsv" | head -n "$fixture_limit" > "$selected_tsv" - fi + done + if [[ "$fixture_limit" -eq 0 ]]; then + sort "$all_tsv" > "$selected_tsv" + else + sort "$all_tsv" | head -n "$fixture_limit" > "$selected_tsv" fi } @@ -339,6 +367,14 @@ stage_fixtures() { done < "$selected_tsv" } +list_selected_fixtures() { + local fixture_source + fixture_source="$(fixture_source_label)" + printf 'fixture_source\t%s\n' "$fixture_source" + printf 'id\tsource_audio\tsource_reference\tsource\n' + cat "$selected_tsv" +} + write_wer_probe() { mkdir -p "$wer_probe_dir/src" cat > "$wer_probe_dir/Cargo.toml" </dev/null || printf 'unknown')" repro="scripts/bench-stt.sh --fixtures $fixture_mode --limit $fixture_limit --language $language" - python3 - "$report_path" "$qube_out/report.json" "$manifest_tsv" "$latency_tsv" "$wer_tsv" "$term_hits_tsv" "$term_source_tsv" "$repro" "$repo_root" "$head_short" "$model_path" "$out_dir" <<'PY' + fixture_source="$(fixture_source_label "$manifest_tsv")" + python3 - "$report_path" "$qube_out/report.json" "$manifest_tsv" "$latency_tsv" "$wer_tsv" "$term_hits_tsv" "$term_source_tsv" "$repro" "$repo_root" "$head_short" "$model_path" "$out_dir" "$fixture_source" <<'PY' import csv import json import math @@ -855,6 +892,7 @@ repo_root = sys.argv[9] head_short = sys.argv[10] model_path = sys.argv[11] out_dir = sys.argv[12] +fixture_source = sys.argv[13] qube = json.loads(qube_json.read_text()) manifest = list(csv.DictReader(manifest_tsv.open(), delimiter="\t")) @@ -944,6 +982,7 @@ lines.append(f"- head: `{head_short}`") lines.append(f"- model: `{model_path}`") lines.append(f"- output: `{out_dir}`") lines.append(f"- fixtures: `{len(manifest)}`") +lines.append(f"- fixture_source: `{fixture_source}`") lines.append("- legacy WER control: existing `qube-report` raw path") lines.append("- scheduler latency: `transcription_session` -> `SttScheduler` lanes; model load is excluded") lines.append("- prompted WER: explicit prompt-aware transcribe call vs unprompted control; with default OFF it passes no initial prompt") @@ -1059,26 +1098,35 @@ PY load_env_file -if ! command -v cargo >/dev/null 2>&1; then - write_honest_report "cargo is not available; cannot run STT benchmark." -fi -if ! command -v python3 >/dev/null 2>&1; then - write_honest_report "python3 is not available; cannot prepare fixture/report metadata." +if [[ "$fixture_mode" == "historical" ]] && ! command -v python3 >/dev/null 2>&1; then + write_honest_report "python3 is not available; cannot read historical benchmark fixtures." fi -if [[ "$fixture_mode" == "repo" ]]; then - select_repo_pairs -else - select_historical_pairs - if [[ "$(count_lines "$selected_tsv")" -eq 0 && "$fixture_mode" == "auto" ]]; then +case "$fixture_mode" in + repo) select_repo_pairs - fi -fi + ;; + historical) + select_historical_pairs + ;; +esac if [[ "$(count_lines "$selected_tsv")" -eq 0 ]]; then write_honest_report "No WAV/TXT fixture pairs found for mode '$fixture_mode'." fi +if [[ "$list_fixtures" == "true" ]]; then + list_selected_fixtures + exit 0 +fi + +if ! command -v cargo >/dev/null 2>&1; then + write_honest_report "cargo is not available; cannot run STT benchmark." +fi +if ! command -v python3 >/dev/null 2>&1; then + write_honest_report "python3 is not available; cannot prepare fixture/report metadata." +fi + stage_fixtures if [[ "$(($(count_lines "$manifest_tsv") - 1))" -le 0 ]]; then From 3615c73a5488dcaf83febb37fc1aca05162a44b2 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 22:08:38 -0700 Subject: [PATCH 11/17] chore(vad): remove dead ensure_downloaded_to_user_dir install path - Remove the unused async downloader API and its private download helper. - Drop the stale Silero VAD URL export while keeping legacy path helpers used by tests. - Keep core/vad/mod.rs changes limited to the install re-export surface. --- core/vad/install.rs | 65 +++------------------------------------------ core/vad/mod.rs | 5 +--- 2 files changed, 5 insertions(+), 65 deletions(-) diff --git a/core/vad/install.rs b/core/vad/install.rs index 54d62dcb..674ef90b 100644 --- a/core/vad/install.rs +++ b/core/vad/install.rs @@ -1,15 +1,9 @@ -//! Silero VAD model installation helpers. +//! Silero VAD legacy model path helpers. //! -//! Goal: make it easy to get `silero_vad.onnx` onto the machine without -//! embedding it into the binary. +//! The embedded/Hugging Face cache path is canonical for runtime loading. These +//! helpers remain for legacy user-model path checks. -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; - -/// Silero VAD v6 ONNX model URL. -pub const SILERO_VAD_URL: &str = - "https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx"; +use std::path::PathBuf; /// Model filename (as expected by the loader). pub const SILERO_VAD_FILE: &str = "silero_vad.onnx"; @@ -27,54 +21,3 @@ pub fn user_models_dir() -> PathBuf { pub fn user_model_path() -> PathBuf { user_models_dir().join(SILERO_VAD_FILE) } - -/// Download Silero VAD model to `~/.codescribe/models/silero_vad.onnx` if missing. -/// -/// Returns the destination path (even if it already existed). -pub async fn ensure_downloaded_to_user_dir() -> Result { - let dest = user_model_path(); - if dest.exists() { - return Ok(dest); - } - - let parent = dest - .parent() - .context("Silero VAD destination has no parent dir")?; - tokio::fs::create_dir_all(parent) - .await - .context("Failed to create ~/.codescribe/models directory")?; - - download_silero_vad(&dest).await?; - Ok(dest) -} - -/// Download the Silero VAD model from the hardcoded upstream URL. -async fn download_silero_vad(dest: &Path) -> Result<()> { - let tmp = dest.with_extension("onnx.part"); - - let resp = reqwest::get(SILERO_VAD_URL) - .await - .with_context(|| format!("Failed to GET {}", SILERO_VAD_URL))? - .error_for_status() - .with_context(|| format!("HTTP error downloading {}", SILERO_VAD_URL))?; - - let bytes = resp - .bytes() - .await - .with_context(|| format!("Failed to read body from {}", SILERO_VAD_URL))?; - - if bytes.is_empty() { - anyhow::bail!("Downloaded file is empty: {}", SILERO_VAD_URL); - } - - tokio::fs::write(&tmp, &bytes) - .await - .with_context(|| format!("Failed to write {}", tmp.display()))?; - - // Atomic-ish replace on macOS when staying on same filesystem. - tokio::fs::rename(&tmp, dest) - .await - .with_context(|| format!("Failed to move {} -> {}", tmp.display(), dest.display()))?; - - Ok(()) -} diff --git a/core/vad/mod.rs b/core/vad/mod.rs index ab183d53..65e7b686 100644 --- a/core/vad/mod.rs +++ b/core/vad/mod.rs @@ -35,10 +35,7 @@ use tracing::warn; pub use config::VadConfig; pub use discriminator::{DISCRIMINATOR_WINDOW_MS, VadTimeline, classify_windows}; -pub use install::{ - SILERO_VAD_FILE, SILERO_VAD_URL, ensure_downloaded_to_user_dir, user_model_path, - user_models_dir, -}; +pub use install::{SILERO_VAD_FILE, user_model_path, user_models_dir}; pub use silero_ort::{AccumulatingVad, Resampler, SileroVad, VAD_SAMPLE_RATE, default_model_path}; /// Expected sample rate for VAD (Silero requires 16kHz) From 4282b52903cb3909a4959df83c0b4f1be2f051ad Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 22:10:56 -0700 Subject: [PATCH 12/17] fix(controller): derive stop-timeout warnings from the STOP_TIMEOUT constant - Replace the stale 45s Busy warning with STOP_TIMEOUT.as_secs(). - Remove stale 45s references from nearby watchdog comments. - Keep timeout behavior unchanged; only message/comment text changed. --- app/controller/mod.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/controller/mod.rs b/app/controller/mod.rs index 331d2e9e..635d982f 100644 --- a/app/controller/mod.rs +++ b/app/controller/mod.rs @@ -1599,7 +1599,8 @@ impl RecordingController { State::Busy => { warn!( "Toggle pressed while previous stop is still processing (state=BUSY). \ - If recording badge persists, stop watchdog will force recovery within 45s." + If recording badge persists, stop watchdog will force recovery within {}s.", + STOP_TIMEOUT.as_secs() ); } _ => { @@ -2551,7 +2552,7 @@ impl RecordingController { async fn stop_toggle_and_adjudicate_inner(&self) -> Result<()> { // Phase-timed instrumentation: the watchdog above wraps this entire fn - // in a 45s timeout, but until now we couldn't tell WHICH await hung. + // in STOP_TIMEOUT, but until now we couldn't tell WHICH await hung. // Operator reported "hands-off, double option, który potrafi wywołać // nagrywanie, ale nie potrafi zakończyć nagrywania" — confirmed in // ~/.codescribe/logs/codescribe.log @ 2026-05-13 23:03:22 PDT @@ -2582,7 +2583,7 @@ impl RecordingController { // Self-deadlock guard (Rust 2024): the read guard temporary from an // if-let chain scrutinee outlives the chain body. Inlining the read // would keep the guard alive across `.write().await`, blocking the - // write on this same task's read guard → 45s hang reproduced in + // write on this same task's read guard → STOP_TIMEOUT hang reproduced in // ~/.codescribe/logs/codescribe.log 2026-05-14T00:16:23 (PHASE 1 // never reached; watchdog forced recovery). Materialize the snapshot // first so the read guard drops at the semicolon. From 72da7e7dfb8502bff5744922afeecdbbea58768e Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 22:34:13 -0700 Subject: [PATCH 13/17] chore(controller): remove unused VAD trigger accessors - Remove dead public VAD trigger helpers from RecordingController.\n- Confirmed both identifiers had zero callsites with loct literal scans.\n- Keep Busy watchdog behavior unchanged after EXT-1 falsification. --- app/controller/mod.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/app/controller/mod.rs b/app/controller/mod.rs index 635d982f..aa0b1f4f 100644 --- a/app/controller/mod.rs +++ b/app/controller/mod.rs @@ -1102,16 +1102,6 @@ impl RecordingController { self.config.read().await.clone() } - /// Check if VAD (silence detection) has triggered auto-stop - pub fn is_vad_triggered(&self) -> bool { - self.vad_triggered.load(Ordering::SeqCst) - } - - /// Clear the VAD triggered flag - pub fn clear_vad_triggered(&self) { - self.vad_triggered.store(false, Ordering::SeqCst); - } - /// Cancel any pending delayed hold-start task async fn cancel_pending_hold_start(&self) { let generation = self.hold_start_generation.fetch_add(1, Ordering::SeqCst) + 1; From 8ca82425a11c2f893c5ef82f333dc5efa874970b Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 23:21:39 -0700 Subject: [PATCH 14/17] fix(stt): clamp overlapping apple speech segments - Clamp partially overlapping Apple STT segment starts to the previous end instead of dropping words. - Keep fully contained or invalid segments filtered out. - Preserve monotonic finite segment timestamps for downstream timeline consumers. --- core/stt/apple_stt/codescribe-stt-bridge.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/core/stt/apple_stt/codescribe-stt-bridge.swift b/core/stt/apple_stt/codescribe-stt-bridge.swift index f57a4db6..e2e26665 100644 --- a/core/stt/apple_stt/codescribe-stt-bridge.swift +++ b/core/stt/apple_stt/codescribe-stt-bridge.swift @@ -275,12 +275,18 @@ private func normalizeSegments(_ segments: [BridgeSegment]) -> [BridgeSegment] { guard !text.isEmpty, segment.startTs.isFinite, segment.endTs.isFinite, - segment.endTs >= segment.startTs, - segment.startTs >= previousEnd + segment.endTs >= segment.startTs else { continue } - normalized.append(BridgeSegment(text: text, startTs: segment.startTs, endTs: segment.endTs)) + if segment.startTs < previousEnd, segment.endTs <= previousEnd { + continue + } + let start = max(segment.startTs, previousEnd) + guard segment.endTs >= start else { + continue + } + normalized.append(BridgeSegment(text: text, startTs: start, endTs: segment.endTs)) previousEnd = segment.endTs } return normalized From 63b491855778f077ab06c3712c43ed062e0877ab Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 23:22:30 -0700 Subject: [PATCH 15/17] fix(stt): preserve raw whisper text across harmless normalization - Preserve raw Whisper text when Silero keeps all segments and only case or punctuation differs. - Keep strict-subset preservation for no-drop segment text while retaining filtered fallback for real drops. - Add regression coverage for no-drop equivalence and dropped-segment fallback. --- core/stt/whisper/engine.rs | 86 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/core/stt/whisper/engine.rs b/core/stt/whisper/engine.rs index da7768fe..1f7e7e7c 100644 --- a/core/stt/whisper/engine.rs +++ b/core/stt/whisper/engine.rs @@ -195,7 +195,10 @@ fn apply_silero_filter_outcome( dropped_count: u32, ) -> RawTranscript { let mut filtered = raw.clone(); - filtered.text = if dropped_count == 0 && is_strict_text_subset(&filtered_text, &raw.text) { + let should_preserve_raw_text = dropped_count == 0 + && (is_text_equivalent(&filtered_text, &raw.text) + || is_strict_text_subset(&filtered_text, &raw.text)); + filtered.text = if should_preserve_raw_text { raw.text.clone() } else { filtered_text @@ -210,8 +213,29 @@ fn is_strict_text_subset(candidate: &str, full_text: &str) -> bool { !candidate.is_empty() && candidate != full_text && full_text.contains(&candidate) } +fn is_text_equivalent(candidate: &str, full_text: &str) -> bool { + let candidate = normalize_transcript_text(candidate); + let full_text = normalize_transcript_text(full_text); + !candidate.is_empty() && candidate == full_text +} + fn normalize_transcript_text(text: &str) -> String { - text.split_whitespace().collect::>().join(" ") + text.split_whitespace() + .filter_map(|token| { + let mut normalized = String::new(); + for ch in token.chars() { + if ch.is_alphanumeric() { + normalized.extend(ch.to_lowercase()); + } + } + if normalized.is_empty() { + None + } else { + Some(normalized) + } + }) + .collect::>() + .join(" ") } fn should_suppress_decoder_control_tokens(generated_tokens: usize) -> bool { @@ -1974,7 +1998,7 @@ mod dedup_tests { end_ts: 1.2, }]; let raw = RawTranscript { - text: "close chart and add plan".to_string(), + text: "Close chart, and add plan.".to_string(), segments: segments.clone(), ..Default::default() }; @@ -1985,6 +2009,62 @@ mod dedup_tests { assert_eq!(filtered.segments, raw.segments); } + #[test] + fn silero_filter_preserves_raw_text_when_no_drop_only_case_or_punctuation_differs() { + let segments = vec![crate::pipeline::contracts::TranscriptSegment { + text: "close chart and add plan".to_string(), + start_ts: 0.0, + end_ts: 1.2, + }]; + let raw = RawTranscript { + text: "Close chart, and add plan.".to_string(), + segments: segments.clone(), + ..Default::default() + }; + + let filtered = + apply_silero_filter_outcome(&raw, "close chart and add plan".to_string(), segments, 0); + + assert_eq!(filtered.text, raw.text); + assert_eq!(filtered.segments, raw.segments); + assert!(!is_strict_text_subset( + "close chart and add plan", + "Close chart, and add plan." + )); + } + + #[test] + fn silero_filter_uses_filtered_text_when_segments_were_dropped() { + let raw_segments = vec![ + crate::pipeline::contracts::TranscriptSegment { + text: "close chart".to_string(), + start_ts: 0.0, + end_ts: 1.2, + }, + crate::pipeline::contracts::TranscriptSegment { + text: "subscribe".to_string(), + start_ts: 1.2, + end_ts: 2.0, + }, + ]; + let filtered_segments = vec![raw_segments[0].clone()]; + let raw = RawTranscript { + text: "Close chart, and subscribe.".to_string(), + segments: raw_segments, + ..Default::default() + }; + + let filtered = apply_silero_filter_outcome( + &raw, + "close chart".to_string(), + filtered_segments.clone(), + 1, + ); + + assert_eq!(filtered.text, "close chart"); + assert_eq!(filtered.segments, filtered_segments); + } + #[test] fn decoder_control_tokens_are_only_suppressed_before_first_token() { assert!(should_suppress_decoder_control_tokens(0)); From 5035f99644d9eb53c2752dab265601e9417b5692 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 23:22:41 -0700 Subject: [PATCH 16/17] fix(macos): avoid redundant settings deeplink notifications - Post pendingSectionDidChange only when a real pending Settings section is set. - Keep consume() as the one-shot clear path without triggering duplicate navigation. - Preserve the existing SettingsView receiver contract for non-nil deep links. --- macos/Codescribe/Screens/Settings/SettingsViewModel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift index 9f469266..106c7010 100644 --- a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift +++ b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift @@ -33,6 +33,7 @@ enum SettingsDeepLink { static var pendingSection: SettingsSection? { didSet { + guard pendingSection != nil else { return } NotificationCenter.default.post(name: pendingSectionDidChange, object: nil) } } From 2393f88cab27a3e93bcca436c5c7fad262948699 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Thu, 9 Jul 2026 23:28:31 -0700 Subject: [PATCH 17/17] fix(build): avoid empty cargo flag array in app build - Replace the empty debug CARGO_FLAGS array with explicit debug/release cargo build branches. - Keep make app behavior unchanged while making bash nounset safe on macOS. - Unblock the required Swift app quality gate for debug builds. --- scripts/build-app.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 7a41ce72..34bfa015 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -29,8 +29,8 @@ cd "$REPO_ROOT" PROFILE="${1:-debug}" case "$PROFILE" in - debug) CONFIG="Debug"; CARGO_FLAGS=(); TARGET_DIR="target/debug" ;; - release) CONFIG="Release"; CARGO_FLAGS=(--release); TARGET_DIR="target/release" ;; + debug) CONFIG="Debug"; TARGET_DIR="target/debug" ;; + release) CONFIG="Release"; TARGET_DIR="target/release" ;; *) echo "usage: $0 [debug|release]" >&2; exit 2 ;; esac @@ -54,7 +54,11 @@ STT_BRIDGE_SRC="core/stt/apple_stt/codescribe-stt-bridge.swift" STT_BRIDGE_BIN="$TARGET_DIR/codescribe-stt-bridge" echo "==> [1/7] Building codescribe-ffi ($PROFILE)" -cargo build -p codescribe-ffi "${CARGO_FLAGS[@]}" +if [ "$PROFILE" = "release" ]; then + cargo build -p codescribe-ffi --release +else + cargo build -p codescribe-ffi +fi echo "==> [2/7] Rewriting dylib install_name to @rpath (relocatable bundle)" install_name_tool -id @rpath/libcodescribe_ffi.dylib "$DYLIB"