diff --git a/Moru/Moru/Data/Remote/RoutineSync/ProductionRoutineSyncTransport.swift b/Moru/Moru/Data/Remote/RoutineSync/ProductionRoutineSyncTransport.swift index 71c1559..e5b15c6 100644 --- a/Moru/Moru/Data/Remote/RoutineSync/ProductionRoutineSyncTransport.swift +++ b/Moru/Moru/Data/Remote/RoutineSync/ProductionRoutineSyncTransport.swift @@ -4,6 +4,7 @@ // import Foundation +import OSLog nonisolated enum RoutineSyncResponseDecodingError: Error, @@ -28,6 +29,12 @@ nonisolated final class ProductionRoutineSyncTransport: Sendable { private let apiClient: any AccountBoundAPIClient private let responseDecoder: any RoutineSyncTransportResponseDecoding + /// The server's own error code/message for an unrecognized 409 carries no + /// account or routine content, so it is safe to log verbatim. + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.teammoru.Moru", + category: "RoutineSyncTransport" + ) init( apiClient: any AccountBoundAPIClient, @@ -89,7 +96,7 @@ nonisolated final class ProductionRoutineSyncTransport: private static func outcome(for error: APIError) -> RoutineSyncTransportOutcome { switch error { - case .server(let statusCode, let code, _): + case .server(let statusCode, let code, let message): if statusCode == 409, code == "COMMON409" { return .processingConflict } @@ -97,12 +104,18 @@ nonisolated final class ProductionRoutineSyncTransport: return .blocked(.idempotencyPayloadConflict) } if statusCode == 409 { + logger.notice( + "Routine sync 409 unknownConflict: code=\(code ?? "nil", privacy: .public), message=\(message, privacy: .public)" + ) return .blocked(.unknownConflict) } if statusCode == 408 || statusCode == 429 || (500..<600).contains(statusCode) { return .ambiguous } + logger.notice( + "Routine sync definitiveServerRejection: statusCode=\(statusCode), code=\(code ?? "nil", privacy: .public), message=\(message, privacy: .public)" + ) return .blocked(.definitiveServerRejection) case .transport, .cancelled, .decoding, .missingResult: diff --git a/Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupCoordinator.swift b/Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupCoordinator.swift index 352b5bb..9b10039 100644 --- a/Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupCoordinator.swift +++ b/Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupCoordinator.swift @@ -87,6 +87,16 @@ nonisolated enum RoutineTTSForegroundPreparationStatus: Equatable, Sendable { nonisolated enum RoutineTTSDiagnosticEvent: String, Sendable { case cachePlanMissing case missingGroupBinding + /// No binding AND no createRoutineGroup mutation record exists at all for + /// this local group. Distinguishes "sync never even recorded intent" from + /// the other missingGroupBinding causes below. + case missingGroupBindingNoMutationRecord + /// A createRoutineGroup mutation exists but is in the terminal `.blocked` + /// state, which never resolves automatically. + case missingGroupBindingMutationBlocked + /// A binding record exists but failed identity/shape validation + /// (wrong member, namespace, or remoteID) rather than being absent. + case missingGroupBindingInvalidExistingBinding case missingRoutineBinding case remoteFetchFailed case responseUnavailable @@ -99,6 +109,12 @@ nonisolated enum RoutineTTSDiagnosticEvent: String, Sendable { case cachePurgeFailed case customCueUnavailable case serverCueUnavailable + /// The done/remind server-voice common cue's plan was not yet prepared + /// when playback needed it. Fails open (silently completes) by design. + case commonCueUnavailableForServerVoice + /// A common cue's local file was already cache-validated but failed to + /// start playback at cue time. Also fails open by design. + case commonCueLateFailure } nonisolated struct RoutineTTSDiagnostics: Sendable { @@ -230,6 +246,12 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio private let foregroundPollingPolicy: RoutineTTSForegroundPollingPolicy private let prefetchPollingPolicy: RoutineTTSPrefetchPollingPolicy private let diagnostics: RoutineTTSDiagnostics + /// `RoutineSyncBlockReason` carries no account or routine content, so it is + /// safe to log verbatim, unlike the identifier-free `RoutineTTSDiagnostics`. + private let blockReasonLogger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.teammoru.Moru", + category: "RoutineTTSWarmup" + ) private let voiceSelectionVersionStore: any RoutineTTSVoiceSelectionVersionStoring private let prefetchJobStore: (any RoutineTTSPrefetchJobStoring)? private weak var backgroundTransferManager: @@ -1184,7 +1206,25 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio ) { return .pendingBinding } - diagnostics.record(.missingGroupBinding) + let mutation = try? bindingRepository.mutation( + memberID: identity.memberID, + operation: .createRoutineGroup, + entityKind: .routineGroup, + localEntityID: routineGroupLocalID + ) + if let mutation { + let attemptAgeSeconds = mutation.attempt.map { + Int(Date().timeIntervalSince($0.attemptedAt)) + } + blockReasonLogger.notice( + "createRoutineGroup mutation state: \(mutation.state.rawValue, privacy: .public), blockReason: \(mutation.blockReason?.rawValue ?? "nil", privacy: .public), generation: \(mutation.generation, privacy: .public), lastAttemptAgeSeconds: \(attemptAgeSeconds.map(String.init) ?? "nil", privacy: .public)" + ) + } + diagnostics.record( + mutation == nil + ? .missingGroupBindingNoMutationRecord + : .missingGroupBindingMutationBlocked + ) return .unavailable } guard isValidGroupBinding( @@ -1196,7 +1236,7 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio routineGroupLocalID: routineGroupLocalID, routineLocalIDs: requestedRoutineIDs ) - diagnostics.record(.missingGroupBinding) + diagnostics.record(.missingGroupBindingInvalidExistingBinding) return .unavailable } groupBinding = binding @@ -1785,11 +1825,16 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio ) -> Bool { switch state { // Newly saved groups start waiting for runtime contract admission, then - // become queued. Both states can gain a server binding during the same - // bounded first-cue window, so neither should fall through silently. - case .waitingForServerContract, .queued, .attempting: + // become queued. All three states can still gain a server binding during + // the same bounded first-cue window, so none should fall through + // silently. `needsReconciliation` means the request may already have + // reached the server; `RoutineSyncSender` retries it automatically + // (see its `pendingReplay` branch), so it is not a dead end either. + case .waitingForServerContract, .queued, .attempting, .needsReconciliation: true - case .needsReconciliation, .blocked: + // `blocked` is the only state that requires explicit intervention and + // will never resolve on its own. + case .blocked: false } } diff --git a/Moru/Moru/Platform/TTS/RemoteFirstRoutineGuidancePlayer.swift b/Moru/Moru/Platform/TTS/RemoteFirstRoutineGuidancePlayer.swift index 6a3ea9c..74b5966 100644 --- a/Moru/Moru/Platform/TTS/RemoteFirstRoutineGuidancePlayer.swift +++ b/Moru/Moru/Platform/TTS/RemoteFirstRoutineGuidancePlayer.swift @@ -252,6 +252,7 @@ final class RemoteFirstRoutineGuidancePlayer: // Fixed server cues are optional and must never block completion-screen // progression or speech-input restart. Using a bundle here would play a // different voice than the selected server voice. + diagnostics.record(.commonCueUnavailableForServerVoice) return .completed case .localFile(let url): bundledPlayer.stop() @@ -268,6 +269,7 @@ final class RemoteFirstRoutineGuidancePlayer: case .failedToStart: // The file was already validated before it entered the cache. A late // playback failure is still fail-open for a server-voice common cue. + diagnostics.record(.commonCueLateFailure) return .completed } }