Skip to content

Meeting: harden realtime fallback and async jobs completion#39

Open
rmarinsky wants to merge 9 commits into
mainfrom
codex/app-1.14.3-canonical-proxy
Open

Meeting: harden realtime fallback and async jobs completion#39
rmarinsky wants to merge 9 commits into
mainfrom
codex/app-1.14.3-canonical-proxy

Conversation

@rmarinsky

@rmarinsky rmarinsky commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Summary

This PR hardens the meeting transcription path after the Fly.io incident where the jobs service completed transcription, but the macOS app had already stopped waiting after SSE reconnect failures.

Changes

  • Treat async jobs SSE as best-effort: after a stream failure, poll job status and keep waiting until terminal state or a 2-hour timeout.
  • Parse both direct completed payloads and wrapped job-status completed payloads so completed jobs surface result.text.
  • Avoid trusting tiny/unfinalized realtime transcripts for longer meetings; fall back to the async jobs pipeline when the realtime session likely stopped before finalization.
  • Map realtime WebSocket HTTP 402 upgrade refusals to the typed usage-limit error instead of a generic connection failure.
  • Keep the branch current with main after PR [codex] prepare 1.14.4 canonical proxy release #38 so this PR is mergeable and does not revert release version 1.14.4.

Verification

  • xcodebuild -project Diduny.xcodeproj -scheme "Diduny DEV" -configuration Debug build -> BUILD SUCCEEDED.
  • First post-merge build caught a Swift return regression in PushToTalkService; fixed and reran successfully.

Release note

No direct Fly.io deploy from local. Release should continue through PR review and GitHub Actions.

rmarinsky and others added 5 commits June 1, 2026 22:48
…tial

stopMeetingRecording previously used any non-empty realtime transcript.
For longer meetings that stop before Soniox emits an explicit finished
frame, this could surface a truncated transcript instead of the complete
async result.

Add shouldAcceptRealtimeTranscript: accept the realtime text when it was
explicitly finalized, or for short (<30s) recordings; otherwise require a
substantial transcript (>=120 chars) before trusting an unfinalized one,
falling back to the async jobs pipeline when in doubt. Log when a partial
transcript is discarded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds in-progress recording manifests/store, chunked capture and stitcher, sleep flush coordination, push-to-talk hold-start delay (settings/UI/service), AppDelegate cleanup for stop/cancel, model updates, cloud usage mapping, tests, and project build wiring.

Changes

Recording pipeline and controls

Layer / File(s) Summary
Project wiring and versioning
Diduny.xcodeproj/project.pbxproj, project.yml
Adds new sources/tests to project groups/build phases and updates placeholder/version entries.
Settings and protocol
Diduny/Core/Storage/SettingsStorage.swift, Diduny/Core/Protocols/ServiceProtocols.swift
Adds persisted hold-delay settings with sanitization and requires holdStartDelaySeconds on PushToTalk service; updates proxy URL migration and default selection.
Push-to-talk service, UI, AppDelegate, tests
Diduny/Core/Services/PushToTalkService.swift, Diduny/Features/Settings/ShortcutsSettingsView.swift, Diduny/App/AppDelegate+Hotkeys.swift, DidunyTests/PushToTalkServiceTests.swift
Implements sanitized hold-start delay scheduling/cancellation in PushToTalkService; adds UI sliders and notifications; wires AppDelegate to apply delays and reset hands-free mode; tests validate timing, toggle/hands-free behavior, and clamping.
In-progress manifest and store + tests
Diduny/Core/Storage/InProgressRecordingManifest.swift, Diduny/Core/Storage/InProgressRecordingStore.swift, DidunyTests/InProgressRecordingStoreTests.swift
Introduces manifest schema and actor-backed store managing per-recording directories, atomic manifest writes/read, chunk URL generation, discovery, and cleanup with filesystem tests.
System capture chunk rotation and flush
Diduny/Core/Services/SystemAudioCaptureService.swift
Adds chunk rotation state/configuration, per-chunk frame counts, rotation callbacks, synchronous flush-for-sleep, and guards to avoid recovery during shutdown.
Chunk stitcher and tests
Diduny/Core/Services/MeetingChunkStitcher.swift, DidunyTests/MeetingChunkStitcherTests.swift
Adds MeetingChunkStitcher.stitch() handling single/multi-chunk merging, skips unreadable chunks, returns metadata; tests cover error paths and duration/frame assertions.
MeetingRecorderService rework
Diduny/Core/Services/MeetingRecorderService.swift
Reworks recorder to allocate recording IDs, use in-progress directories, wire chunk rotation callbacks, write initial manifest, stitch on stop (with fallback), update manifest, and cleanup on cancel.
Sleep flush coordinator and AppDelegate integration
Diduny/Core/Services/SleepFlushCoordinator.swift, Diduny/App/AppDelegate.swift, DidunyTests/SleepFlushCoordinatorTests.swift
Adds SleepFlushCoordinator that synchronously invokes flush on will-sleep; AppDelegate bridge flushes meeting chunk and marks manifest interrupted, releases App Nap tokens, and handles wake recovery; tests validate synchronous behavior and observer cleanup.
Meeting/translation stop, transcript acceptance, and cleanup
Diduny/App/AppDelegate+MeetingRecording.swift, Diduny/App/AppDelegate+MeetingTranslationRecording.swift
Capture in-progress recording IDs before stop, implement realtime transcript acceptance rules, and call in-progress directory cleanup after successful library save and on cancellation/error.
Recording model, library preservation, and migration tests
Diduny/Core/Models/Recording.swift, Diduny/Core/Storage/RecordingsLibraryStorage.swift, DidunyTests/RecordingModelMigrationTests.swift
Adds RecoverySource and recoverySource on Recording, partiallyRecovered status and display label; preserves recoverySource during replaceStoredAudioFile; adds migration and round-trip tests.
Cloud realtime usage handling and Auth tweak
Diduny/Core/Services/CloudRealtimeService.swift, Diduny/Core/Services/AuthService.swift
Detects HTTP 402 WebSocket upgrade refusals and maps to usageLimitExceeded with usage refresh and stops reconnect attempts; small AuthService return consolidation.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly related PRs

Suggested labels

release:patch

Poem

🐰 I stitched the chunks while shadows slept,

Hold-delays kept starts from being leapt,
Manifests saved each little hop,
Sleep flush whispered — then cleanup: chop!
Hooray — a tidy, tuneful drop.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Meeting: harden realtime fallback and async jobs completion' accurately captures the main objective of improving realtime transcript handling and fallback logic, and is closely aligned with the actual changes in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/app-1.14.3-canonical-proxy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Diduny/Core/Services/MeetingRecorderService.swift (1)

194-288: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix last-chunk duration computation when stitching skips chunks

MeetingChunkStitcher.Result.totalDurationSeconds is computed from totalFrames written into the stitched output (appendFile increments totalFrames as it reads/writes), not from appendedChunkCount. Also, if the first readable chunk fails mid-append, totalFrames may still include partially written frames even though that chunk is added to skippedChunks.

Because MeetingRecorderService.stopRecording() sets:
lastDuration = max(0, stitch.totalDurationSeconds - sum(prior manifest.chunks[*].durationSeconds)),
this is only correct if none of the preceding chunks that contribute to the stitched duration were skipped/failed during stitching. If stitch.skippedChunks contains any earlier chunk indices, the subtraction overcounts “prior” duration and can drive lastDuration too low (including clamping to 0 for very short last chunks).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Diduny/Core/Services/MeetingRecorderService.swift` around lines 194 - 288,
stopRecording’s computation of lastDuration wrongly subtracts durations of all
preceding chunks from stitch.totalDurationSeconds even when some preceding
chunks were skipped during stitching; update the logic in stopRecording() to
subtract only the durations of preceding chunks that were actually appended to
the stitched output by using MeetingChunkStitcher.Result.skippedChunks (or
equivalent) to exclude skipped chunk indices when summing priorDurations from
manifest.chunks (i.e., compute priorDurations = sum of
manifest.chunks[0..lastIdx-1].durationSeconds where the chunk index is NOT in
stitch.skippedChunks), then set lastDuration = max(0,
stitch.totalDurationSeconds - priorDurations). Ensure you reference
stopRecording(), MeetingChunkStitcher.Result.totalDurationSeconds and
.skippedChunks, and manifest.chunks when making the change.
🧹 Nitpick comments (3)
DidunyTests/RecordingModelMigrationTests.swift (1)

129-160: 💤 Low value

Consider using CaseIterable for automatic exhaustiveness.

The current test uses a manually maintained allCases array and relies on the compiler to enforce switch exhaustiveness. While this works, it could get out of sync if someone adds a new case to ProcessingStatus but forgets to update the array. If ProcessingStatus can conform to CaseIterable, you could replace lines 133-140 with:

let allCases = Recording.ProcessingStatus.allCases

This would automatically include all cases and eliminate the manual maintenance. However, only apply this if ProcessingStatus doesn't have associated values or other constraints that prevent CaseIterable conformance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@DidunyTests/RecordingModelMigrationTests.swift` around lines 129 - 160, Make
Recording.ProcessingStatus conform to CaseIterable (if it has no associated
values) and update the test_processingStatus_switchIsExhaustive() to use
Recording.ProcessingStatus.allCases instead of the manually maintained allCases
array; specifically, add CaseIterable to the ProcessingStatus enum declaration
and replace the hardcoded array in the test with let allCases =
Recording.ProcessingStatus.allCases so the test automatically stays exhaustive.
Diduny/App/AppDelegate.swift (1)

39-46: ⚡ Quick win

Use NSLog with the repo prefixes for the new sleep-flow logs.

These new AppDelegate/sleep lifecycle logs should follow the project’s required NSLog prefix format instead of Log.app / Log.recording. As per coding guidelines, **/*.swift: Use NSLog() for logging with prefixes: [Diduny] for AppDelegate flow, [Transcription] for cloud API calls, [AppState] for state changes`.

Also applies to: 70-76, 319-335

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Diduny/App/AppDelegate.swift` around lines 39 - 46, Replace the in-file
logging calls in AppDelegate's sleep lifecycle with NSLog using the repo
prefixes: in the flushActiveRecordingForSleep flow (function
flushActiveRecordingForSleep) change Log.recording.info / Log.app uses to NSLog
and prefix messages with [Diduny] (e.g. NSLog("[Diduny] ...")). Apply the same
replacement for the other identified blocks in this file (around the regions
covering lines 70-76 and 319-335) so all AppDelegate/sleep lifecycle messages
use NSLog("[Diduny] ...") instead of Log.* calls.
Diduny/App/AppDelegate+Hotkeys.swift (1)

96-100: ⚡ Quick win

Use NSLog("[Diduny] …") for these new hotkey-setting logs.

These are AppDelegate flow messages, so they should use the repo’s required NSLog prefix format instead of Log.app.info(...). As per coding guidelines, **/*.swift: Use NSLog() for logging with prefixes: [Diduny] for AppDelegate flow, [Transcription] for cloud API calls, [AppState] for state changes`.

Also applies to: 160-164

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Diduny/App/AppDelegate`+Hotkeys.swift around lines 96 - 100, Replace the
Log.app.info(...) calls in the hotkey handlers with NSLog(...) using the
required prefix "[Diduny] " (e.g., in pushToTalkHoldStartDelayChanged(_:) change
the Log.app.info message to NSLog("[Diduny] Dictation modifier hold delay
changed to: \(String(format: \"%.1f\", delay))s")). Do the same for the other
hotkey-setting log statements in AppDelegate+Hotkeys.swift (the other hotkey
handler methods around the same section) so all AppDelegate flow messages use
NSLog with the "[Diduny]" prefix and preserve the original formatted message
content.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Diduny/App/AppDelegate.swift`:
- Around line 51-82: The manifest update is being run inside Task { ... } and
returns before persistence completes, so make the InProgressRecordingStore
read/write happen synchronously on the flush path: remove the Task wrapper
around the read/write block that touches recordingId,
manifest.recordingInterruptedBySleep, manifest.lastWriteAt and
manifest.chunks[...] and instead await the store.readManifest(for: recordingId)
and try await store.writeManifest(manifest, for: recordingId) inline (or call
the store's synchronous/blocking write API) so the method performing the flush
(e.g., flushActiveRecordingForSleep()/synchronousFlushForSleep()) does not
return until the manifest has been persisted; if that method cannot be async,
block until completion (DispatchSemaphore or synchronous API) and only call
releaseActivityTokens?() and return flushSucceeded after the write
succeeds/fails and logging completes.

In `@Diduny/Core/Models/Recording.swift`:
- Around line 34-37: The lifecycle bug is that Recording.recoverySource is never
set when a recovered recording is saved: update
RecordingsLibraryStorage.saveRecording (both Data and URL overloads) to accept
and persist an optional RecoverySource and set Recording.recoverySource at
creation for recovered saves; change AppDelegate.recoverRecording(from:) to pass
the recovered RecoverySource into the save path (or construct the recovered
Recording with recoverySource before calling discardRecovery(state:)) so
recovered entries get the "set once at recovery-save time" value, and ensure
RecordingsLibraryStorage.replaceStoredAudioFile continues to preserve
recoverySource when reconstructing recordings.

In `@Diduny/Core/Services/PushToTalkService.swift`:
- Around line 259-280: The isKeyCurrentlyPressed logic uses family-wide flags
(e.g. .shift/.option/.command/.control) for side-specific cases which causes the
right/left pairs to stick when the opposite side is held; update
isKeyCurrentlyPressed so that for side-specific cases (.leftShift, .rightShift,
.leftOption, .rightOption, .leftCommand, .rightCommand, .leftControl,
.rightControl) you determine pressed state by matching the physical keyCode only
(keep the existing numeric keyCode checks like keyCode ==
56/60/58/61/55/54/59/62) and remove the flags.contains(...) check for those
cases, while preserving the capsLock case to still check
flags.contains(.capsLock). This change should be applied inside the
isKeyCurrentlyPressed function and use the existing selectedKey enum case names
to locate the branches.

In `@DidunyTests/SleepFlushCoordinatorTests.swift`:
- Around line 1-2: The test file SleepFlushCoordinatorTests.swift references
AppKit APIs (NSWorkspace used in the tests) but only imports XCTest and
`@testable` import Diduny; add import AppKit at the top of
SleepFlushCoordinatorTests.swift so the NSWorkspace symbols resolve (locate the
file and add the single-line import near the other imports where NSWorkspace is
used in the SleepFlushCoordinatorTests tests).

In `@project.yml`:
- Around line 32-33: The project.yml file was manually edited to set
MARKETING_VERSION and CURRENT_PROJECT_VERSION; revert this manual change so
version fields are managed by CI/git tags instead of hand-editing, removing or
restoring MARKETING_VERSION and CURRENT_PROJECT_VERSION to the
CI-controlled/default state and re-running the generator that populates them;
add (or update) a CI validation step that checks MARKETING_VERSION and
CURRENT_PROJECT_VERSION against the Git tag and fails the build if they diverge,
and add a short inline comment near MARKETING_VERSION to warn contributors not
to edit it by hand.

---

Outside diff comments:
In `@Diduny/Core/Services/MeetingRecorderService.swift`:
- Around line 194-288: stopRecording’s computation of lastDuration wrongly
subtracts durations of all preceding chunks from stitch.totalDurationSeconds
even when some preceding chunks were skipped during stitching; update the logic
in stopRecording() to subtract only the durations of preceding chunks that were
actually appended to the stitched output by using
MeetingChunkStitcher.Result.skippedChunks (or equivalent) to exclude skipped
chunk indices when summing priorDurations from manifest.chunks (i.e., compute
priorDurations = sum of manifest.chunks[0..lastIdx-1].durationSeconds where the
chunk index is NOT in stitch.skippedChunks), then set lastDuration = max(0,
stitch.totalDurationSeconds - priorDurations). Ensure you reference
stopRecording(), MeetingChunkStitcher.Result.totalDurationSeconds and
.skippedChunks, and manifest.chunks when making the change.

---

Nitpick comments:
In `@Diduny/App/AppDelegate.swift`:
- Around line 39-46: Replace the in-file logging calls in AppDelegate's sleep
lifecycle with NSLog using the repo prefixes: in the
flushActiveRecordingForSleep flow (function flushActiveRecordingForSleep) change
Log.recording.info / Log.app uses to NSLog and prefix messages with [Diduny]
(e.g. NSLog("[Diduny] ...")). Apply the same replacement for the other
identified blocks in this file (around the regions covering lines 70-76 and
319-335) so all AppDelegate/sleep lifecycle messages use NSLog("[Diduny] ...")
instead of Log.* calls.

In `@Diduny/App/AppDelegate`+Hotkeys.swift:
- Around line 96-100: Replace the Log.app.info(...) calls in the hotkey handlers
with NSLog(...) using the required prefix "[Diduny] " (e.g., in
pushToTalkHoldStartDelayChanged(_:) change the Log.app.info message to
NSLog("[Diduny] Dictation modifier hold delay changed to: \(String(format:
\"%.1f\", delay))s")). Do the same for the other hotkey-setting log statements
in AppDelegate+Hotkeys.swift (the other hotkey handler methods around the same
section) so all AppDelegate flow messages use NSLog with the "[Diduny]" prefix
and preserve the original formatted message content.

In `@DidunyTests/RecordingModelMigrationTests.swift`:
- Around line 129-160: Make Recording.ProcessingStatus conform to CaseIterable
(if it has no associated values) and update the
test_processingStatus_switchIsExhaustive() to use
Recording.ProcessingStatus.allCases instead of the manually maintained allCases
array; specifically, add CaseIterable to the ProcessingStatus enum declaration
and replace the hardcoded array in the test with let allCases =
Recording.ProcessingStatus.allCases so the test automatically stays exhaustive.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c3e02e05-64ba-4a92-a5e1-8078fac44ecb

📥 Commits

Reviewing files that changed from the base of the PR and between 656e99f and 2f3a496.

📒 Files selected for processing (24)
  • Diduny.xcodeproj/project.pbxproj
  • Diduny/App/AppDelegate+Hotkeys.swift
  • Diduny/App/AppDelegate+MeetingRecording.swift
  • Diduny/App/AppDelegate+MeetingTranslationRecording.swift
  • Diduny/App/AppDelegate.swift
  • Diduny/Core/Models/Recording.swift
  • Diduny/Core/Protocols/ServiceProtocols.swift
  • Diduny/Core/Services/AuthService.swift
  • Diduny/Core/Services/MeetingChunkStitcher.swift
  • Diduny/Core/Services/MeetingRecorderService.swift
  • Diduny/Core/Services/PushToTalkService.swift
  • Diduny/Core/Services/SleepFlushCoordinator.swift
  • Diduny/Core/Services/SystemAudioCaptureService.swift
  • Diduny/Core/Storage/InProgressRecordingManifest.swift
  • Diduny/Core/Storage/InProgressRecordingStore.swift
  • Diduny/Core/Storage/RecordingsLibraryStorage.swift
  • Diduny/Core/Storage/SettingsStorage.swift
  • Diduny/Features/Settings/ShortcutsSettingsView.swift
  • DidunyTests/InProgressRecordingStoreTests.swift
  • DidunyTests/MeetingChunkStitcherTests.swift
  • DidunyTests/PushToTalkServiceTests.swift
  • DidunyTests/RecordingModelMigrationTests.swift
  • DidunyTests/SleepFlushCoordinatorTests.swift
  • project.yml

Comment thread Diduny/App/AppDelegate.swift
Comment thread Diduny/Core/Models/Recording.swift Outdated
Comment on lines +34 to +37
/// Non-nil when this recording was saved via a recovery path rather than a normal stop.
/// Drives the "Recovered" badge in the library and the detail-view notice.
/// Set once at recovery-save time; never cleared.
var recoverySource: RecoverySource?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify recoverySource is only set at creation and preserved thereafter

echo "=== Search for assignments to recoverySource ==="
rg -n --type=swift 'recoverySource\s*[:=]' -C 3

echo ""
echo "=== Search for Recording initializer calls with recoverySource ==="
rg -n --type=swift 'Recording\(' -A 15 | rg -A 10 'recoverySource'

echo ""
echo "=== Search for recovery-related save calls ==="
rg -n --type=swift -C 5 'saveRecording|replaceStoredAudioFile'

Repository: rmarinsky/Diduny

Length of output: 26538


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for RecoverySource usage (excluding tests) ==="
rg -n --type=swift 'RecoverySource' -S . --glob '!DidunyTests/**'

echo ""
echo "=== Search for recoverySource: in non-test Swift files ==="
rg -n --type=swift 'recoverySource\s*:' -S . --glob '!DidunyTests/**'

echo ""
echo "=== Search for any reference to 'Recovered' badge / recovery-save flow ==="
rg -n --type=swift 'Recovered|recovery save|recovery-save|recoverySave|Orphan|orphaned' -S Diduny --glob '!**/*Tests*' --glob '!**/Vendor/**'

echo ""
echo "=== Inspect RecordingsLibraryStorage.swift around replaceStoredAudioFile and related metadata write ==="
rg -n --type=swift 'replaceStoredAudioFile|saveMetadata|Recording\(' Diduny/Core/Storage/RecordingsLibraryStorage.swift
echo ""
sed -n '240,360p' Diduny/Core/Storage/RecordingsLibraryStorage.swift

Repository: rmarinsky/Diduny

Length of output: 6741


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for orphanedSession / partiallyRecovered usages (non-tests) ==="
rg -n --type=swift 'orphanedSession|partiallyRecovered' Diduny --glob '!**/*Tests*'

echo ""
echo "=== Inspect AppDelegate recovery/orphaned flow around checkForOrphanedRecordings ==="
sed -n '240,520p' Diduny/App/AppDelegate.swift | sed -n '1,300p'

echo ""
echo "=== Inspect RecordingsLibraryStorage.swift saveRecording initializer calls (look for recoverySource argument) ==="
sed -n '1,220p' Diduny/Core/Storage/RecordingsLibraryStorage.swift

echo ""
echo "=== Inspect recovery detector/manager code that assembles recovered recordings (if present) ==="
rg -n --type=swift 'OrphanedRecordingDetector|Recovery|recoverySave|recovery save|assemble' Diduny/Core/Storage Diduny/App --glob '!**/*Tests*'

Repository: rmarinsky/Diduny

Length of output: 23898


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Inspect Recording.swift around RecoverySource + recoverySource property and Codable/init ==="
rg -n --type=swift 'enum RecoverySource|var recoverySource|CodingKeys|init\(|encode|decode' Diduny/Core/Models/Recording.swift
sed -n '1,220p' Diduny/Core/Models/Recording.swift

echo ""
echo "=== Inspect RecordingsLibraryStorage.swift loadMetadata/saveMetadata (look for recoverySource encoding/decoding) ==="
rg -n --type=swift 'loadMetadata|saveMetadata|JSONEncoder|JSONDecoder|recoverySource' Diduny/Core/Storage/RecordingsLibraryStorage.swift
sed -n '220,340p' Diduny/Core/Storage/RecordingsLibraryStorage.swift

Repository: rmarinsky/Diduny

Length of output: 8437


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for OrphanedRecordingDetector implementation/usages ==="
rg -n --type=swift 'OrphanedRecordingDetector|orphaned recording|orphanedRecording|orphanedSession|RecoverySource' Diduny --glob '!**/*Tests*'
echo ""
echo "=== Search for 'saveMetadata()' / direct 'recordings[' mutation with recoverySource preservation ==="
rg -n --type=swift 'recordings\[\s*\w+\s*\]\s*=\s*Recording\(|Recording\(' Diduny/Core/Storage --glob '!**/*Tests*'

Repository: rmarinsky/Diduny

Length of output: 1784


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search: where is tempFilePath used? (non-tests) ==="
rg -n --type=swift 'tempFilePath' Diduny --glob '!**/*Tests*'

echo ""
echo "=== Search: any RecordingsLibraryStorage.saveRecording calls within recovery flow (AppDelegate.swift) ==="
rg -n --type=swift 'recoverRecording\(from:|discardRecovery\(state:|checkForOrphanedRecordings|hasOrphanedRecording' Diduny/App/AppDelegate.swift
echo ""
rg -n --type=swift 'RecordingsLibraryStorage\.shared\.saveRecording\(' Diduny/App/AppDelegate.swift --glob '!**/*Tests*'
echo ""
rg -n --type=swift 'saveRecording\(' Diduny/App/AppDelegate.swift --glob '!**/*Tests*'

echo ""
echo "=== Search: any other files create Recording with recoverySource: (non-tests) ==="
rg -n --type=swift 'recoverySource\s*:' Diduny --glob '!**/*Tests*'
echo ""
rg -n --type=swift 'recoverySource\s*=' Diduny --glob '!**/*Tests*'
echo ""
rg -n --type=swift 'RecoveryStateManager\.shared\.loadState\(' Diduny --glob '!**/*Tests*'

Repository: rmarinsky/Diduny

Length of output: 1388


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for 'orphaned' across non-test code ==="
rg -n --type=swift 'orphaned|Orphaned' Diduny --glob '!**/*Tests*'

echo ""
echo "=== Search for 'recoverySource' across non-test code ==="
rg -n --type=swift 'recoverySource' Diduny --glob '!**/*Tests*'

echo ""
echo "=== List files under Diduny/Core/Storage (to find missing detector/assembler) ==="
ls -R Diduny/Core/Storage | sed -n '1,200p'

echo ""
echo "=== Inspect RecoveryStateManager.swift ==="
sed -n '1,200p' Diduny/Core/Storage/RecoveryStateManager.swift

Repository: rmarinsky/Diduny

Length of output: 4321


Fix recoverySource lifecycle contract: recovery flow never sets it in production code, but replacement preserves it.

  • RecordingsLibraryStorage.saveRecording(...) (both Data + URL overloads) creates Recording without recoverySource, so newly saved recovered entries default to nil (no “set once at recovery-save time” path found).
  • AppDelegate.recoverRecording(from:) only transcribes/cleans text and then calls discardRecovery(state:); it doesn’t create a recovered Recording in the library.
  • RecordingsLibraryStorage.replaceStoredAudioFile(...) is the only place that reconstructs Recording with recoverySource: recording.recoverySource, and no other non-test code mutates/clears recoverySource.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Diduny/Core/Models/Recording.swift` around lines 34 - 37, The lifecycle bug
is that Recording.recoverySource is never set when a recovered recording is
saved: update RecordingsLibraryStorage.saveRecording (both Data and URL
overloads) to accept and persist an optional RecoverySource and set
Recording.recoverySource at creation for recovered saves; change
AppDelegate.recoverRecording(from:) to pass the recovered RecoverySource into
the save path (or construct the recovered Recording with recoverySource before
calling discardRecovery(state:)) so recovered entries get the "set once at
recovery-save time" value, and ensure
RecordingsLibraryStorage.replaceStoredAudioFile continues to preserve
recoverySource when reconstructing recordings.

Comment thread Diduny/Core/Services/PushToTalkService.swift Outdated
Comment thread DidunyTests/SleepFlushCoordinatorTests.swift
Comment thread project.yml
Comment on lines +32 to +33
MARKETING_VERSION: "1.14.4"
CURRENT_PROJECT_VERSION: "4"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify CI versioning approach and check for conflicting tags

echo "=== CI configuration files ==="
fd -t f 'yml|yaml' .github/workflows | head -5

echo ""
echo "=== Recent version tags ==="
git tag --sort=-version:refname | grep '^v' | head -10

echo ""
echo "=== Current branch version settings ==="
grep -A2 'MARKETING_VERSION\|CURRENT_PROJECT_VERSION' project.yml

Repository: rmarinsky/Diduny

Length of output: 402


Disallow hand-editing MARKETING_VERSION in project.yml (workflow violation)
project.yml now contains:

MARKETING_VERSION: "1.14.4"
CURRENT_PROJECT_VERSION: "4"

This conflicts with the documented workflow that CI derives versioning from git tags and that MARKETING_VERSION in project.yml should not be manually edited—risking mismatches between the tag/release metadata and what’s embedded in the app/update artifacts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@project.yml` around lines 32 - 33, The project.yml file was manually edited
to set MARKETING_VERSION and CURRENT_PROJECT_VERSION; revert this manual change
so version fields are managed by CI/git tags instead of hand-editing, removing
or restoring MARKETING_VERSION and CURRENT_PROJECT_VERSION to the
CI-controlled/default state and re-running the generator that populates them;
add (or update) a CI validation step that checks MARKETING_VERSION and
CURRENT_PROJECT_VERSION against the Git tag and fails the build if they diverge,
and add a short inline comment near MARKETING_VERSION to warn contributors not
to edit it by hand.

rmarinsky and others added 2 commits June 4, 2026 16:11
…t check

The proxy now refuses realtime WS upgrades with HTTP 402 once a user is over
the usage quota. Previously a 402 (when the local usage pre-check was nil or
stale) surfaced as a failed receive → generic reconnect storm (2s/4s/8s) and a
"Connection lost" message instead of the real reason.

- Detect a 402 upgrade response in handleDisconnect (synchronously, to stop the
  reconnect) and on the initial config send, mapping it to a typed
  usageLimitExceeded error + a usage refresh.
- shouldAcceptRealtimeTranscript: measure trimmed length for the 120-char
  substantiality threshold so a whitespace-padded transcript can't masquerade
  as substantial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AppDelegate (sleep flush): persist the in-progress manifest synchronously
  before flushActiveRecordingForSleep() returns. The write was deferred to a
  Task{} and could be lost if the app suspended first, leaving recovery with
  stale state. Block on a detached task (no MainActor deadlock) with a 2s guard.
- PushToTalkService: distinguish left/right modifiers via device-dependent
  masks (NX_DEVICE*KEYMASK) instead of family flags (.shift/.option/...), so a
  side-specific key detects its own key-up while the opposite side is held —
  previously hold-to-record could stick.
- SleepFlushCoordinatorTests: import AppKit (uses NSWorkspace) so the test
  target compiles under CI.
- project.yml: reset MARKETING_VERSION/CURRENT_PROJECT_VERSION to neutral
  placeholders — CI derives the real version from the git tag (per CLAUDE.md);
  hand-editing risked tag/artifact mismatch.
- Recording.recoverySource: correct the doc comment to reality (no save path
  sets it yet) + TODO, instead of claiming an unimplemented contract.

Build + full DidunyTests suite pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
Diduny/Core/Models/Recording.swift (1)

43-43: ⚡ Quick win

Enforce the “never cleared” contract in the model.

Line 43 is mutable (var), but the contract above says the value should be preserved once set. Making this immutable prevents accidental clearing and aligns with the persistence invariant.

Suggested change
-    var recoverySource: RecoverySource?
+    let recoverySource: RecoverySource?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Diduny/Core/Models/Recording.swift` at line 43, Change the mutable property
"recoverySource" to an immutable constant to enforce the "never cleared"
contract: replace "var recoverySource: RecoverySource?" with "let
recoverySource: RecoverySource?" in the Recording model, then update any
initializers, Codable init(from:)/Decodable paths, factory methods, and places
that previously assigned to recoverySource so they set it at construction time
instead of later; if any code mutates recoverySource, refactor that logic to
produce a new Recording or accept the value in the initializer.
Diduny/Core/Services/CloudRealtimeService.swift (1)

462-462: ⚡ Quick win

Use the required transcription log format here.

This new cloud-API log should use NSLog("[Transcription] ...") so it follows the repo’s searchable logging contract instead of going through Log.transcription.

As per coding guidelines, **/*.swift: Use NSLog() for logging with prefixes: [Diduny] for AppDelegate flow, [Transcription] for cloud API calls, [AppState] for state changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Diduny/Core/Services/CloudRealtimeService.swift` at line 462, Replace the
Log.transcription.warning call with an NSLog-based transcription log so it
follows the repo logging contract: locate the call to
Log.transcription.warning("Cloud RT: WS upgrade returned 402 — usage limit, not
reconnecting") in CloudRealtimeService (the WS upgrade/response handling method)
and change it to emit an NSLog with the [Transcription] prefix and the same
message (i.e., use NSLog("[Transcription] Cloud RT: WS upgrade returned 402 —
usage limit, not reconnecting")). Ensure no other Log.transcription usage
remains for this cloud-API message.
Diduny/App/AppDelegate.swift (1)

87-90: ⚡ Quick win

Use NSLog for the new AppDelegate sleep-path log.

This adds AppDelegate-flow logging through Log.recording.error, which diverges from the repo logging contract for Swift files. Please switch this timeout log to NSLog("[Diduny] ...") so lifecycle diagnostics stay consistent. As per coding guidelines, "**/*.swift: Use NSLog() for logging with prefixes: [Diduny] for AppDelegate flow, [Transcription] for cloud API calls, [AppState] for state changes".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Diduny/App/AppDelegate.swift` around lines 87 - 90, The timeout log in the
AppDelegate sleep path currently uses Log.recording.error (see sem.wait timeout
branch and the existing log string) which violates the Swift logging contract;
replace that Log.recording.error call with an NSLog call using the AppDelegate
prefix, e.g. NSLog("[Diduny] manifest update timed out (2s) — proceeding without
confirmed persist"), preserving the original message text and context around the
sem.wait timeout handling so lifecycle diagnostics use NSLog as required.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Diduny/App/AppDelegate.swift`:
- Around line 53-61: The sleep-flush path is blocking for up to 2s via the
DispatchSemaphore wait around a Task.detached; change this so persistence is
bounded to the coordinator budget: either perform the manifest write
synchronously on the current thread (remove Task.detached + semaphore) or, if
keeping the detached task, cap the semaphore wait to the SleepFlushCoordinator
budget (~250ms) and treat a timeout as a flush failure (log/report/return
failure) rather than proceeding as if the write succeeded; make the same change
for the other instance at the 87-90 region, ensure sem.signal() runs on all code
paths, and update any callers of SleepFlushCoordinator.flushCurrentChunk to
handle a failed/timed-out flush result.

In `@Diduny/Core/Services/CloudRealtimeService.swift`:
- Around line 433-442: The code reads UsageService.shared.cachedUsage into
`usage` before calling await UsageService.shared.refresh(), so the created
RealtimeTranscriptionError uses a stale snapshot; change the order in
usageLimitUpgradeError() to call await UsageService.shared.refresh() first, then
read UsageService.shared.cachedUsage (falling back to the original snapshot only
if the refreshed cache is nil), and apply the same change in the connect path
that constructs the usageLimitExceeded error (the connect method) so the UI gets
the refreshed quota values rather than the pre-refresh snapshot.
- Around line 456-474: The 402 branch exits early without tearing down the live
socket, leaving webSocketTask/urlSession retained and pingTask running; before
returning, cancel and cleanup the socket state (cancel webSocketTask, invalidate
or finish the urlSession if owned, clear/await/ cancel pingTask) and reset any
connection-related properties so the normal cleanup paths aren't required;
perform this teardown inside the same Task block (or synchronously before it)
that calls UsageService and before calling self.onError? /
self.onConnectionStatusChanged? so that webSocketTask, urlSession and pingTask
are stopped (refer to webSocketTask, urlSession, pingTask, disconnect()).

---

Nitpick comments:
In `@Diduny/App/AppDelegate.swift`:
- Around line 87-90: The timeout log in the AppDelegate sleep path currently
uses Log.recording.error (see sem.wait timeout branch and the existing log
string) which violates the Swift logging contract; replace that
Log.recording.error call with an NSLog call using the AppDelegate prefix, e.g.
NSLog("[Diduny] manifest update timed out (2s) — proceeding without confirmed
persist"), preserving the original message text and context around the sem.wait
timeout handling so lifecycle diagnostics use NSLog as required.

In `@Diduny/Core/Models/Recording.swift`:
- Line 43: Change the mutable property "recoverySource" to an immutable constant
to enforce the "never cleared" contract: replace "var recoverySource:
RecoverySource?" with "let recoverySource: RecoverySource?" in the Recording
model, then update any initializers, Codable init(from:)/Decodable paths,
factory methods, and places that previously assigned to recoverySource so they
set it at construction time instead of later; if any code mutates
recoverySource, refactor that logic to produce a new Recording or accept the
value in the initializer.

In `@Diduny/Core/Services/CloudRealtimeService.swift`:
- Line 462: Replace the Log.transcription.warning call with an NSLog-based
transcription log so it follows the repo logging contract: locate the call to
Log.transcription.warning("Cloud RT: WS upgrade returned 402 — usage limit, not
reconnecting") in CloudRealtimeService (the WS upgrade/response handling method)
and change it to emit an NSLog with the [Transcription] prefix and the same
message (i.e., use NSLog("[Transcription] Cloud RT: WS upgrade returned 402 —
usage limit, not reconnecting")). Ensure no other Log.transcription usage
remains for this cloud-API message.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 85034382-3f7d-471d-b119-22941de14c7f

📥 Commits

Reviewing files that changed from the base of the PR and between 2f3a496 and 3b012d8.

📒 Files selected for processing (7)
  • Diduny/App/AppDelegate+MeetingRecording.swift
  • Diduny/App/AppDelegate.swift
  • Diduny/Core/Models/Recording.swift
  • Diduny/Core/Services/CloudRealtimeService.swift
  • Diduny/Core/Services/PushToTalkService.swift
  • DidunyTests/SleepFlushCoordinatorTests.swift
  • project.yml
🚧 Files skipped from review as they are similar to previous changes (3)
  • DidunyTests/SleepFlushCoordinatorTests.swift
  • Diduny/Core/Services/PushToTalkService.swift
  • Diduny/App/AppDelegate+MeetingRecording.swift

Comment on lines +53 to +61
// Persist the manifest synchronously before returning: the app can be
// suspended the instant this sleep-flush returns, so a deferred async
// write could be lost and leave recovery reading stale state after
// wake/crash. Block on a detached task (detached → not MainActor-bound,
// so waiting on the main thread can't deadlock the actor) with a short
// timeout so a wedged store can't hang the sleep transition.
let sem = DispatchSemaphore(value: 0)
Task.detached(priority: .userInitiated) {
defer { sem.signal() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Keep the sleep-flush path within the coordinator’s timing budget.

SleepFlushCoordinator.flushCurrentChunk is documented to finish on the power-management thread within ~250 ms, but this path can now block for up to 2 seconds on actor/file I/O. That can stall system sleep and still leaves you without a confirmed manifest write if the timeout fires. Please move this persistence onto a truly bounded synchronous path, or at minimum cap the wait to the coordinator budget and treat timeout as a failed flush.

Also applies to: 87-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Diduny/App/AppDelegate.swift` around lines 53 - 61, The sleep-flush path is
blocking for up to 2s via the DispatchSemaphore wait around a Task.detached;
change this so persistence is bounded to the coordinator budget: either perform
the manifest write synchronously on the current thread (remove Task.detached +
semaphore) or, if keeping the detached task, cap the semaphore wait to the
SleepFlushCoordinator budget (~250ms) and treat a timeout as a flush failure
(log/report/return failure) rather than proceeding as if the write succeeded;
make the same change for the other instance at the 87-90 region, ensure
sem.signal() runs on all code paths, and update any callers of
SleepFlushCoordinator.flushCurrentChunk to handle a failed/timed-out flush
result.

Comment on lines +433 to +442
private func usageLimitUpgradeError() async -> RealtimeTranscriptionError? {
guard (webSocketTask?.response as? HTTPURLResponse)?.statusCode == 402 else {
return nil
}
let usage = await UsageService.shared.cachedUsage
await UsageService.shared.refresh()
return .usageLimitExceeded(
usedHours: usage?.usedHours ?? 0,
limitHours: usage?.limitHours ?? 5
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Build the usage-limit error from the refreshed cache.

Both 402 paths snapshot cachedUsage before refresh(), then format usageLimitExceeded from that stale snapshot. The connect path bubbles error.localizedDescription straight into UI state, so a real quota hit can still show fallback numbers like 0.0h / 5h even when the refresh succeeded. Refresh first, then read the updated cache, and only fall back to the pre-refresh snapshot if the refresh did not populate it.

Also applies to: 465-470

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Diduny/Core/Services/CloudRealtimeService.swift` around lines 433 - 442, The
code reads UsageService.shared.cachedUsage into `usage` before calling await
UsageService.shared.refresh(), so the created RealtimeTranscriptionError uses a
stale snapshot; change the order in usageLimitUpgradeError() to call await
UsageService.shared.refresh() first, then read UsageService.shared.cachedUsage
(falling back to the original snapshot only if the refreshed cache is nil), and
apply the same change in the connect path that constructs the usageLimitExceeded
error (the connect method) so the UI gets the refreshed quota values rather than
the pre-refresh snapshot.

Comment on lines +456 to +474
// A refused WS upgrade (HTTP 402 usage limit) lands here via the receive
// loop with no close code. Reconnecting is futile — the server will keep
// refusing — and would surface a generic "Connection lost" instead of the
// real reason. Detect it synchronously to stop the reconnect, then surface
// the typed usage error with the best numbers we have.
if (webSocketTask?.response as? HTTPURLResponse)?.statusCode == 402 {
Log.transcription.warning("Cloud RT: WS upgrade returned 402 — usage limit, not reconnecting")
Task { [weak self] in
guard let self else { return }
let usage = await UsageService.shared.cachedUsage
await UsageService.shared.refresh()
self.onError?(RealtimeTranscriptionError.usageLimitExceeded(
usedHours: usage?.usedHours ?? 0,
limitHours: usage?.limitHours ?? 5
))
self.onConnectionStatusChanged?(.failed("Cloud usage limit reached"))
}
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tear down the failed socket before returning on 402.

This branch returns before either cleanup path runs. Since the ping loop is already started earlier, the dead webSocketTask/urlSession stay retained and pingTask keeps firing until someone manually calls disconnect(). Cancel/reset the live socket state here before returning.

Suggested fix
         if (webSocketTask?.response as? HTTPURLResponse)?.statusCode == 402 {
+            pingTask?.cancel()
+            pingTask = nil
+            receiveTask?.cancel()
+            receiveTask = nil
+            webSocketTask?.cancel(with: .normalClosure, reason: nil)
+            webSocketTask = nil
+            urlSession?.invalidateAndCancel()
+            urlSession = nil
             Log.transcription.warning("Cloud RT: WS upgrade returned 402 — usage limit, not reconnecting")
             Task { [weak self] in
                 guard let self else { return }
                 let usage = await UsageService.shared.cachedUsage
                 await UsageService.shared.refresh()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// A refused WS upgrade (HTTP 402 usage limit) lands here via the receive
// loop with no close code. Reconnecting is futile — the server will keep
// refusing — and would surface a generic "Connection lost" instead of the
// real reason. Detect it synchronously to stop the reconnect, then surface
// the typed usage error with the best numbers we have.
if (webSocketTask?.response as? HTTPURLResponse)?.statusCode == 402 {
Log.transcription.warning("Cloud RT: WS upgrade returned 402 — usage limit, not reconnecting")
Task { [weak self] in
guard let self else { return }
let usage = await UsageService.shared.cachedUsage
await UsageService.shared.refresh()
self.onError?(RealtimeTranscriptionError.usageLimitExceeded(
usedHours: usage?.usedHours ?? 0,
limitHours: usage?.limitHours ?? 5
))
self.onConnectionStatusChanged?(.failed("Cloud usage limit reached"))
}
return
}
// A refused WS upgrade (HTTP 402 usage limit) lands here via the receive
// loop with no close code. Reconnecting is futile — the server will keep
// refusing — and would surface a generic "Connection lost" instead of the
// real reason. Detect it synchronously to stop the reconnect, then surface
// the typed usage error with the best numbers we have.
if (webSocketTask?.response as? HTTPURLResponse)?.statusCode == 402 {
pingTask?.cancel()
pingTask = nil
receiveTask?.cancel()
receiveTask = nil
webSocketTask?.cancel(with: .normalClosure, reason: nil)
webSocketTask = nil
urlSession?.invalidateAndCancel()
urlSession = nil
Log.transcription.warning("Cloud RT: WS upgrade returned 402 — usage limit, not reconnecting")
Task { [weak self] in
guard let self else { return }
let usage = await UsageService.shared.cachedUsage
await UsageService.shared.refresh()
self.onError?(RealtimeTranscriptionError.usageLimitExceeded(
usedHours: usage?.usedHours ?? 0,
limitHours: usage?.limitHours ?? 5
))
self.onConnectionStatusChanged?(.failed("Cloud usage limit reached"))
}
return
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Diduny/Core/Services/CloudRealtimeService.swift` around lines 456 - 474, The
402 branch exits early without tearing down the live socket, leaving
webSocketTask/urlSession retained and pingTask running; before returning, cancel
and cleanup the socket state (cancel webSocketTask, invalidate or finish the
urlSession if owned, clear/await/ cancel pingTask) and reset any
connection-related properties so the normal cleanup paths aren't required;
perform this teardown inside the same Task block (or synchronously before it)
that calls UsageService and before calling self.onError? /
self.onConnectionStatusChanged? so that webSocketTask, urlSession and pingTask
are stopped (refer to webSocketTask, urlSession, pingTask, disconnect()).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Diduny/Core/Services/AsyncTranscriptionJobService.swift (1)

185-200: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Status-poll failures currently abort retry loop prematurely

When SSE fails, a transient failure in getJobStatus(jobId:) (Line 185) throws out of transcribeWithRetry immediately, bypassing the deadline-based retry behavior. That turns a recoverable network blip into a hard failure.

Suggested fix
             } catch {
                 sseFailures += 1
                 Log.transcription.warning("SSE stream failed (attempt \(sseFailures)): \(error)")

                 // Check if job finished while disconnected
-                let status = try await getJobStatus(jobId: submission.jobId)
-                if status.status == "completed", let result = status.result {
-                    return result.text
-                }
-                if status.status == "error" {
-                    throw TranscriptionError.apiError(status.error ?? "Transcription failed")
-                }
-                if let parsed = JobStatus(rawValue: status.status) {
-                    onUpdate(parsed)
+                do {
+                    let status = try await getJobStatus(jobId: submission.jobId)
+                    if status.status == "completed", let result = status.result {
+                        return result.text
+                    }
+                    if status.status == "error" {
+                        throw TranscriptionError.apiError(status.error ?? "Transcription failed")
+                    }
+                    if let parsed = JobStatus(rawValue: status.status) {
+                        onUpdate(parsed)
+                    }
+                } catch is CancellationError {
+                    throw CancellationError()
+                } catch {
+                    Log.transcription.warning("Status poll failed after SSE disconnect: \(error)")
                 }

                 // Still in progress. SSE is best-effort; keep polling/retrying until
                 // the server-side job reaches a terminal state or the long job timeout.
                 try Task.checkCancellation()
                 let delaySeconds = min(Double(max(sseFailures, 1)) * 2, 30)
                 try await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000))
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Diduny/Core/Services/AsyncTranscriptionJobService.swift` around lines 185 -
200, The poll call to getJobStatus(jobId:) inside transcribeWithRetry currently
lets any thrown error escape and abort retries; wrap the call in a do-catch
inside transcribeWithRetry so transient errors increment the sseFailures
counter, log or capture the error, and fall through to the existing backoff
sleep/loop (rather than rethrowing) until the longJobTimeout/deadline or Task
cancellation is reached; only rethrow or throw a TranscriptionError.apiError
when the deadline has passed or the error is non-transient, and keep the
existing Task.checkCancellation, delay calculation, and
onUpdate(JobStatus(rawValue:)) handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Diduny/Core/Services/AsyncTranscriptionJobService.swift`:
- Line 182: Replace the Log.transcription.warning call in
AsyncTranscriptionJobService (the SSE stream failure handling) with an NSLog
call that uses the "[Transcription]" prefix and includes the error details;
specifically, change the logging expression that currently reads
Log.transcription.warning("SSE stream failed (attempt \(sseFailures)):
\(error)") to an NSLog invocation that starts with "[Transcription]" and formats
the same message including sseFailures and the error for consistent repo logging
conventions.

---

Outside diff comments:
In `@Diduny/Core/Services/AsyncTranscriptionJobService.swift`:
- Around line 185-200: The poll call to getJobStatus(jobId:) inside
transcribeWithRetry currently lets any thrown error escape and abort retries;
wrap the call in a do-catch inside transcribeWithRetry so transient errors
increment the sseFailures counter, log or capture the error, and fall through to
the existing backoff sleep/loop (rather than rethrowing) until the
longJobTimeout/deadline or Task cancellation is reached; only rethrow or throw a
TranscriptionError.apiError when the deadline has passed or the error is
non-transient, and keep the existing Task.checkCancellation, delay calculation,
and onUpdate(JobStatus(rawValue:)) handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 39b9eb56-67c4-449b-8f1c-fb4e163ac90a

📥 Commits

Reviewing files that changed from the base of the PR and between 3b012d8 and 874a5cf.

📒 Files selected for processing (1)
  • Diduny/Core/Services/AsyncTranscriptionJobService.swift

retries += 1
Log.transcription.warning("SSE stream failed (attempt \(retries)/\(self.maxRetries)): \(error)")
sseFailures += 1
Log.transcription.warning("SSE stream failed (attempt \(sseFailures)): \(error)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use NSLog with [Transcription] prefix for this cloud API log

This changed line uses Log.transcription... instead of the required Swift logging convention from the repo guideline.

As per coding guidelines, "**/*.swift: Use NSLog() for logging with prefixes: [Diduny] for AppDelegate flow, [Transcription] for cloud API calls, [AppState] for state changes".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Diduny/Core/Services/AsyncTranscriptionJobService.swift` at line 182, Replace
the Log.transcription.warning call in AsyncTranscriptionJobService (the SSE
stream failure handling) with an NSLog call that uses the "[Transcription]"
prefix and includes the error details; specifically, change the logging
expression that currently reads Log.transcription.warning("SSE stream failed
(attempt \(sseFailures)): \(error)") to an NSLog invocation that starts with
"[Transcription]" and formats the same message including sseFailures and the
error for consistent repo logging conventions.

Source: Coding guidelines

…nical-proxy

# Conflicts:
#	Diduny/App/AppDelegate.swift
#	Diduny/Core/Models/Recording.swift
#	Diduny/Core/Services/PushToTalkService.swift
#	DidunyTests/SleepFlushCoordinatorTests.swift
#	project.yml
@rmarinsky rmarinsky changed the title Meeting: fall back to async jobs when realtime transcript is partial Meeting: harden realtime fallback and async jobs completion Jun 8, 2026
@rmarinsky rmarinsky added the release:patch Bug fix / internal — bumps patch version label Jun 8, 2026
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🔖 On merge this PR will release v1.14.5 (release:patch).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:patch Bug fix / internal — bumps patch version

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant