From 03040e889d17919bb12d8290a29fc3be859ea938 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 18 Jul 2026 20:27:25 -0500 Subject: [PATCH] Retain active import recovery journal --- .../TranscriptionQueueCoordinator.swift | 6 ++-- .../Pipeline/TranscriptionTaskManager.swift | 15 +++++++++- ...riptionTaskManagerImportedAudioTests.swift | 28 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/Sources/Meeting/TranscriptionQueueCoordinator.swift b/Sources/Meeting/TranscriptionQueueCoordinator.swift index 14b06b23..f141a288 100644 --- a/Sources/Meeting/TranscriptionQueueCoordinator.swift +++ b/Sources/Meeting/TranscriptionQueueCoordinator.swift @@ -341,9 +341,11 @@ final class TranscriptionQueueCoordinator { audioURL: audioURL, outputFolder: MeetingStoragePaths.transcriptsFolder, meetingTitle: suggestedTitle, - recordingDate: recordingDate + recordingDate: recordingDate, + onTerminal: { [weak self] in + self?.removeImportedJournal(for: job) + } ) - removeImportedJournal(for: job) } } diff --git a/Sources/TranscriptedCore/Pipeline/TranscriptionTaskManager.swift b/Sources/TranscriptedCore/Pipeline/TranscriptionTaskManager.swift index 3204b5ad..7b30e833 100644 --- a/Sources/TranscriptedCore/Pipeline/TranscriptionTaskManager.swift +++ b/Sources/TranscriptedCore/Pipeline/TranscriptionTaskManager.swift @@ -25,6 +25,7 @@ public class TranscriptionTaskManager: ObservableObject { private var savedTranscriptTaskIdsByURL: [URL: UUID] = [:] var activeTasks: [UUID: Task] = [:] private var activeTaskAudio: [UUID: (micURL: URL?, systemURL: URL?, meetingTitle: String?, recordingDate: Date?)] = [:] + private var importedTranscriptionTerminalHandlers: [UUID: @MainActor @Sendable () -> Void] = [:] private var preservedTaskIdsForShutdown: Set = [] private var intentionallyCancelledTaskIds: Set = [] private var committedTranscriptTaskIds: Set = [] @@ -326,11 +327,13 @@ public class TranscriptionTaskManager: ObservableObject { audioURL: URL, outputFolder: URL, meetingTitle: String? = nil, - recordingDate: Date? = nil + recordingDate: Date? = nil, + onTerminal: (@MainActor @Sendable () -> Void)? = nil ) { if !activeTasks.isEmpty { AppLogger.pipeline.warning("Rejecting imported transcription — another pipeline is already active", ["activeCount": "\(activeTasks.count)"]) removeRecordingFile(audioURL, label: "rejected imported recording") + onTerminal?() publishFailure( displayMessage: "Another transcript is already running. Wait for it to finish, then import the file again.", diagnosticMessage: "Transcription already in progress" @@ -343,6 +346,7 @@ public class TranscriptionTaskManager: ObservableObject { if let audioDuration = audioDuration(url: audioURL), audioDuration < minDuration { AppLogger.pipeline.info("Imported recording too short, skipping transcription", ["duration": String(format: "%.1fs", audioDuration)]) removeRecordingFile(audioURL, label: "short imported recording") + onTerminal?() publishFailure( displayMessage: "That audio file is too short to transcribe. Choose audio that is at least two seconds long.", diagnosticMessage: "Recording too short" @@ -360,6 +364,9 @@ public class TranscriptionTaskManager: ObservableObject { meetingTitle: meetingTitle, recordingDate: recordingDate ) + if let onTerminal { + importedTranscriptionTerminalHandlers[taskId] = onTerminal + } publishNonFailureStatus(.gettingReady) AppLogger.pipeline.info("Starting imported transcription task", [ @@ -1278,6 +1285,7 @@ public class TranscriptionTaskManager: ObservableObject { // MARK: - Task Completion & Cleanup func handleTaskCompletion(taskId: UUID) { + importedTranscriptionTerminalHandlers.removeValue(forKey: taskId)?() activeTasks.removeValue(forKey: taskId) activeTaskAudio.removeValue(forKey: taskId) preservedTaskIdsForShutdown.remove(taskId) @@ -1303,6 +1311,10 @@ public class TranscriptionTaskManager: ObservableObject { func markTaskTranscriptCommitted(taskId: UUID) { committedTranscriptTaskIds.insert(taskId) + // An imported queue journal is no longer needed once the transcript's + // durable side effects commit. Keep it through inference so a crash + // before this point can recover the scratch audio on the next launch. + importedTranscriptionTerminalHandlers.removeValue(forKey: taskId)?() // Transcript is durably on disk — the crash-recovery journal for this // recording's scratch audio is no longer needed. if let micURL = activeTaskAudio[taskId]?.micURL { @@ -1331,6 +1343,7 @@ public class TranscriptionTaskManager: ObservableObject { activeCount = max(0, activeCount - 1) backgroundTaskCount = max(0, backgroundTaskCount - 1) } + importedTranscriptionTerminalHandlers.removeValue(forKey: taskId)?() if activeCount == 0 { publishNonFailureStatus(.idle) } diff --git a/Tests/TranscriptedCoreTests/PipelineTests/TranscriptionTaskManagerImportedAudioTests.swift b/Tests/TranscriptedCoreTests/PipelineTests/TranscriptionTaskManagerImportedAudioTests.swift index 942779de..9e6a252d 100644 --- a/Tests/TranscriptedCoreTests/PipelineTests/TranscriptionTaskManagerImportedAudioTests.swift +++ b/Tests/TranscriptedCoreTests/PipelineTests/TranscriptionTaskManagerImportedAudioTests.swift @@ -63,6 +63,34 @@ extension TranscriptionTaskManagerMetadataTests { speech.release() } + func testImportedTranscriptionTerminalHandlerWaitsForTranscriptCommit() async throws { + let speech = BlockingMetadataStubSpeechToTextEngine(transcript: "Imported terminal handoff.") + let manager = makeManager( + speechToText: speech, + diarization: MetadataStubDiarizationEngine(segments: singleSpeakerSegments()) + ) + let copiedImportURL = tempDirectory + .appendingPathComponent("audio", isDirectory: true) + .appendingPathComponent("imported-terminal-handoff.wav") + try writeMonoWAV(to: copiedImportURL, duration: 2.5) + + var didReachTerminal = false + manager.startImportedTranscription( + audioURL: copiedImportURL, + outputFolder: tempDirectory.appendingPathComponent("transcripts"), + onTerminal: { didReachTerminal = true } + ) + + try await waitUntil { speech.didStart } + XCTAssertFalse(didReachTerminal, "the import must remain recoverable while inference is running") + XCTAssertTrue(FileManager.default.fileExists(atPath: copiedImportURL.path)) + + speech.release() + try await waitUntil { + didReachTerminal && manager.lastSavedTranscriptURL != nil && manager.activeTasks.isEmpty + } + } + func testStartSavedAudioRetranscriptionDoesNotDeleteSourceWhenRejectedForActiveTask() throws { let manager = makeManager() let savedAudioDirectory = tempDirectory.appendingPathComponent("saved-meeting-audio", isDirectory: true)