Skip to content

Commit 8fde23a

Browse files
r3dbarsclaude
andcommitted
Route stop-timeout meetings to failed queue
When stopAndAwaitFiles times out waiting for Audio.onRecordingComplete, the WAV header is not yet fully patched. Previously the controller fed those URLs straight into transcription, risking a corrupted transcript. Now the bridge returns CaptureStopResult with didTimeOut, the controller routes to the failed queue on timeout, and the user can retry from Settings - Meetings once the file finalizes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 72e11b9 commit 8fde23a

5 files changed

Lines changed: 101 additions & 13 deletions

File tree

‎Sources/Meeting/MeetingCaptureBridge.swift‎

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ import Combine
1919
import Foundation
2020
import TranscriptedCore
2121

22+
/// Result of a stop request. `didTimeOut == true` means we did not receive
23+
/// `Audio.onRecordingComplete` within `meetingStopTimeout`, so the WAV files
24+
/// at the returned URLs may not be fully finalized — the controller should
25+
/// route the audio to the failed queue rather than enqueuing for transcription.
26+
struct CaptureStopResult {
27+
let micURL: URL?
28+
let systemURL: URL?
29+
let didTimeOut: Bool
30+
}
31+
2232
@available(macOS 14.0, *)
2333
@MainActor
2434
final class MeetingCaptureBridge: ObservableObject {
@@ -42,7 +52,7 @@ final class MeetingCaptureBridge: ObservableObject {
4252

4353
/// Fulfilled when Core's Audio reports recording completed. Cleared each
4454
/// time `startRecording` runs so back-to-back sessions do not leak state.
45-
private var completionContinuation: CheckedContinuation<(micURL: URL?, systemURL: URL?), Never>?
55+
private var completionContinuation: CheckedContinuation<CaptureStopResult, Never>?
4656
private var completionAttemptID: UUID?
4757
private var completionTimeoutTask: Task<Void, Never>?
4858
private var startContinuation: CheckedContinuation<Bool, Never>?
@@ -59,7 +69,11 @@ final class MeetingCaptureBridge: ObservableObject {
5969
startTimeoutTask?.cancel()
6070
startContinuation?.resume(returning: false)
6171
completionTimeoutTask?.cancel()
62-
completionContinuation?.resume(returning: (audio.micAudioFileURL, audio.systemAudioFileURL))
72+
completionContinuation?.resume(returning: CaptureStopResult(
73+
micURL: audio.micAudioFileURL,
74+
systemURL: audio.systemAudioFileURL,
75+
didTimeOut: false
76+
))
6377
// Combine cancellables auto-release. Audio's own deinit tears down CoreAudio.
6478
}
6579

@@ -73,7 +87,11 @@ final class MeetingCaptureBridge: ObservableObject {
7387
completionAttemptID = nil
7488
completionTimeoutTask?.cancel()
7589
completionTimeoutTask = nil
76-
continuation.resume(returning: (audio.micAudioFileURL, audio.systemAudioFileURL))
90+
continuation.resume(returning: CaptureStopResult(
91+
micURL: audio.micAudioFileURL,
92+
systemURL: audio.systemAudioFileURL,
93+
didTimeOut: false
94+
))
7795
}
7896
if audio.isRecording { return true }
7997

@@ -109,11 +127,18 @@ final class MeetingCaptureBridge: ObservableObject {
109127
}
110128

111129
/// Stop the current recording and wait for Core's Audio to finish writing
112-
/// the mic + system WAV files to disk. Returns the URLs of both files (system
113-
/// may be nil if capture failed or was disabled).
114-
func stopAndAwaitFiles() async -> (micURL: URL?, systemURL: URL?) {
130+
/// the mic + system WAV files to disk. Returns a result that distinguishes
131+
/// natural completion (`didTimeOut == false`) from `meetingStopTimeout`
132+
/// expiry (`didTimeOut == true`). On timeout the WAV header may not be
133+
/// fully patched, so the caller should treat the audio as failed-but-
134+
/// recoverable rather than enqueuing it for transcription directly.
135+
func stopAndAwaitFiles() async -> CaptureStopResult {
115136
guard audio.isRecording else {
116-
return (audio.micAudioFileURL, audio.systemAudioFileURL)
137+
return CaptureStopResult(
138+
micURL: audio.micAudioFileURL,
139+
systemURL: audio.systemAudioFileURL,
140+
didTimeOut: false
141+
)
117142
}
118143

119144
return await withCheckedContinuation { continuation in
@@ -142,19 +167,23 @@ final class MeetingCaptureBridge: ObservableObject {
142167
"system_file_available": "\(self.audio.systemAudioFileURL != nil)",
143168
]
144169
)
145-
continuation.resume(returning: (self.audio.micAudioFileURL, self.audio.systemAudioFileURL))
170+
continuation.resume(returning: CaptureStopResult(
171+
micURL: self.audio.micAudioFileURL,
172+
systemURL: self.audio.systemAudioFileURL,
173+
didTimeOut: true
174+
))
146175
}
147176
}
148177
}
149178

150179
/// Stop the current recording, wait for file handles to close, then remove
151180
/// the just-captured scratch audio instead of handing it to transcription.
152181
func stopAndDiscardFiles() async -> (micURL: URL?, systemURL: URL?) {
153-
let files = await stopAndAwaitFiles()
154-
MeetingRecordingCleanup.discardFiles(micURL: files.micURL, systemURL: files.systemURL)
182+
let result = await stopAndAwaitFiles()
183+
MeetingRecordingCleanup.discardFiles(micURL: result.micURL, systemURL: result.systemURL)
155184
audio.micAudioFileURL = nil
156185
audio.systemAudioFileURL = nil
157-
return files
186+
return (result.micURL, result.systemURL)
158187
}
159188

160189
/// Snapshot of Core's recording health metadata for transcript frontmatter.
@@ -227,7 +256,11 @@ final class MeetingCaptureBridge: ObservableObject {
227256
self.completionAttemptID = nil
228257
self.completionTimeoutTask?.cancel()
229258
self.completionTimeoutTask = nil
230-
continuation?.resume(returning: (micURL, systemURL))
259+
continuation?.resume(returning: CaptureStopResult(
260+
micURL: micURL,
261+
systemURL: systemURL,
262+
didTimeOut: false
263+
))
231264
}
232265
}
233266
}

‎Sources/Meeting/MeetingFailureCopy.swift‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ struct MeetingFailureCopy: Equatable {
5050
)
5151
}
5252

53+
if message.contains("stop timed out") {
54+
return MeetingFailureCopy(
55+
title: "Recording didn't close cleanly",
56+
detail: "The audio files may be incomplete. Retry to transcribe what was captured, or delete to discard."
57+
)
58+
}
59+
5360
return MeetingFailureCopy(
5461
title: isRetryable ? "Transcript needs another pass" : "Recording needs attention",
5562
detail: shortErrorMessage

‎Sources/Meeting/MeetingFailureKind.swift‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ enum MeetingFailureKind: String {
1414
case transcriptionInferenceFailed = "transcription_inference_failed"
1515
case diarizationFailed = "diarization_failed"
1616
case pipelineBusy = "pipeline_busy"
17+
case stopTimeout = "stop_timeout"
1718
case unexpectedError = "unexpected_error"
1819

1920
static func isRecordingTooShortMessage(_ message: String) -> Bool {
@@ -32,6 +33,11 @@ enum MeetingFailureKind: String {
3233
static func classify(message: String) -> MeetingFailureKind {
3334
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
3435

36+
if normalized.contains("stop timed out")
37+
|| normalized.contains("recording stop timed out") {
38+
return .stopTimeout
39+
}
40+
3541
if normalized.contains("system audio is required")
3642
|| normalized.contains("system audio recording")
3743
|| normalized.contains("screen recording") {

‎Sources/Meeting/MeetingSessionController.swift‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,8 @@ final class MeetingSessionController: ObservableObject {
497497
// Snapshot capture health before stop resets system-audio status/stats during cleanup.
498498
let healthInfo = capture.healthInfo()
499499
let finalSystemAudioStatus = capture.systemAudioStatus
500-
let files = await capture.stopAndAwaitFiles()
500+
let stopResult = await capture.stopAndAwaitFiles()
501+
let files = (micURL: stopResult.micURL, systemURL: stopResult.systemURL)
501502
let durationMs = Int(recordingDuration * 1000)
502503
activeRecordingTrigger = .unknown
503504
state = .transcribing
@@ -514,6 +515,7 @@ final class MeetingSessionController: ObservableObject {
514515
"duration_ms": "\(durationMs)",
515516
"mic_file_present": boolString(files.micURL != nil),
516517
"system_file_present": boolString(files.systemURL != nil),
518+
"stop_timed_out": boolString(stopResult.didTimeOut),
517519
"capture_quality": healthInfo.captureQuality.rawValue,
518520
"audio_gaps": "\(healthInfo.audioGaps)",
519521
"device_switches": "\(healthInfo.deviceSwitches)"
@@ -534,6 +536,28 @@ final class MeetingSessionController: ObservableObject {
534536
return
535537
}
536538

539+
// Stop timeout means Audio.onRecordingComplete never fired. The WAV
540+
// header may not be fully patched, so route the audio to the failed
541+
// queue rather than enqueuing for transcription. The user can retry
542+
// from Settings → Meetings, where the pipeline will either succeed
543+
// on a now-finalized file or fail cleanly.
544+
if stopResult.didTimeOut {
545+
failedManager.addFailedTranscription(
546+
micAudioURL: micURL,
547+
systemAudioURL: files.systemURL,
548+
errorMessage: "Recording stop timed out before audio files were finalized."
549+
)
550+
DiagnosticsTrail.record(
551+
level: .warning,
552+
engine: "meeting",
553+
event: "meeting_recording_stop_timeout_failed",
554+
message: "Meeting routed to failed queue due to stop timeout",
555+
context: baseDiagnosticsContext(extra: ["reason": reason.rawValue])
556+
)
557+
state = .error("Recording didn't close cleanly. Open Settings → Meetings to retry.")
558+
return
559+
}
560+
537561
let outcome = enqueueTranscriptionJob(
538562
micURL: micURL,
539563
systemURL: files.systemURL,

‎Tests/MeetingFailureKindTests.swift‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,24 @@ func testMeetingFailureKind() {
4949
assertEqual(kind, .pipelineBusy, "pipeline-busy rejections from TranscriptionTaskManager should not fall to unexpected_error")
5050
}
5151

52+
runSuite("MeetingFailureKind classifies stop-timeout errors") {
53+
let kind = MeetingFailureKind.classify(
54+
message: "Recording stop timed out before audio files were finalized."
55+
)
56+
57+
assertEqual(kind, .stopTimeout, "stop-timeout failures should keep their own bucket so retry copy can target them")
58+
}
59+
60+
runSuite("MeetingFailureCopy surfaces stop-timeout retry guidance") {
61+
let copy = MeetingFailureCopy.make(
62+
forMessage: "Recording stop timed out before audio files were finalized.",
63+
shortErrorMessage: "Recording stop timed out.",
64+
isRetryable: true
65+
)
66+
67+
assertEqual(copy.title, "Recording didn't close cleanly", "stop-timeout users should see specific copy, not generic retry phrasing")
68+
}
69+
5270
runSuite("MeetingFailureKind falls back to an explicit unexpected bucket") {
5371
let kind = MeetingFailureKind.classify(
5472
message: "Something odd happened while processing the meeting"

0 commit comments

Comments
 (0)