[Backend] The audio retry endpoint disarms both duration guards #421
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: MEDIUM. Found while recovering the 2026-08-26 production incident.
The defect
POST /sessions/{id}/audio/retry(routers/sessions.py) re-queuesprocess_audiowith a hardcoded duration of0:Both duration invariants added in #324 early-return below
DURATION_GUARD_MIN_SECONDS(600):check_tracks_cover_session— every per-speaker track must span the sessioncheck_transcript_covers_session— the merged timeline must cover most of itSo a retry runs with no coverage checking at all. These are the guards that exist specifically to catch the #320 failure — per-speaker tracks on their own compressed clocks, producing a scrambled, misattributed transcript that reads fine to a human. A retry is precisely when you most want them: something already went wrong with this recording.
The comment is honest about the cause (
duration unknown on retry) but the consequence was not thought through: unknown duration silently became no verification, rather than derive it or say so.Proposed fix
The duration is not actually unknown — it is recoverable from the audio itself.
_wav_duration_secondsalready reads it, and every track in a session directory is the same length by construction since #320.Option 3 is the real fix; 1 is the cheap one that removes the hole today.
Note
This was worked around manually during the 2026-08-26 recovery by queueing
process_audiowith the true duration (4639s) instead of using the endpoint, so that session's reprocess was guarded. Nobody else gets that.Acceptance criteria
0check_tracks_cover_sessionCorrection to this issue's premise
I wrote that "the consequence was not thought through". That was wrong, and the code says so plainly —
check_tracks_cover_session's docstring reads:So the bypass was a decision, not an oversight, and it has a real rationale: if a recording genuinely is mis-clocked, a guard that blocks reprocessing means you can never retry it at all.
The concern survives in a narrower form, which is what got fixed:
check_transcript_covers_sessionchecks whether transcription covered the session — nothing to do with whether the source tracks are suspect. "Let an admin re-run a bad recording" does not justify skipping that one, and passing0skipped both.What shipped
derive_session_duration(audio_dir)recovers the length from the tracks, and both reprocess endpoints — GM-facingPOST /sessions/{id}/audio/retryand the admin one, which had the same hole — now pass it. The guards run on a retry exactly as on a first pass. When nothing readable can be measured the duration is still 0 and the guards still skip, but the endpoint logs which invariants were not checked rather than passing over it.On the original rationale: a retry that now fails with "Track for 'Sol_Invictus' covers 400s of a 4639s recording" is a better outcome than one that silently rebuilds the same scrambled transcript. The GM learns the recording is unrepairable — which is what
pre_timeline_fix(#328) exists to record — instead of getting plausible-looking nonsense.One case deliberately kept: a session under
DURATION_GUARD_MIN_SECONDS(600s) is still exempt, because below that timing noise dominates and the check is meaningless.Tests in
test_reprocess_duration_guards.py, including the one that matters — a short track must not be allowed to define the session length, or the guard would compare every track against the broken one and validate itself into always passing.Verified against the acceptance criteria before closing. All met.
derive_session_duration(audio_path)is called beforeprocess_audio.delay(...)in bothrouters/sessions.py:930-935androuters/admin.py:208-214.check_tracks_cover_sessionandcheck_transcript_covers_sessionrun unconditionally insideprocess_audioagainst whatever duration they are handed, so a nonzero derived value re-arms both.sessions.py:936-943,admin.py:215-222, naming that the guards will not run.test_a_mis_clocked_track_is_caught_once_the_duration_is_real(tests/test_reprocess_duration_guards.py:88) reproduces the real 4639s/400s shape and asserts theRuntimeErrornames Sol_Invictus.On the quiet-player risk
I specifically asked for this to be scrutinised, since a guard that rejects audio is exactly where a quiet player gets deleted. It does not apply here, and the reason is worth recording.
check_tracks_cover_sessioncompares each track's WAV file duration, not its speech content. Since #320, the bot pads every speaker's WAV to the full session length (recording.py:842,sink.close(pad_to=duration_s)), so a player who spoke 163 seconds of a 4,639-second session still produces a ~4,639-second file and passes trivially.test_a_healthy_recording_still_passescovers exactly that shape.So the guard is wall-clock-aware and speech-blind by construction, which is what makes it safe. Worth knowing, because the other silence guard is not — see #425, which came out of this same verification pass and is a merge blocker.
Closing. Part of a full acceptance-criteria pass across the v4.0.0 milestone.