fix(recording): finish a recording the bot died in the middle of (#399) #470

Merged
claude-bot merged 1 commit from fix/399-recover-interrupted-recordings into main 2026-08-31 17:53:46 +00:00
Contributor

Closes the last open criterion on #399.

The gap

A crash mid-session leaves per-speaker raw capture tracks on disk and nothing else — no session directory, no manifest, no handoff marker, because all three are written at stop. #399's first pass stopped the startup sweep from deleting them and logged needs finalising by hand. Nobody has hands on a self-hosted bot at 2am, so an hour into a four-hour session sat on the volume indefinitely.

What lands

The bot converts what it captured on its next start and hands it over. The session then appears like any other.

Automating this is only defensible because placement in the raw tracks is absolute — each track is silence-padded from the session's t0 before every append — so the tracks are already mutually aligned and the duration is exactly max(file size) / BYTES_PER_SECOND. Nothing is reconstructed or estimated; the timings and attribution are the recorded ones. Per-speaker private clocks would make recovery impossible, which is the whole of #320. That dependency is now written into CLAUDE.md so it isn't quietly broken later.

Wiring. A background task off on_ready, not setup_hook, for two reasons: resolving a speaker's guild nickname needs a warm member cache that doesn't exist in setup_hook, and re-encoding hours of audio must not sit in front of the bot answering commands. A player who renamed themselves to their character loses that identity to the global name (#344) — the sessions that already went wrong shouldn't also read worst. Guarded to run once per process, since on_ready re-fires on every gateway reconnect.

API change

GET /api/bot/sessions/{id}/summary now returns guild_id and notification_channel_id. Neither is derivable from a filename, and both are load-bearing:

  • POST /audio matches the session against the guild id.
  • process_audio passes the channel straight to session_summarised, and the bot drops an event with an empty channel_id. Without it a recovered session would transcribe, summarise, and then say nothing in Discord — indistinguishable from the recovery not having run.

Additive optional fields on a response, so no BOT_CONTRACT_VERSION bump (confirmed with check_version_sync.py).

Every path errs towards leaving the audio alone

Situation Behaviour
Handoff fails Raws untouched; converts again next start
Session lookup fails Converts nothing — a blank guild would send a request guaranteed to be rejected
Campaign has no Discord server Same — left alone, nothing deleted
Directory already handed off Not re-submitted. _retry_pending_handoffs runs first and may have just succeeded; the 409 guard would not catch the double submit, because the first run hasn't written a transcript yet
Session already has a transcript #397's refusal — take is kept, retry disabled
Track below the speech threshold Reported as present, not as a speaker (#425, still reachable per #432)

A raw track is unlinked only after the handoff is acked, so nothing is ever the sole copy of something converted from a file already gone.

Verification

15 new bot tests + 2 backend tests, all run against unfixed code first and confirmed to fail. Mutations checked:

  • raws deleted before the ack — caught
  • no already-handed-off guard (double submit) — caught
  • global Discord name preferred over guild nickname — caught
  • proceeds with a blank guild id — caught
  • silent tracks counted as speakers — caught
  • duration reported as zero — caught
  • session lookup failure swallowed, blanks used — caught
  • once-per-process guard removed — caught
  • non-UUID session id trusted — caught
  • guild_id/notification_channel_id dropped from the response — caught
  • guild_id emitted as "" rather than nullcaught

Full bot suite: 244 passed. Backend test_bot.py: 50 passed. ruff check + ruff format --check clean on webapp/backend/ scripts/.

🤖 Generated with Claude Code

Closes the last open criterion on #399. ## The gap A crash mid-session leaves per-speaker raw capture tracks on disk and **nothing else** — no session directory, no manifest, no handoff marker, because all three are written at stop. #399's first pass stopped the startup sweep from deleting them and logged `needs finalising by hand`. Nobody has hands on a self-hosted bot at 2am, so an hour into a four-hour session sat on the volume indefinitely. ## What lands The bot converts what it captured on its next start and hands it over. The session then appears like any other. Automating this is only defensible because placement in the raw tracks is **absolute** — each track is silence-padded from the session's t0 before every append — so the tracks are already mutually aligned and the duration is exactly `max(file size) / BYTES_PER_SECOND`. Nothing is reconstructed or estimated; the timings and attribution are the recorded ones. Per-speaker private clocks would make recovery impossible, which is the whole of #320. That dependency is now written into `CLAUDE.md` so it isn't quietly broken later. **Wiring.** A background task off `on_ready`, not `setup_hook`, for two reasons: resolving a speaker's *guild nickname* needs a warm member cache that doesn't exist in `setup_hook`, and re-encoding hours of audio must not sit in front of the bot answering commands. A player who renamed themselves to their character loses that identity to the global name (#344) — the sessions that already went wrong shouldn't also read worst. Guarded to run once per process, since `on_ready` re-fires on every gateway reconnect. ## API change `GET /api/bot/sessions/{id}/summary` now returns `guild_id` and `notification_channel_id`. Neither is derivable from a filename, and both are load-bearing: - `POST /audio` matches the session against the guild id. - `process_audio` passes the channel straight to `session_summarised`, and the bot **drops an event with an empty channel_id**. Without it a recovered session would transcribe, summarise, and then say nothing in Discord — indistinguishable from the recovery not having run. Additive optional fields on a response, so no `BOT_CONTRACT_VERSION` bump (confirmed with `check_version_sync.py`). ## Every path errs towards leaving the audio alone | Situation | Behaviour | |---|---| | Handoff fails | Raws untouched; converts again next start | | Session lookup fails | Converts **nothing** — a blank guild would send a request guaranteed to be rejected | | Campaign has no Discord server | Same — left alone, nothing deleted | | Directory already handed off | Not re-submitted. `_retry_pending_handoffs` runs first and may have just succeeded; the 409 guard would *not* catch the double submit, because the first run hasn't written a transcript yet | | Session already has a transcript | #397's refusal — take is kept, retry disabled | | Track below the speech threshold | Reported as present, not as a speaker (#425, still reachable per #432) | A raw track is unlinked **only** after the handoff is acked, so nothing is ever the sole copy of something converted from a file already gone. ## Verification 15 new bot tests + 2 backend tests, all run against unfixed code first and confirmed to fail. Mutations checked: - raws deleted before the ack — **caught** - no already-handed-off guard (double submit) — **caught** - global Discord name preferred over guild nickname — **caught** - proceeds with a blank guild id — **caught** - silent tracks counted as speakers — **caught** - duration reported as zero — **caught** - session lookup failure swallowed, blanks used — **caught** - once-per-process guard removed — **caught** - non-UUID session id trusted — **caught** - `guild_id`/`notification_channel_id` dropped from the response — **caught** - `guild_id` emitted as `""` rather than `null` — **caught** Full bot suite: 244 passed. Backend `test_bot.py`: 50 passed. `ruff check` + `ruff format --check` clean on `webapp/backend/ scripts/`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(recording): finish a recording the bot died in the middle of (#399)
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 23s
CI / Backend lint (ruff) (pull_request) Successful in 1m11s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m51s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m21s
CI / Bot tests and audit (pull_request) Successful in 2m23s
CI / Docker image build (pull_request) Successful in 4m25s
CI / Backend migration, tests, and audit (pull_request) Successful in 7m33s
5382daa8d2
A crash mid-session left per-speaker raw capture tracks on disk and nothing
else — no session directory, no manifest, no handoff marker, because all three
are written at stop. #399's first pass stopped the startup sweep deleting them
and logged "needs finalising by hand"; nobody has hands on a self-hosted bot at
2am, so an hour into a four-hour session sat there indefinitely.

The bot now converts what it captured on its next start and hands it over, and
the session appears like any other. Automating this is only defensible because
placement in the raw tracks is absolute — each is silence-padded from the
session's t0 before every append — so the tracks are already mutually aligned
and the duration is exactly max(file size) / BYTES_PER_SECOND. Nothing is
reconstructed: the timings and attribution are the recorded ones. Per-speaker
private clocks would make this impossible, which is the whole of #320.

Deliberately a background task off on_ready rather than setup_hook. Resolving a
speaker's guild nickname needs a warm member cache, which does not exist in
setup_hook, and a player who renamed themselves to their character loses that
identity to the global name (#344) — exactly the sessions that already went
wrong should not also read worst. It is guarded to run once per process, since
on_ready re-fires on every gateway reconnect.

GET /api/bot/sessions/{id}/summary now returns guild_id and
notification_channel_id. Both are needed and neither is derivable from a
filename: POST /audio matches the session against the guild, and process_audio
passes the channel straight to session_summarised — the bot drops an event with
an empty channel, so without it a recovered session would transcribe and then
say nothing in Discord, indistinguishable from the recovery not running.
Additive optional fields, so no contract bump.

Every path errs towards leaving the audio alone. A raw track is unlinked only
after the handoff is acked, so a failure at any point loses nothing and the
next start tries again. A failed session lookup or a campaign with no guild
converts nothing at all rather than sending a request guaranteed to be
rejected. A directory already carrying the handoff marker is not re-submitted —
_retry_pending_handoffs runs first and may have just succeeded, and the 409
guard would not catch the double submit because the first run has not written a
transcript yet. #397's refusal keeps the take and stops retrying it.

Silent tracks stay silent: the same MIN_SPEECH_BYTES policy as the normal stop
path, so a present-but-inaudible member is proposed for attendance rather than
for having spoken (#425, still reachable per #432).

All fifteen new bot tests and both backend tests were run against unfixed code
first and confirmed to fail — including raws-deleted-before-ack, global-name-
before-nickname, blank-guild-proceeds, silent-counted-as-speaker, duration-
reported-as-zero, and the double-submit guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch fix/399-recover-interrupted-recordings 2026-08-31 17:53:46 +00:00
Sign in to join this conversation.
No description provided.