Skip to content

feat(meetings): bot ownership on dispatch, pause/resume, and a user-facing status - #12

Open
Rahulkaushik01 wants to merge 1 commit into
feat/local-session-owner-and-reaperfrom
feat/meeting-ownership-and-status
Open

feat(meetings): bot ownership on dispatch, pause/resume, and a user-facing status#12
Rahulkaushik01 wants to merge 1 commit into
feat/local-session-owner-and-reaperfrom
feat/meeting-ownership-and-status

Conversation

@Rahulkaushik01

Copy link
Copy Markdown
Collaborator

Stacked PR 5 of 5 — base feat/local-session-owner-and-reaper. Merge PRs 1–4 first.
Chain: audio-engine → ingest-and-lifecycle → api → owner-and-reaper → this

Summary

Groundwork for per-user meeting history. Four independent gaps had to close before a
"show me my meetings" endpoint can exist and stay correct: bot meetings had no owner,
a paused session was indistinguishable from a crashed one, the internal bot states are
meaningless to an end user, and the history query would get slower with every recording.


Problem 1 — bot meetings had no owner

The problem. When a bot is dispatched, nothing recorded who dispatched it. The server
therefore could not answer "which meetings are mine?" for the bot half of a user's history.
(Local sessions already carry a verified owner from PR 4.)

Why not just use metadata.app_user_id, which the desktop already sends? Because it is
written by the client and never verified. The Attendee API key is a compile-time constant in
the desktop binary (env!("ATTENDEE_API_KEY")), so anyone with the app can extract it and call
/bots directly with any app_user_id they like. Filtering history on that field would mean
letting the caller choose whose meetings they receive. It is a claim, not proof. (It is also
inside a JSON column, so it would need its own functional index anyway.)

What this does. The dispatch endpoint reads the member's verified team.day token and stamps
Bot.owner_user_id from it.

Three deliberate decisions:

  1. The token is optional. /bots is a public, documented API used by other customers.
    Requiring the header would break every existing integration. No token → the bot is created
    exactly as before and left unowned.
  2. But a token that is sent must be valid. Silently ignoring an expired token would hand
    the caller a bot that never appears in their history — indistinguishable from data loss.
    A bad token fails the request before anything is created, so there is no orphan bot.
  3. Stamped with a single UPDATE, not a read-modify-write, so it can never contend with
    the row's optimistic-concurrency version.

Problem 2 — a paused session looked exactly like a crashed one

The problem. Pausing deliberately stops audio uploads. To the server that is
indistinguishable from the app dying, so the idle reaper could end a session while the user was
simply paused.

Why not infer it from timing? Because a long thoughtful silence looks identical to a pause.
Any threshold either ends real sessions or fails to detect real crashes. The desktop is the only
component that actually knows the user pressed pause, so it should say so.

What this does. Adds POST /local_sessions/{id}/pause and /resume, reusing Attendee's
existing RecordingStates.PAUSED rather than inventing a state.

  • IdempotentRecordingManager already returns early if the recording is in the target
    state, so a double-tap or a retry is harmless.
  • Keeps the session alive — both stamp the heartbeat, so a paused session is never reaped.
  • Resume preserves started_at — the session timeline continues unbroken, which is what
    makes every chunk's offset_ms line up after a pause instead of jumping.
  • 409, not a crash — pausing an already-ended session returns a clear conflict rather than
    letting RecordingManager raise out of the view.

The heartbeat endpoint moved into the same module, which also brought
local_session_api_views.py from 247 down to 231 lines, back under the preferred limit.


Problem 3 — internal states are meaningless, and a stuck clip said "processing" forever

The problem. Bot.state has 19 values describing pod plumbing (JOINING,
WAITING_ROOM, POST_PROCESSING, FATAL_ERROR…). None of it means anything to someone looking
at their own recordings. Worse: if transcription exhausted its retries, nothing surfaced that,
so a session would report "processing" indefinitely.

What this does. meeting_status.py maps the internal states onto the seven a person cares
about: recording · paused · finalizing · processing · done · partially_failed · deleted.

Why a derived read rather than a new state or a watchdog job:

  • It writes nothing — a pure function of the bot, its recording and its utterance counts —
    so it cannot race the lifecycle or introduce a state the state machine doesn't recognise.
  • Adding a real FAILED state for local sessions was rejected: verified that FATAL_ERROR is
    only reachable from states [2,3,4,5,6,8,11,12,13,14,15,16,100,101,102]READY is not
    among them
    — so a local session (which lives in READY until it ends) can never legally
    enter it. Failure therefore has to surface from Recording.state and Utterance.failure_data,
    which is exactly what this reads.
  • A background watchdog was rejected as another moving part that can itself fail.

The "stuck forever" fix reuses the codebase's own definition of in-progress from
BotControllertranscription IS NULL AND failure_data IS NULL. Every provider returns
(transcription, failure_data) and process_utterance retries 6 times with backoff, so when it
finally gives up the failure is recorded as data. A clip that has given up therefore flips the
meeting to partially_failed instead of hanging on processing.

partially_failed rather than failed is deliberate: the rest of the transcript is still
perfectly usable, and calling the whole meeting failed would be misleading.


Problem 4 — history would get slower with every recording

The problem. History is always "this user's meetings, newest first". The previous index
covered only owner_user_id, so it helped find the rows but not order them.

Measured with EXPLAIN (ANALYZE, BUFFERS) on 6,000 rows:

owner-only index composite index
Plan Bitmap Heap Scan + Sort Index Only Scan (no sort)
Rows actually read 1,200 (every row the user owns) 25 (just the page)
Buffers 119 6
Execution 0.906 ms 0.204 ms

The point is not the 4× today — it is that the old plan reads every row a user owns and sorts
them to return one page, so it degrades as someone records more. The composite index reads only
the requested page and stays flat at 10 meetings or 10,000.

Why replace rather than add: owner_user_id is the leading column of the new index, so it
serves everything the old one did. Keeping both would mean paying for two indexes on every write
for no benefit.

Why the migration is hand-written: makemigrations generated the operations in the wrong
order — dropping the old index before creating the new one, which leaves a window with no
index at all and performs a blocking build. This migration is atomic = False and does
AddIndexConcurrently first, then RemoveIndexConcurrently, so owner lookups are never
unindexed and writes are never locked out.


Changes made

File Lines Purpose
bots/meeting_status.py (new) +73 derive a user-facing status from existing data
bots/local_session_control_views.py (new) +93 pause, resume, heartbeat (moved here)
bots/migrations/0091_replace_bot_owner_index.py (new) +33 concurrent index swap, add-before-drop
bots/team_day_user_auth.py +13 optional_user_id() — absent is fine, invalid is not
bots/bots_api_views.py +11 stamp owner on dispatch
bots/local_session_api_urls.py +14 / −2 pause + resume routes
bots/models.py +10 / −3 composite index definition
bots/local_session_api_views.py −16 heartbeat moved out (247 → 231 lines)
version.json +1 / −1 1.49.0 → 1.50.0 (minor, per feat title)

Total: 270 changed lines, 1 commit.


Testing performed

Everything below was run against a real server over HTTP with a real team.day-signed token
and real audio, not mocks.

New behaviour — 20/20

  • dispatch with token → 201, owner stamped 60 (a real team.day user id)
  • dispatch without token → 201, owner NULL — existing API customers unaffected
  • dispatch with a forged token → 401, no bot created
  • pause → 200 and PAUSED in the DB; pausing twice → 200 (idempotent)
  • another user pausing my session → 404 (existence never leaks)
  • resume → 200, back to IN_PROGRESS, audio still accepted afterwards
  • status walks recording → paused → recording → processing → done
  • EXPLAIN confirms bot_owner_history_idx via Index Only Scan, old index gone

Regression — nothing existing broke

  • test_bots_api_views, test_bots_api_utils, test_bot_data_deletion, test_cleanup
    114 tests OK (the first directly covers the endpoint modified here)
  • the 7 heartbeat / global-runtime / never-launched reaper tests → 7/7 OK
  • Full end-to-end regression of PRs 1→5 together → 30/30: start, auth rejection, validation
    400s, cross-user isolation, 10 real audio chunks, replayed-chunk dropped, heartbeat from its
    new home, stop → ENDED with recording COMPLETE (not FAILED), idempotent double-stop,
    real transcription ("Hi, I am currently testing"), and delete_dataDATA_DELETED
  • concurrency: 24 parallel mic+system uploads, out-of-order and duplicated chunks, and 4
    simultaneous sessions → all accepted, duplicates not double-counted

Gates: ruff check ✅ · ruff format --check ✅ · manage.py check ✅ ·
version.json bump simulated locally against the CI rule ✅

Two defects my own testing caught: the backwards migration described above, and routes
silently 404-ing locally because the dev server runs --noreload (a local-dev quirk only —
gunicorn starts fresh on every rollout).


Coding-guidelines compliance

  • File size (§2): every new file is ≤93 lines, well under the 250 preferred level, and this
    PR reduces local_session_api_views.py from 247 → 231.
    ⚠️ bots/models.py is pre-existing at ~3,300 lines (over the 350 cap); this PR adds 10 lines
    because a Django index definition must live in the model. See the refactor note below.
  • Functions (§6): all new functions ≤50 lines, single responsibility, early returns.
  • Constants (§5): status values are named constants; no magic numbers or repeated strings.
  • Error handling (§12): nothing fails silently — 401 / 404 / 409 with explicit messages, and
    the RecordingManager raise path is guarded rather than allowed to 500.
  • Security (§8): no secrets added; ownership is only ever taken from a verified token, never
    from client-supplied input; a mismatch returns 404 so existence never leaks.
  • Commits (§14): type(scope): description.
  • No dead code, debug logs, or unused imports (§15).

Refactoring recommendation (§2 flag)

bots/models.py breaches the file cap. I recommend not splitting it in this fork: the
history shows ~3,810 commits from the upstream author versus a handful of ours, and models.py
is the most-edited file upstream — restructuring it would turn every future upstream sync into a
permanent manual conflict. Our footprint there is deliberately minimal, with all genuinely new
logic in new, compliant files. If the split is wanted, the right move is to propose it upstream
and inherit it.


Risks / notes

  • Migration 0091 is non-atomic and builds CONCURRENTLY. If a concurrent build fails
    part-way, Postgres leaves an INVALID index behind that must be dropped by hand before
    re-running. Queries simply ignore an invalid index, so nothing breaks meanwhile — but whoever
    runs the migration should confirm it succeeded.
  • No behaviour change for existing API customers. Dispatch without a token is byte-for-byte
    the previous behaviour; only a present but invalid token is newly rejected.
  • Pause/resume require the desktop to call them. Until it does, sessions behave exactly as
    before (a long pause with no heartbeat can still be reaped, as designed).
  • version.json is 1.50.0, one minor above this PR's base. Note the stack: if the earlier
    PRs' versions are bumped before this merges, this one needs re-bumping to stay one minor above
    its base.
  • Ownership only protects data as strongly as the token it trusts — see the separate note on the
    shared JWT signing secret.

…acing status

Groundwork for per-user meeting history.

* Ownership: a bot dispatched with a member's team.day token is stamped with
  owner_user_id, so history can later be scoped to whoever created it. The token
  is OPTIONAL -- /bots is a public API and customers who send none keep working
  unchanged, with their bots left unowned. A token that IS sent must be valid:
  silently ignoring a bad one would hand the caller a bot that never appears in
  their history, which looks like data loss.

* Pause/resume: pause stops audio uploads, so without an explicit signal the
  server cannot tell a paused session from a crashed one -- and guessing from
  timing would flag every thoughtful silence as a pause. The desktop now says so,
  reusing Attendee's existing PAUSED recording state. Both endpoints are
  idempotent, keep the session away from the idle reaper, and resume preserves
  started_at so the timeline (and every chunk's offset_ms) continues unbroken.

* Status: maps the 19 internal bot states onto what a person actually needs --
  recording, paused, finalizing, processing, done, partially_failed, deleted. It
  is a pure read of data we already store, so it cannot race the lifecycle.
  "Still transcribing" reuses the predicate the bot controller already uses (no
  transcription and no failure recorded), which is what stops a stuck clip from
  reporting "processing" forever.

* Index: history is always "this user's meetings, newest first", so the index now
  carries the sort order. Measured on 6k rows, the owner-only index read every row
  a user owned and sorted them (1200 rows, 119 buffers) to return one page; the
  composite reads only the page (25 rows, 6 buffers) and stays flat as history
  grows. Migration 0091 builds the new index CONCURRENTLY *before* dropping the
  old one, so owner lookups are never left unindexed.

Co-Authored-By: Claude <noreply@anthropic.com>
@Rahulkaushik01
Rahulkaushik01 requested a review from hd1801 July 21, 2026 05:54
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