fix(bot,webapp): /record start's refusals stay private, and a refused start no longer leaves the session live (#843) #867

Merged
claude-bot merged 1 commit from fix/843-record-start-followups into main 2026-09-27 01:25:45 +00:00
Collaborator

Closes #843.

1. Refusals stay private

record_start deferred the interaction publicly, and a followup after a public defer inherits that visibility. So every "private" refusal could appear in the channel: session not found, the backend's 409s, nobody in voice, and D171's warning.

  • The fix: it now defers with ephemeral=True, thinking=True.
  • The public announcement still goes to the channel, via interaction.channel instead of a followup.
  • The GM gets a private "Recording … has started." Without it, the deferred interaction would never be answered, the same failure #393 fixed for /record stop.

2. A refused start no longer leaves the session live (D116)

On the Discord path, the bot used to flip the session to in_progress before joining voice. If the join then failed, the session was stuck live with no recording.

  • The fix: an additive ?dry_run=true on POST /api/bot/sessions/{id}/recording-started. It runs the same checks (D10, D123), with the same 409 body, but doesn't flip. The bot:
    1. calls the dry run before connect();
    2. does the real flip after listen(), once capture has started.
  • If the real flip then refuses (a race since the dry run), it's logged and capture continues.
  • Why this option: the check and the flip were already separate calls. That meant no new route and no compensating state to reconcile.
  • Compatibility: the web path is unchanged, since D116 applies there only after the bot's 200. The bot contract stays at v2, and check_version_sync passes.

Tests

  • Bot full suite: 491 passed. It checks that the defer is ephemeral and that the announcement goes to the channel. It asserts the order dry run → connect → listen → flip. And when the voice join fails, the session never goes live.
  • Backend: test_table_live 67 passed. A dry run doesn't flip, still enforces D10, and the real call flips afterwards.
  • ruff is clean; docs/API.md is updated.

🤖 Generated with Claude Code

Closes #843. ## 1. Refusals stay private `record_start` deferred the interaction publicly, and a followup after a public defer inherits that visibility. So every "private" refusal could appear in the channel: session not found, the backend's 409s, nobody in voice, and D171's warning. - **The fix:** it now defers with `ephemeral=True, thinking=True`. - **The public announcement still goes to the channel,** via `interaction.channel` instead of a followup. - **The GM gets a private "Recording … has started."** Without it, the deferred interaction would never be answered, the same failure #393 fixed for `/record stop`. ## 2. A refused start no longer leaves the session live (D116) On the Discord path, the bot used to flip the session to `in_progress` before joining voice. If the join then failed, the session was stuck live with no recording. - **The fix:** an additive `?dry_run=true` on `POST /api/bot/sessions/{id}/recording-started`. It runs the same checks (D10, D123), with the same 409 body, but doesn't flip. The bot: 1. calls the dry run before `connect()`; 2. does the real flip after `listen()`, once capture has started. - **If the real flip then refuses** (a race since the dry run), it's logged and capture continues. - **Why this option:** the check and the flip were already separate calls. That meant no new route and no compensating state to reconcile. - **Compatibility:** the web path is unchanged, since D116 applies there only after the bot's 200. The bot contract stays at v2, and `check_version_sync` passes. ## Tests - Bot full suite: **491 passed**. It checks that the defer is ephemeral and that the announcement goes to the channel. It asserts the order dry run → connect → listen → flip. And when the voice join fails, the session never goes live. - Backend: `test_table_live` 67 passed. A dry run doesn't flip, still enforces D10, and the real call flips afterwards. - ruff is clean; `docs/API.md` is updated. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(bot,webapp): /record start's refusals stay private, and a refused start no longer leaves the session live (#843)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m35s
CI / Backend lint (ruff) (pull_request) Successful in 1m23s
CI / Bot tests and audit (pull_request) Successful in 2m0s
CI / Bot/backend version sync (pull_request) Successful in 25s
CI / Docker image build (pull_request) Successful in 16s
CI / Feedback site tests (pull_request) Successful in 1m22s
CI / Does this change touch the recording path? (pull_request) Successful in 38s
CI / Backend migration, tests, and audit (pull_request) Successful in 14m18s
CI / Frontend tests, audit, and build (pull_request) Successful in 18m49s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 31m48s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 34m40s
04378aed62
record_start deferred publicly, so the first followup after it — including
SESSION_NOT_FOUND, the backend's 409s, a failed voice join, and D171's
private "couldn't announce" warning — inherited that visibility regardless
of its own ephemeral flag. The defer is now ephemeral, and the announcement
itself is posted through the channel rather than the interaction followup,
so it stays public; the command-success path now also answers the deferred
interaction privately, or Discord would leave "thinking..." on screen.

Separately, the bot used to flip a session to in_progress (D116) in the
same backend call that checked D10/D123, before joining voice, so a voice
join that then failed left the session live with nothing being recorded.
POST /api/bot/sessions/{id}/recording-started gains an additive
?dry_run=true (no contract bump, BOT_CONTRACT_VERSION stays 2): the bot
checks with a dry run before joining voice, and only flips the session
after it has actually joined and started capture. A refusal on the second
(real) call only warns, since capture cannot be undone by that point.

Tests:
- bot: full suite, 491 passed, 1 skipped. New coverage for the ephemeral
  defer, the announcement staying on the channel, the check-then-flip
  ordering, and a join failure never flipping the session.
- backend: targeted webapp/backend/tests/test_table_live.py, 67 passed.
  New coverage for a dry run not flipping, still refusing D10, and the
  real call flipping normally afterwards.
- check_version_sync.py passes; ruff check + format --check clean over
  webapp/backend, scripts, webapp/frontend/e2e.

Closes #843

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.