@@ -26,10 +26,17 @@ enum FinishConversationResult {
2626
2727@MainActor
2828class AppState : ObservableObject {
29+ /// Weak reference to the current AppState instance, set on init.
30+ /// Used by background services (e.g. TranscriptionRetryService) to check recording state.
31+ static weak var current : AppState ?
32+
2933 @AppStorage ( " hasCompletedOnboarding " ) var hasCompletedOnboarding = false
3034
3135 // Transcription state
3236 @Published var isTranscribing = false
37+ /// Monotonically increasing counter — incremented each time a new recording starts.
38+ /// Used to detect if a new recording began during the post-stop force-process delay.
39+ private( set) var recordingGeneration : UInt64 = 0
3340 @Published var isSavingConversation = false
3441 // currentTranscript is internal-only (not observed by views), so no @Published needed
3542 private var currentTranscript : String = " "
@@ -195,6 +202,9 @@ class AppState: ObservableObject {
195202 private var bluetoothStateCancellable : AnyCancellable ?
196203
197204 init ( ) {
205+ // Register as the current instance so background services can check recording state
206+ AppState . current = self
207+
198208 // Load API key from environment or .env file
199209 loadEnvironment ( )
200210
@@ -1331,6 +1341,7 @@ class AppState: ObservableObject {
13311341 )
13321342
13331343 isTranscribing = true
1344+ recordingGeneration &+= 1
13341345 AssistantSettings . shared. transcriptionEnabled = true
13351346 audioSource = effectiveSource
13361347 currentTranscript = " "
@@ -1538,22 +1549,89 @@ class AppState: ObservableObject {
15381549
15391550 /// Stop real-time transcription
15401551 /// The Python backend handles conversation lifecycle automatically — disconnecting the WebSocket
1541- /// triggers conversation processing on the backend side.
1552+ /// triggers conversation processing on the backend side. We also call force-process to ensure
1553+ /// the conversation is finalized, preventing the retry service from creating duplicates.
15421554 func stopTranscription( ) {
1555+ // Capture session metadata BEFORE clearing state (clearTranscriptionState sets sessionId to nil)
1556+ let capturedSessionId = currentSessionId
1557+ let capturedStartTime = recordingStartTime
1558+ let generationAtStop = recordingGeneration
1559+
15431560 stopAudioCapture ( )
15441561 clearTranscriptionState ( )
15451562
1546- // Backend processes the conversation when WebSocket disconnects.
1547- // The local session stays as pendingUpload — the retry service will either:
1548- // 1. Find the conversation on the backend (duplicate check) and mark it completed, or
1549- // 2. Re-upload if backend processing failed (recovery path).
1550- // We don't optimistically mark as completed because that orphans the session if backend fails.
1563+ // After WS close, the Python backend processes the conversation automatically.
1564+ // Call force-process to ensure finalization and get the backend conversation ID.
1565+ // This prevents the retry service from picking up the pendingUpload session.
15511566 Task {
1552- try ? await Task . sleep ( nanoseconds: 5_000_000_000 ) // 5s for backend to process
1567+ try ? await Task . sleep ( nanoseconds: 3_000_000_000 ) // 3s for backend to process after WS close
1568+
1569+ // If a new recording started during the delay, skip force-process — it would
1570+ // finalize the NEW conversation instead of the one we just stopped.
1571+ // The retry service will reconcile the old session by timestamp matching.
1572+ guard self . recordingGeneration == generationAtStop else {
1573+ log ( " Transcription: New recording started during delay, skipping force-process for session \( capturedSessionId. map ( String . init) ?? " nil " ) " )
1574+ return
1575+ }
1576+
1577+ do {
1578+ if let conversation = try await APIClient . shared. forceProcessConversation ( ) {
1579+ // Validate the returned conversation matches the session we just stopped
1580+ if let sessionId = capturedSessionId, let startTime = capturedStartTime,
1581+ let convStarted = conversation. startedAt,
1582+ abs ( convStarted. timeIntervalSince ( startTime) ) < 10 ,
1583+ conversation. source == . desktop {
1584+ try ? await TranscriptionStorage . shared. markSessionCompleted (
1585+ id: sessionId, backendId: conversation. id)
1586+ log ( " Transcription: Force-processed conversation \( conversation. id) , session \( sessionId) completed " )
1587+ } else if let sessionId = capturedSessionId, let startTime = capturedStartTime {
1588+ // Force-process returned a different conversation — fall back to reconciliation
1589+ log ( " Transcription: Force-processed conversation \( conversation. id) does not match session \( sessionId) , reconciling by timestamp " )
1590+ await reconcileSession ( sessionId: sessionId, startTime: startTime)
1591+ }
1592+ } else {
1593+ // 404: No in-progress conversation — WS close handler already processed it.
1594+ // Reconcile by checking if a matching conversation exists on the backend.
1595+ if let sessionId = capturedSessionId, let startTime = capturedStartTime {
1596+ await reconcileSession ( sessionId: sessionId, startTime: startTime)
1597+ }
1598+ }
1599+ } catch {
1600+ // Other error — leave session as pendingUpload for retry service to reconcile
1601+ logError ( " Transcription: Force-process failed, retry service will reconcile " , error: error)
1602+ }
1603+
15531604 await loadConversations ( )
15541605 }
15551606 }
15561607
1608+ /// Reconcile a local session by checking if a matching conversation exists on the backend.
1609+ /// If found, marks the session as completed. Otherwise leaves it as pendingUpload for retry.
1610+ private func reconcileSession( sessionId: Int64 , startTime: Date ) async {
1611+ do {
1612+ let conversations = try await APIClient . shared. getConversations (
1613+ limit: 5 ,
1614+ includeDiscarded: true ,
1615+ startDate: startTime. addingTimeInterval ( - 5 ) ,
1616+ endDate: Date ( ) . addingTimeInterval ( 5 )
1617+ )
1618+ if let match = conversations. first ( where: { conv in
1619+ guard let convStarted = conv. startedAt else { return false }
1620+ // Must be a desktop conversation with matching start time
1621+ guard conv. source == . desktop else { return false }
1622+ return abs ( convStarted. timeIntervalSince ( startTime) ) < 10
1623+ } ) {
1624+ try await TranscriptionStorage . shared. markSessionCompleted (
1625+ id: sessionId, backendId: match. id)
1626+ log ( " Transcription: Reconciled session \( sessionId) → backend conversation \( match. id) " )
1627+ } else {
1628+ log ( " Transcription: No matching backend conversation found for session \( sessionId) , leaving for retry " )
1629+ }
1630+ } catch {
1631+ logError ( " Transcription: Reconciliation failed for session \( sessionId) " , error: error)
1632+ }
1633+ }
1634+
15571635 /// Finish the current conversation and keep recording for a new one.
15581636 /// Disconnects the WebSocket (triggers backend conversation processing) then reconnects.
15591637 func finishConversation( ) async -> FinishConversationResult {
0 commit comments