First run: a fresh install reads as ready to set up, not as a failure; Dictate never hangs when session saving is off
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 22/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- macos, swift
- Domain
- accessibility, desktop
Research direction
The issue covers two separate bugs. Start with prepare(acquire:) in RecognitionProviders.swift and the missing-assets handling in RecognitionLocalModels.swift, then the savePendingCapture guard in AppModel.swift around line 766. Done means the new checks in RecognitionLifecycleChecks (--check-providers) and the case in scripts/test-capture-persistence.py pass, with the existing checks still green.
Written by the indexing model from the issue text.
Description
Outcome: First run: a fresh install reads as ready to set up, not as a failure; Dictate never hangs when session saving is off.
Verified by an independent skeptic against the release train head d7cea35 (round four of the 15–16 October pass: energy, leaks, first run, keyboard, concurrency, settings propagation, public docs).
A fresh install reads as a failed speech setup: "Your saved work is available now", an orange triangle and Retry model before anything was downloaded
What a person sees: Every brand-new user is told that speech "needs a download… Your saved work is available now" when they have no saved work. Their first Snap & Talk session marks speech with an orange triangle and a Retry model link that cannot help, plus "If the model still can't be prepared…", which frames a model they never downloaded as a broken one. The designed first-run sentence, "Download Parakeet to turn speech into text on this Mac.", never appears on a real fresh Mac. Users who already have the model are unaffected.
Evidence (d7cea35): AppModel.swift:262 Task { await prepare() } → :273 engine.prepareCached() → RecognitionProviders.swift:103 prepare(acquire: false) → RecognitionLocalModels.swift:62-65 → load() throws .missingAssets with 'Parakeet needs a download before speech can be transcribed. Your saved work is available now.' (:113) when the files are absent. RecognitionProviders.swift:139-141 stores it as state.failure. RecognitionReadiness.swift:19-27: line returns failure.message before the .unavailable offer at :26. AppModel.swift:269 sets modelMessage = line and modelFailure = errorDescription. Surfaces: Home's engineBanner shows modelMessage (WorkbenchHome.swift:809) beside Download Parakeet (:824). The panel's Dictate row returns modelMessage + ' · Settings › Models' (WorkbenchControlTool.swift:384). Meetings prefixes it through MeetingModel.swift:153 → MeetingAdmission.swift:15. Snap & Talk gets engine: .init(name: model.modelMessage, ready: model.ready, failure: model.modelFailure, …) (WorkbenchHome.swift:186); needsAttention = failure != nil && !ready (ReadbackView.swift:83) draws the triangle and Retry model (:52-58) and the 'If the model still can't be prepared…' line (:63) instead of 'You can record for later…' (:66). Retry model reruns the same cached check. Coverage gap: RecognitionLifecycleChecks.swift:124 checks only the loader's error kind, and the gallery child opts out of cached preparation (docs/verification/2026-10-07-first-speech.md:36). Contract: workbench.md:233 'Idle missing assets are not described as preparing'.
Change: 1. The change. In RecognitionProviders.swift prepare(acquire:)'s catch: when !acquire and the error is a RecognitionFailure of kind .missingAssets, leave state.failure nil and admission .unavailable, call finishPreparation() and return without throwing. Not downloaded is the offer, not an error. The configuration check at :116, the acquire path (a download that yields no files stays a .missingAssets failure), and the invalidCache, load, storage and network results all stay as they are.
- If the implementer prefers to keep throwing, ModelSettingsView
operationFailuremust also ignore.missingAssets. Otherwise switching from a local server to Parakeet shows a duplicate note.
- No Retry saved files flag, no hiding of Retry saved files, no change to workbench.md.
requireAdmission's fallback (.missingAssets, "Open Models to prepare speech first. Your recording and saved work are kept.") still drives ReadbackModel.swift:1464's resume wait and the CLI--transcribeerror. - Checks in RecognitionLifecycleChecks (
--check-providers).- Build an engine whose
services.prepareCachedcallsRecognitionLocalModels.prepareCached(cache: <empty temp dir>). AfterprepareCached(), which must not throw, assert:failure == niladmission == .unavailable!canTranscribeline == "Download Parakeet to turn speech into text on this Mac."transcribe()still throws aRecognitionFailureof kind.missingAssets
- A cache with a corrupt vocabulary still records
.invalidCacheas a failure. - An acquire whose load finds no files still records
.missingAssetsas a failure.
- Build an engine whose
- Optional check in ReadbackChecks. A
NarrationEnginebuilt from that snapshot (name:line, ready: false, failure: `
Acceptance: --check-providers (RecognitionLifecycleChecks)
- An engine using the real
RecognitionLocalModels.prepareCachedon an empty temporary cache returns fromprepareCached()without throwing. Afterwards:snapshot.failure == niladmission == .unavailable!canTranscribeline == "Download Parakeet to turn speech into text on this Mac."transcribe(url)still throws aRecognitionFailureof kind.missingAssets
- A cache with an invalid vocabulary still ends with
failure.kind == .invalidCache. - An acquire whose load finds no files still ends with
failure.kind == .missingAssets. - The existing stalled-download check still passes.
--check-readback
- The existing 'kept take waits while speech prepares' checks (ReadbackChecks.swift:1112-1160) still pass.
- A
NarrationEnginebuilt from the fresh snapshot is notneedsAttention.
Manual source check
- Switching from a local server to Parakeet on an empty cache shows no WorkbenchNote, only the offer line and Download Parakeet.
check-surfaces.py and the gallery
check-surfaces.pystill passes.- If the gallery change is made, the first-speech offer render's text comes from an engi
Value: 3 of 5 · Grammar: Quality · Risk: Low, with one trap.
- The trap. If the cached path keeps throwing, ModelSettingsView
operationFailureturns the now failure-less.missingAssetsinto a duplicate note when switching to Parakeet without the model. Either don't throw for.missingAssetson the cached path, or filter it there. - Callers that stay correct.
- Snap & Talk's resumed-take wait still matches
.missingAssetsthroughrequireAdmission's fallback. - The CLI
--transcribestill fails, now with "Open Model
- Snap & Talk's resumed-take wait still matches
Dictate stays on 'Tidying your words…' and a Shortcuts run never returns when session saving is disabled
What a person sees: This path is rare but deterministic. It begins after Workbench says 'Your saved session could not be opened or backed up. Automatic saving is disabled…'. The next dictation records, recognises and then stops at 'Tidying your words…' with no error, Retry or Cancel. Every later dictation, Retry and Shortcut is refused until relaunch. A 'Transcribe with Workbench' Shortcut keeps spinning, and cancelling it in Shortcuts does nothing.
Evidence (d7cea35): AppModel.swift:251-253: the backup-failure branch reports and returns before loaded = true (:256), skipping restoreCaptureRecovery (:258) and prepare() (:262). :767: guard loaded, let record = captureRecovery.pending, let capture = record.capture else { return false } returns without fail(), while every other refusal in savePendingCapture goes through fail() (:778). :763: commitRecognizedCapture returns savePendingCapture() ? capture.id : nil. :654-655: guard let captureID = try commitRecognizedCapture(...) else { return } leaves phase .cleaning (set at :641) and never calls shortcutRequest.finish. :626-628: the task's defer clears transcriptionTask, so cancelCurrentCapture's guard [.transcribing, .cleaning].contains(phase), transcriptionTask != nil (:552) returns before shortcutRequest.cancel(). The Shortcuts cancellation handler routes there (Shortcuts.swift:105; AppModel.swift:31-35). DictationRequest (Shortcuts.swift:69-80) holds the continuation from AppModel.swift:25-27 indefinitely. fail() (AppModel.swift:1248-1253) is the path that resets phase and finishes the Shortcut.
Change: AppModel.savePendingCapture (Sources/LocalVoice/AppModel.swift:766-767): split the guard. The missing-record case keeps returning false quietly. Add a new branch first:
guard loaded else { canRetry = false; fail("Automatic saving is off for this session, so this dictation was not added to History or sent to your app. The words are in Dictate. Copy them, then use Discard recovery… to dictate again, or reopen Workbench.", code: "dictate.saving_disabled"); captureFailure = self.error; onPhaseChange?(); return false }
This mirrors the existing save-failure branch at :776-779. fail() sets phase to .idle, raises the failure on Dictate and finishes any Shortcut exactly once (:1248-1253). The retry door at :604 now reports the same reason instead of doing nothing.
Out of scope, deliberately: moving prepare(), restoreCaptureRecovery or the early return in init, and any "deliver without History" behaviour. Those are behaviour decisions.
Add a case to scripts/test-capture-persistence.py, next to the "shortcut" case at lines 676-685.
Acceptance: Add a case to scripts/test-capture-persistence.py and run it via scripts/test.sh:57, or with PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-capture-persistence.py.
(a) Shortcut run: a CaptureHarness with loaded = false begins a shortcutRequest with a counting completion, then runs run(importedURL, owned: false) and await finish(model). Assert:
- replies == 1 and the result is a failure
- shortcutRequest.id == nil and phase == .idle
- transcript equals the recognised text, history is empty and store.calls == 0
- a later cancelShortcut(id) leaves replies at 1
(b) Owned Dictate run: makeRecording(wav) then run(url, owned: true) with loaded false. Assert:
- phase == .idle and transcriptionTask == nil
- captureFailure contains "Automatic saving is off"
- canRetry == false, hasCaptureRecovery == true and canDiscardCaptureRecovery == true
The case must fail on d7cea35, where phase stays .cleaning and replies == 0; the harness's finish awaits the task, so the old code fails without hanging. It must pass after the change, with the rest of the script still green.
Value: 2 of 5 · Grammar: Quality · Risk: Low. The new branch runs only when loaded == false, which happens only after the session failed to load and could not be backed up (AppModel.swift:251-253). It reuses fail(), which already finishes Shortcuts once and resets the phase. If live preview text was already written, it keeps that text in the field, exactly as the existing save-failure branch does. The one behaviour change in the normal path is none. In the disabled-saving path, Retry saving is hidden and a plain sentence plus Discard r
Refs #15
Build-ready record from round four of the overnight lead pass, verified by an independent skeptic per item. Closes only after native acceptance.
- Dominant language
- Swift
- Stars
- 0
- Forks
- 1
- Avg merge
- 8h 5m
- Merged PRs (30d)
- 215
Getting set up
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from Ship-Work/workbench
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Maintainers usually reply within 1 day
-
documentation good first issue help wanted
Difficulty 2/5 Half a day Newbie friendliness 74/100
Ship-Work/workbench#29 · 1 comment ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 55/100
Maintainers usually reply within 1 day
All issues in Ship-Work/workbench
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 83/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
openwatersio/slackwater-ios#736 ·
Maintainers usually reply within 1 day
-
agent/sec-check help wanted security stage/010-actionable
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
hivecommons/hotshot#235 ·
Maintainers usually reply within 1 day
-
enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
MustacheCase/zanadir#254 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100