Repository navigation
fix(stt): join a decoder migration that started during the probe - #23381
Conversation
Two transcodes that find no decoder both call migrate_retired_decoder. Each awaits the retired-file probe off the loop. When the first one's transfer installs the pin and removes the retired file before the second's probe runs, that probe finds nothing, and the second transcode reported the decoder missing while a migration had just succeeded. Re-check for a started migration after the probe and join it, through the same-loop guard the early join already used. Fixes kirodotdev#23360 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UX Review (Opus 5.5, fork) — ⏭️ skippedRevision |
Design Review (Opus 5.5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Nothing blocks: the fix re-checks the start-once task after the probe's await, which removes the race at its cause and changes no contract. [DESIGN-REVIEWED] cfc32f6 Description read[DESCRIPTION-READ] aba6e4a31e5305692c69bc6cdf60ae654e000d9d0c728c9f7c8b79ddad0b2235 That sha256 names everything this verdict read: the PR description, plus the 1 evidence file(s) the workflow collected from it and handed this review. It covers that evidence because the media strip replaces every attachment URL with the same placeholder, so an edit swapping one attachment for another would leave the description's captured text identical while what this review judged changed. Recompute it with |
First Principles Review (Opus 5.5, fork) — ✅ PASSPremise-level review of First-Principles-Verdict: PASS Nothing blocks: this fixes the mechanism behind the race seen in #23360, and the new test fails on base. What this change shipsInventory (3 items) — 3 justifiedIntent: a voice note or dictation sent while the retired-decoder migration is running gets the new decoder instead of "decoder missing". This is a FIX.
[FIRST-PRINCIPLES-REVIEWED] cfc32f6 Description read[DESCRIPTION-READ] 0235991bd706736651d3f5e14a395b044568fc83fa148b519a8aa47780bb6688 That sha256 names the PR description this verdict read. Recompute it with |
Opus 5.5 Review (fork) — ✅ no blocking findingsReviewed |
GPT 6.1 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
KiroCrewCaptain
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, security checklist all-NO, no reviewer-lane findings, readiness signal fresh for this head SHA, declared category matches the diff. Category: the STT decoder migration re-checks for an in-flight migration after the retired-file probe and joins it, closing the race where a second caller reported the decoder missing; covered by a new concurrency test.
Problem / Motivation
Goal: A transcode that runs while the retired-decoder migration is under way gets the newly installed decoder instead of a "decoder missing" result.
test_stt_decoder.py::TestRetiredDecoderMigration::test_concurrent_callers_share_one_transferfailed on a Linux shard of #23334 (#23360): of two concurrentmigrate_retired_decoder()callers, one returned the installed path and the otherNone. The issue suspected the test's reset of_migration_task. The race is inmigrate_retired_decoderitself. A caller that finds no migration started awaits the retired-file probe (asyncio.to_thread(has_retired_decoder)). If another caller starts the migration meanwhile and its transfer installs the pin and removes the retired file before this probe runs, the probe finds no retired file and the caller returnsNone, even though a migration had just succeeded.Why it matters
The transcode resolver calls
migrate_retired_decoderwhen nothing resolves, andNonemeans "no decoder". On an install upgrading from a retired pin, a second voice note or dictation that arrives during the first one's migration fails to transcode, even though the decoder it needed was installed moments earlier. On CI the same race shows up as an intermittent red on PRs that do not touch STT.Not a goal
DecoderStore.ensuredoes.None, as before.What changed (motivation → approach → change)
None(assert PosixPath('.../ffmpeg-test') == None).migrate_retired_decoder(stt/decoder.py) checked for a started migration only before the probe'sawait. A migration that started during that await, and finished and removed the retired file before the probe read the store, left the probe answering "nothing to migrate"._migration_taskagain and joins a migration that was started, whatever the probe found. Both joins go through a new_join_migrationhelper, which is the existing early-join code moved unchanged: the same-loop guard (Nonefor a task on another loop) and theasyncio.shieldthat keeps one caller's cancellation from cancelling the shared transfer.Backwards compatibility
Compatible: a caller that used to get the path still gets it. The only caller whose result changes is one that used to get
Nonewhile a migration on its own loop had already started, and it now waits for that migration.Removes nothing: no field, key or path is removed. The function only waits for a transfer it used to ignore.
Tests
Proof: PROVEN test/test_stt_decoder.py::TestRetiredDecoderMigration::test_a_caller_whose_probe_sees_the_migrated_store_joins_the_transfer (head cfc32f6)
test_a_caller_whose_probe_sees_the_migrated_store_joins_the_transferforces the losing order instead of hoping for it. The retired-file probe is wrapped so that whichever call arrives second blocks on athreading.Event(bounded at 10 s). The shared store'sensuresets that event when the first migration finishes. So the second probe always runs on a store where the retired file has already been removed. It asserts that both callers get the installed path and that there is one fetch. Onorigin/mainit fails withassert WindowsPath('.../ffmpeg-test') == None.test_concurrent_callers_share_one_transfer, the test that failed in Flaky: test_concurrent_callers_share_one_transfer (test_stt_decoder.py::TestRetiredDecoderMigration) returns None for one caller #23360, is unchanged. The fix removes the order it lost under.TestRetiredDecoderMigration9 passed in each of 5 consecutive runs.test_stt_decoder.py74 passed, 4 skipped. Acrosstest_stt_decoder.py,test_transcribe_ffmpeg.py,test_build_desktop_decoder_requirement.py,test_cli_doctor.pyandtest_cli_stt_memory_doctor.py: 255 passed, 2 failed. The two failures areTestDoctorStt::test_doctor_stt_transcribe_boto3_missingand...amazon_transcribe_missing, and they fail the same way withorigin/main'sdecoder.pyon this host, so they do not come from this change.test_flake_pattern_ratchet.py60 passed. black, isort and flake8 are clean on both files, and the comment-history, brand-name and sync-io-in-async gates pass.Manual verification
N/A — unit coverage sufficient: the new test reproduces the exact interleaving with the real
DecoderStore.ensure, the real retired-file probe and a served artifact. Only the network is stubbed.Related Issues
Fixes #23360
Found on #23334's CI; the test arrived with #23158.
Sibling sweep
Swept: git grep -nE "_task is None|_task = asyncio.ensure_future" -- src/kiro_crew/stt/ (other start-once tasks in stt that probe before starting)
Sibling: src/kiro_crew/stt/decoder.py:604 -- changed here
Sibling: src/kiro_crew/stt/models.py:895 -- not affected -- a detached
maybe_autofetchper model download with no start-once global; its probe thenstore().ensure(), and ensure re-checks the installed file under its own lockPattern harvest
Rule candidate: review-prompt
Pattern: a start-once task checked before an
awaitand decided on after it, without re-checking the task, so a task started during the await is missedChecklist
feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
🤖 Generated with Claude Code