Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//

import Foundation
import OSLog

nonisolated enum RoutineSyncResponseDecodingError:
Error,
Expand All @@ -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,
Expand Down Expand Up @@ -89,20 +96,26 @@ 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
}
if statusCode == 409, code == "COMMON410" {
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:
Expand Down
57 changes: 51 additions & 6 deletions Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -1196,7 +1236,7 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
routineGroupLocalID: routineGroupLocalID,
routineLocalIDs: requestedRoutineIDs
)
diagnostics.record(.missingGroupBinding)
diagnostics.record(.missingGroupBindingInvalidExistingBinding)
return .unavailable
}
groupBinding = binding
Expand Down Expand Up @@ -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
}
}
Expand Down
2 changes: 2 additions & 0 deletions Moru/Moru/Platform/TTS/RemoteFirstRoutineGuidancePlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
}
Expand Down
Loading