Skip to content

feat(discord): add /queue and let an admin re-run a finished session - #49

Merged
TheMeinerLP merged 5 commits into
mainfrom
fix/vad-destroys-transcripts
Aug 21, 2026
Merged

feat(discord): add /queue and let an admin re-run a finished session#49
TheMeinerLP merged 5 commits into
mainfrom
fix/vad-destroys-transcripts

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Re-transcribing the sessions that the VAD defect ruined had to be done by hand-written SQL against production, twice. This is that capability as a reviewed command instead.

/queue session reads a session's jobs; /queue requeue puts them back through transcription behind a confirmation. Admin-gated through the same require_admin() the other commands use, every reply ephemeral — these replies name who was recorded.

What it refuses, and why each refusal exists

A session that is not documented. Two different hazards, one rule:

  • open is the window JobQueue.complete's Defect-5 guard exists for. RecordingService.close uploads and enqueues one speaker at a time, each enqueue committing separately, and only calls close_session after the last upload. Forcing status="closed" inside that window lets the next complete() call the session finished from whichever speakers happen to exist yet. On a long multi-speaker session that window is wide, and "re-queue the session that just ended" is the obvious thing an impatient admin does.
  • closed is still owned by retry_pending_documents, which can document the session from the transcripts a re-queue just cleared — after which neither complete()'s last-job rule nor the sweep ever revisits it.

A job whose audio is gone. audio_deleted_at is set only after a real S3 delete succeeded. Re-queueing those creates jobs that can only fail.

The reply names the actual status and what to wait for.

Bounded output, because a diagnostic command must not go silent

render_session emits a line per speaker including job.error, which holds an arbitrary exception string. With several speakers, or one long error, that exceeds Discord's 2000-character limit; the HTTPException from followup.send after a thinking=True defer means the admin gets no reply at all — precisely when the queue is full of failures.

Errors are collapsed to one line and cut at 160 characters so one exception cannot swallow the other speakers; the list fills the remaining budget and ends in "…and N more speakers not shown", pointing at the SQL in operations.md §5. The cap sits under every render function, not just /queue session: a refusal listing 80 erased speakers has the same failure mode.

Truncation rather than an attachment, because the readout is scanned for one thing — a transcript length absurd for the session — and the first speakers sample that fine, whereas a file arrives as a download on a command whose whole value is an answer on screen in a second.

A race that silently lost the announcement

SessionRepository.mark_announced is now a compare-and-set on status = 'documented' AND announced_at IS NULL. announce_ready_sessions awaits announcer.post(...) — a Discord HTTP call that can take seconds under rate limiting — and only then stamps. A sweep already inside that call would re-stamp the column a re-queue had just cleared, and the redo would never be announced. Now the late stamp misses and logs at INFO. Accepted cost: one duplicate post of the superseded link, the side announce_ready_sessions already errs towards.

Tests

695 pass. 13 mutations, each killed by the test that names the behaviour.

Two test defects were found by review and both were confirmed real before being fixed — the old suite was run with the mutations applied and stayed green:

  • RequeueConfirmView.interaction_check was only tested in the deny direction. The _press helper called item.callback directly, bypassing the gate Discord dispatches through (View._scheduled_task runs item._run_checks(...) and self.interaction_check(...)). It now goes through that path, so the allow direction is load-bearing in all ten confirming tests.
  • The Confirm button's defer could be deleted with the suite green, though the file docstring claimed the fakes enforce Discord's contract. _Followup now refuses a followup on an unacknowledged interaction.

Worth second-guessing

  • Refusing closed removes a capability. A session genuinely stuck there — the document sweep failing permanently — can no longer be re-queued by command at all; SQL is the only route. operations.md §5 points at the document-failure section, but it is a real narrowing.
  • _press reaches into discord.py privates (View._scheduled_task, assigning over on_error). That is the price of testing the gate Discord actually dispatches through, and it couples the suite to a private API across upgrades.
  • 160 characters of error per line and roughly twenty speakers per reply are judgement, not measurement.

Every session recorded before the VAD fix on this branch has a document
built from an empty or hallucinated transcript, and nothing in the system
can produce a better one: JobQueue.claim selects only pending jobs, never
done and never dead, so a finished job stays finished no matter how wrong
its transcript is. /queue requeue is the explicit write that resurrects
one.

A re-queue is a state reset and nothing else. It returns the session to
exactly the state it was in immediately after close_session and before
documentation, and the machinery that already exists -- claim, complete,
_create_session_document, announce_ready_sessions -- carries it forward a
second time on its own. The decision of which jobs may be reset is a pure
function over plain dicts in sturnus.application.requeue, beside
sessions_to_announce and expired_jobs and tested the same way; the write
is inline ORM in the cog, exactly as audio_cog._erase_audio is and for the
same reason -- the selection is used by nothing else.

Two rules keep the command from being destructive. A session with any job
still pending or running is refused outright: the worker holding a running
job will call complete() when it finishes, writing the old run's
transcript back over the reset while the admin has already been told the
reset happened. And a job whose audio_deleted_at is set is skipped rather
than reset, because re-queueing it would only hand a worker a key it
cannot download; its done status and its old transcript stay, so the new
document is the redone speakers plus the untouched old text of the ones
whose audio is gone -- which the reply says in as many words.

Both of those are decided inside the same SELECT ... FOR UPDATE the
JobQueue.complete takes, ORDER BY id included so the two cannot deadlock,
and re-derived there rather than reused from the plan the confirmation was
rendered from. Under READ COMMITTED a plan built outside that lock would
see a snapshot predating a complete() already in flight. The job resets
and the session reset share one transaction and one commit: half of that
write landing would strand the session with no document and no error,
since complete() and retry_pending_documents both require status ==
"closed".

announced_at is cleared deliberately, and that is the choice to post a
second link into the voice channel. mark_documented never touches that
column and sessions_to_announce selects only sessions where it is null, so
leaving it set would produce a session that transcribes, re-documents with
a fresh URL and is then never announced -- nothing logged, nothing raised,
the channel simply never told. A corrected transcript nobody hears about
is indistinguishable from no transcript.

The old document is orphaned rather than updated: DocumentSink exposes
create and nothing else, anywhere in the codebase. That is accepted, not
stumbled into, so the confirmation names the existing document_url and
says it stays and will not be updated -- deleting or cross-linking it
remains a human act.

Confirmation is a discord.ui.View showing the plan, not a force flag: the
three things an admin cannot undo -- discarded transcripts, a second
document, a second public post -- are all on screen before a button
exists. A second press finds the jobs already pending and is refused on
that alone, so it cannot produce a second document or a second
announcement.

All three subcommands are admin-gated, including the read-only ones: even
/queue status reports who was recorded and how much they said. Every query
joins to session and filters on guild_id, and a session belonging to
another guild gets the same reply as one that exists nowhere, so the
command cannot be used to probe ids elsewhere. /queue session reports the
length of a stored transcript and never its text -- 24 characters for a
100-minute recording is the tell, and that is all an admin needs to decide.
Section 5 previously had no way to name a job that succeeded end-to-end
and was still wrong: `status` done, `transcript` non-NULL, nothing logged.
That was every session transcribed before this branch's VAD fix. Add it as
a third failure category alongside the existing transcription/document
split, with the concrete tell (/queue session's transcript length, e.g.
the 19-character " Copyright WDR 2021" fixture) and the fix (/queue
requeue), and document the three /queue subcommands themselves, which had
no writeup anywhere. Also point the stale "no admin command yet to list
dead jobs" line at /queue status and /queue session.

No settings table changes: the two commits on this branch touch no
Settings/WorkerSettings/LinkSettings field, verified by diffing against
the branch's merge-base. first-deployment.md needs no change for the same
reason -- no first-deployment step touches any of this.
Three defects found reviewing /queue, all of them in what the command
does when the pipeline has not actually finished with a session, plus the
three holes in its tests that let them through.

**A re-queue may only touch a `documented` session.** Neither
`_load_requeue_view` nor `_apply_requeue` looked at `session.status`, and
`_apply_requeue` writes `status="closed"` unconditionally -- so a session
still inside `RecordingService.close` could be forced into exactly the
state `JobQueue.complete`'s Defect 5 guard exists to refuse. That is not
an exotic race: `close` uploads and enqueues one speaker at a time, each
`enqueue` committing on its own, and calls `close_session` only after the
last upload, so a long multi-speaker session stays `open` for minutes with
early speakers already `done`; the first job to finish after the forced
`closed` would be taken for the session's last, and the document would be
built from part of the meeting. A `closed` session is refused for a
different reason: `retry_pending_documents` still owns it and may be
between its read and its `mark_documented` write, so a re-queue landing in
that sweep gets documented from the transcripts it has just discarded,
after which neither `complete`'s last-job rule nor the sweep itself ever
looks at the session again. `SessionView.is_settled` is the rule,
`is_refused` composes it with the two job rules from
`sturnus.application.requeue`, and the check is taken inside the row lock
as well as in front of the prompt -- the prompt holds nothing but ids
while a human reads it, and the session can move in that time. The
refusal names the status the session is in and says what to wait for; an
administrator who is told only "no" tries again immediately.

**Every reply is bounded to Discord's 2000 characters.** `render_session`
emitted one ~85-character line per speaker plus `str(exc)` out of
`job.error`, unbounded on both counts, and the resulting `HTTPException`
from `followup.send` after a `thinking=True` defer is not a truncated
answer but no answer at all -- the diagnostic command going silent exactly
when the queue is in the state it exists to diagnose. Errors are collapsed
to one line and cut at `MAX_ERROR_CHARS`, so one exception cannot swallow
the other speakers; the speaker list is filled to the remaining budget and
ends in a line stating how many speakers are not shown. Truncation rather
than an attachment, because the readout is scanned for one thing -- a
transcript length absurd for the length of the session -- and the first
speakers sample that as well as any, while a file arrives as a download to
open on a command whose value is that the answer is on screen in a second;
`docs/operations.md` section 5 already documents the SQL for the rest.
`_capped` is the backstop under that for lengths nobody budgeted, and
every render function ends inside it, not only `/queue session`.

**`mark_announced` is now a compare-and-set.** `announce_ready_sessions`
awaits `announcer.post` -- seconds, under rate limiting -- and stamps
`announced_at` only afterwards; a re-queue landing in that window clears
the column precisely so the redo's link will be posted, and the late
unconditional stamp put it straight back, leaving the corrected transcript
documented and never announced, with nothing logged and nothing raised.
The UPDATE now matches only the state the selection was made on (still
`documented`, still unannounced), so the stamp lands only if the session
is still the one the post was about. The cost is one duplicate post of the
superseded link, which is the side `announce_ready_sessions` already
documents itself as erring towards.

The tests had three holes of their own, and every one of them is a
mutation the old suite passed with. `_press` called `Button.callback`
directly, which is not how discord.py dispatches: `View._scheduled_task`
runs `item._run_checks(interaction) and self.interaction_check(interaction)`
first and drops the press if either is falsy, so the confirm view's author
check could be made to deny every press -- locking administrators out of
their own Confirm -- with the file still green. `_press` goes through
`_scheduled_task` now, with `on_error` replaced by one that re-raises,
because the default logs and swallows exactly the assertions these fakes
exist to make. `_Followup.send` accepted a followup on an interaction
Discord had never been told about, so the `defer` in
`RequeueConfirmView.confirm` was deletable with the suite green; it now
refuses one, which is the contract the module docstring already claimed to
enforce. And the four reply paths that never asserted `ephemeral is True`
now do, ephemerality being a contract this cog states in its own docstring
because these replies name who was recorded.

docs/operations.md section 5 gains the refusal rule with both states and
what to wait for, the fact that `/queue session` truncates and where to
read the untruncated rows, and the duplicate announcement as something to
expect rather than to be alarmed by.
#43 (silent-audio detection) and #48 (transcribe the speech, not the padded
track) landed while this waited. Two conflicts, both additive:

`tests/infrastructure/test_repositories.py` needed `select` and `update`
from sqlalchemy rather than one or the other -- #43's repository test reads
a column back, this branch's compare-and-set writes one.

`docs/operations.md` section 5 gained two independent troubleshooting
entries at the same anchor: this branch's `/queue` walkthrough and #43's
'a speaker's audio arrives with no level'. Both stay; they answer different
questions and neither supersedes the other.
@TheMeinerLP
TheMeinerLP merged commit e2e8c93 into main Aug 21, 2026
6 checks passed
TheMeinerLP added a commit that referenced this pull request Aug 21, 2026
…content

OpenTelemetry traces and metrics, plus structured logging shaped for Loki.

Squashed to a single commit on top of main. Two earlier commits on this
branch carried fixtures with the literal shape of a credential -- an AWS
access key id and a Discord bot token -- which secret scanning detects,
correctly, whether or not either string opens anything. Both are now
assembled from parts at import: identical at runtime, so the tests still
prove the redaction catches those exact shapes, with nothing in the file
for a scanner or a reader to mistake for a credential. Rewriting the tip
alone would not have helped; a scan reads every commit in the pull
request, so the literals had to leave the history.

**Redaction is an allowlist, not a denylist.** Unregistered field names are
dropped, `bytes` is dropped as a class (audio and wrapped data keys are
always bytes, so that closes the highest-value leak by construction),
strings are pattern-scrubbed and capped, and every replacement is visible
(`«redacted:discord_token»`) rather than silent.

**The leak this branch closed.** With `STURNUS_LOG_LEVEL=DEBUG` the Discord
voice `secret_key` reached the logs and would have reached Loki.
Reproduced:

    discord/ext/voice_recv/gateway.py:57
      log.debug("Received op %s: \n%s", op, pformat(data))
      -> {'mode': ..., 'secret_key': [1, 2, ..., 32], 'ssrc': ...}

Not a redaction failure: `extra={"secret_key": ...}` was dropped, and so was
`extra={"voice_ready": {...}}`. The key arrived already formatted into a
third-party logger's *message string*, which a field allowlist cannot
touch. The cause was `root.setLevel(min(resolved_level,
resolved_third_party))`, which put 28 unclamped third-party loggers at
DEBUG -- every logger absent from the enumerated clamp list inherits from
root, and one list cannot be complete about libraries it does not import.
That `min()` was never load-bearing (Python checks the *originating*
logger's effective level on propagation, never root's), so removing it
costs nothing and closes the hole. `THIRD_PARTY_FLOOR` replaces the
enumeration as the structural half of the fix, and
`tests/observability/test_third_party_log_floor.py` asserts the property
over `logging.Logger.manager.loggerDict` rather than over a list.

`discord.voice_state` is pinned at INFO rather than silenced: DEBUG is where
the leak lives, but INFO is the connect narrative -- handshake attempts,
endpoint, close codes, resume -- and it is the evidence base for telling
the three capture failures apart. Pinning needed a `NEVER_ABOVE`
counterpart: `NEVER_BELOW` is applied as `max(level, floor)` and can only
ever make a logger *quieter*, so an INFO entry there was a no-op at the
deployed `WARNING` default -- the logger still ended at WARNING and the
line the entry was written to keep was still gone.

**The metrics answer questions this project actually had.**
`sturnus.transcription.decoded_seconds` divided by wall time is the
real-time factor: a job that "finished" a 100-minute recording in 43
seconds reports an impossible, unmistakable number -- where the symptom
everyone saw, an empty transcript, looked exactly like a participant who
never spoke and was misread as one for a day. `position_seconds`,
`total_seconds` and `seconds_since_progress` are **observable instruments,
not synchronous gauges**: a gauge only changes when a call site sets it, so
a decoder that wedges freezes it and `seconds_since_progress` -- the actual
alert -- could never grow. The SDK calls these callbacks once per export
interval instead, which is also what lets them emit nothing at all while
the worker is idle, so the series goes stale rather than reporting a
finished job's numbers forever. The stall clock starts before the library
call, since the collapse happened inside feature extraction. Labels are
`model` only: no session, job, guild or user id, which would be unbounded
cardinality and a record of who was in a voice channel when, kept for as
long as the metric store keeps anything.

`sturnus.job.outcome` reported `done` for every failed job, because
`process_one` returns True after `queue.fail(...)` exactly as it does after
`queue.complete(...)` -- the boolean means "work was attempted", never
"work succeeded", and a metric that reports failures as successes is worse
than no metric because it will be believed. The label is now recorded by
the transitions that decide a job's terminal state, and `crashed` is the
one the worker loop still owns.

**Rebased onto #48, which rewrote the same method.** The transcription
mechanics are main's: the model is handed the gated speech concatenated,
`clip_timestamps` is re-expressed on that timeline, and every returned
segment goes back onto the recording's through
`_on_the_original_timeline(segment.start, segment.end, segment.seek, ...)`,
where `Segment.seek` -- the encoder window the segment was decoded from --
is what names its clip. The observability is re-expressed on top: the
segment generator is drained by a loop and not a comprehension, so
`TRANSCRIPTION_PROGRESS` sees each segment as it arrives rather than only
after the job has already finished.

Progress is reported on the *concatenated* timeline -- `advance(segment.end)`
and not the restored end -- because the denominator is
`duration_after_vad`, which since #48 is the concatenated speech. Reporting
a restored end against it would put a job that had decoded its first clip
at several hundred percent. `telemetry.TranscriptionProgress` and
`docs/operations.md` § 7.5 say so; they described the whole file before.

Two call sites arrived from main that the merge could not have seen, both
of them what `tests/test_logging_discipline.py` R2 and R6 forbid, and for
the reason R6 exists: `%s` on an exception prints `str(exc)` verbatim into
the message `observability.scrub_event` forwards to Sentry.
`RecordingService._report_silent_audio` (#48) was three `log.warning`
calls, two interpolating an exception and one passing `display_name`; it is
now `speaker.audio_silent`, `speaker.silent_warning_failed` and
`speaker.silent_record_failed` through `log_event`/`log_exception`, with
`display_name` gone -- the channel message renders the mention, and the
operator has the id. `RequeueConfirmView._disable` (#49) is now
`queue.view_disable_failed` the same way.
TheMeinerLP added a commit that referenced this pull request Aug 21, 2026
…content (#50)

OpenTelemetry traces and metrics, plus structured logging shaped for Loki.

Squashed to a single commit on top of main. Two earlier commits on this
branch carried fixtures with the literal shape of a credential -- an AWS
access key id and a Discord bot token -- which secret scanning detects,
correctly, whether or not either string opens anything. Both are now
assembled from parts at import: identical at runtime, so the tests still
prove the redaction catches those exact shapes, with nothing in the file
for a scanner or a reader to mistake for a credential. Rewriting the tip
alone would not have helped; a scan reads every commit in the pull
request, so the literals had to leave the history.

**Redaction is an allowlist, not a denylist.** Unregistered field names are
dropped, `bytes` is dropped as a class (audio and wrapped data keys are
always bytes, so that closes the highest-value leak by construction),
strings are pattern-scrubbed and capped, and every replacement is visible
(`«redacted:discord_token»`) rather than silent.

**The leak this branch closed.** With `STURNUS_LOG_LEVEL=DEBUG` the Discord
voice `secret_key` reached the logs and would have reached Loki.
Reproduced:

    discord/ext/voice_recv/gateway.py:57
      log.debug("Received op %s: \n%s", op, pformat(data))
      -> {'mode': ..., 'secret_key': [1, 2, ..., 32], 'ssrc': ...}

Not a redaction failure: `extra={"secret_key": ...}` was dropped, and so was
`extra={"voice_ready": {...}}`. The key arrived already formatted into a
third-party logger's *message string*, which a field allowlist cannot
touch. The cause was `root.setLevel(min(resolved_level,
resolved_third_party))`, which put 28 unclamped third-party loggers at
DEBUG -- every logger absent from the enumerated clamp list inherits from
root, and one list cannot be complete about libraries it does not import.
That `min()` was never load-bearing (Python checks the *originating*
logger's effective level on propagation, never root's), so removing it
costs nothing and closes the hole. `THIRD_PARTY_FLOOR` replaces the
enumeration as the structural half of the fix, and
`tests/observability/test_third_party_log_floor.py` asserts the property
over `logging.Logger.manager.loggerDict` rather than over a list.

`discord.voice_state` is pinned at INFO rather than silenced: DEBUG is where
the leak lives, but INFO is the connect narrative -- handshake attempts,
endpoint, close codes, resume -- and it is the evidence base for telling
the three capture failures apart. Pinning needed a `NEVER_ABOVE`
counterpart: `NEVER_BELOW` is applied as `max(level, floor)` and can only
ever make a logger *quieter*, so an INFO entry there was a no-op at the
deployed `WARNING` default -- the logger still ended at WARNING and the
line the entry was written to keep was still gone.

**The metrics answer questions this project actually had.**
`sturnus.transcription.decoded_seconds` divided by wall time is the
real-time factor: a job that "finished" a 100-minute recording in 43
seconds reports an impossible, unmistakable number -- where the symptom
everyone saw, an empty transcript, looked exactly like a participant who
never spoke and was misread as one for a day. `position_seconds`,
`total_seconds` and `seconds_since_progress` are **observable instruments,
not synchronous gauges**: a gauge only changes when a call site sets it, so
a decoder that wedges freezes it and `seconds_since_progress` -- the actual
alert -- could never grow. The SDK calls these callbacks once per export
interval instead, which is also what lets them emit nothing at all while
the worker is idle, so the series goes stale rather than reporting a
finished job's numbers forever. The stall clock starts before the library
call, since the collapse happened inside feature extraction. Labels are
`model` only: no session, job, guild or user id, which would be unbounded
cardinality and a record of who was in a voice channel when, kept for as
long as the metric store keeps anything.

`sturnus.job.outcome` reported `done` for every failed job, because
`process_one` returns True after `queue.fail(...)` exactly as it does after
`queue.complete(...)` -- the boolean means "work was attempted", never
"work succeeded", and a metric that reports failures as successes is worse
than no metric because it will be believed. The label is now recorded by
the transitions that decide a job's terminal state, and `crashed` is the
one the worker loop still owns.

**Rebased onto #48, which rewrote the same method.** The transcription
mechanics are main's: the model is handed the gated speech concatenated,
`clip_timestamps` is re-expressed on that timeline, and every returned
segment goes back onto the recording's through
`_on_the_original_timeline(segment.start, segment.end, segment.seek, ...)`,
where `Segment.seek` -- the encoder window the segment was decoded from --
is what names its clip. The observability is re-expressed on top: the
segment generator is drained by a loop and not a comprehension, so
`TRANSCRIPTION_PROGRESS` sees each segment as it arrives rather than only
after the job has already finished.

Progress is reported on the *concatenated* timeline -- `advance(segment.end)`
and not the restored end -- because the denominator is
`duration_after_vad`, which since #48 is the concatenated speech. Reporting
a restored end against it would put a job that had decoded its first clip
at several hundred percent. `telemetry.TranscriptionProgress` and
`docs/operations.md` § 7.5 say so; they described the whole file before.

Two call sites arrived from main that the merge could not have seen, both
of them what `tests/test_logging_discipline.py` R2 and R6 forbid, and for
the reason R6 exists: `%s` on an exception prints `str(exc)` verbatim into
the message `observability.scrub_event` forwards to Sentry.
`RecordingService._report_silent_audio` (#48) was three `log.warning`
calls, two interpolating an exception and one passing `display_name`; it is
now `speaker.audio_silent`, `speaker.silent_warning_failed` and
`speaker.silent_record_failed` through `log_event`/`log_exception`, with
`display_name` gone -- the channel message renders the mention, and the
operator has the id. `RequeueConfirmView._disable` (#49) is now
`queue.view_disable_failed` the same way.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant