Release an abandoned start's interviewer at once

Merged
MA
5d ago
+1062-64

When the page ends a start after its token was granted, while the Strict problem loads or the LiveKit join is still running, the page tells the server once that step returns, and the server gives the interviewer back without waiting for the 300 s no-show limit. Before, the slot, the guest slot and the consent claim stayed held until that limit ran out.

The page sends the join token it was given to POST /api/token/release as { "roomName": "...", "token": "..." }. The server checks the token with livekit_join_from_token (signature, exp, roomJoin, and a granted room equal to roomName) and hands its identity to the dispatcher. The dispatcher releases the start only if that identity is the one recorded on the room's current reservation and the reservation is still Waiting. Guests and signed-in callers go through the same check, so the cap-of-two behavior is covered too. A closed tab still waits out the 300 s.

A reservation moves from Waiting to Joined or Released under the dispatch lock and never moves back. Cancellation is cooperative, with no abort(): the candidate wait ends, open_session returns NoCandidate, and the existing no-show path releases consent before the slot drops. If the start is released before the agent reaches its room, the agent skips the join. If the agent has joined and nobody comes, it now leaves the room before returning, because a dropped Room stays connected and a fixed room's next start would get a second agent beside it. Until the slot drops, a token request for a released room is refused as Finalizing, so a retry never lands in a room whose agent is leaving. The route has its own rate limit, and its body cap is large enough for the biggest token /api/token can mint, including one with a full grounding packet. It is listed in the route allowlists in tests/web/routes.rs and tests/web/accounts.rs.

Tests for each case accepted on the issue:

CaseTest
Repeated releasedispatch::tests::a_start_is_released_once_and_only_by_its_own_identity
Release before the room task waitsdispatch::tests::a_release_before_the_room_task_waits_is_not_lost, with an_unreleased_start_keeps_waiting as its control
Join racing a releasedispatch::tests::a_join_racing_a_release_has_exactly_one_winner (200 rounds on two threads)
Reconnectdispatch::tests::a_reconnect_does_not_reopen_a_joined_start
Fixed room reused from A to B with A's tokendispatch::tests::a_token_from_an_earlier_start_in_a_reused_room_releases_nothing
Second token for a running room (Reservation::Existing)dispatch::tests::a_second_token_for_a_running_room_cannot_release_it
Guest cap releaseddispatch::tests::a_released_guest_start_gives_its_guest_slot_back
Consent releaseddispatch::tests::a_released_start_gives_its_consent_and_slot_back
Token request between a release and the slot droppingdispatch::tests::a_released_room_is_refused_until_its_slot_drops
Agent leaves an empty roomlivekit::tests::an_agent_nobody_came_to_leaves_the_room
open_session wired to the slotlivekit::tests::the_candidate_wait_listens_for_its_start_being_released
Token identitya_join_token_proves_its_room_and_identity in tests/token.rs
Routetoken::a_guest_releases_its_start_with_the_token_it_was_given, token::a_release_without_a_matching_signed_token_reaches_no_dispatcher, token::releases_are_rate_limited_apart_from_token_requests, token::a_start_with_a_full_grounding_packet_can_still_be_released
PagePractice ended while its start awaits the token leaves no room and releases devices and Practice ended while its start awaits the join leaves no room and releases devices in tests/browser/strict-selection.test.js, which now also assert the release request

I checked that every test named above shows ok in the local output, and that both browser tests ran rather than being skipped. The wiring test fails if I break any of the places where open_session and run_room_with_slot pass the slot on, or if I remove the check that skips the join. The empty-room test fails without the leave_room call. On Windows, scripts/test.sh fails gates outside this change: three clippy warnings in src/gemini.rs, tests/unit/dispatch_http.rs and tests/unit/gemini.report_eval.rs; gemini::tests::bounded_http_diagnostic_* and two recording:: tests, which fail the same way on upstream main; and lanes whose tools are not installed. CI is the full check.

Closes #539


Summary by cubic

Releases an abandoned start's interviewer as soon as the page gives up, instead of holding the slot, guest slot, and consent claim until the five-minute no-show limit runs out.

  • The page posts its join token to POST /api/token/release; the dispatcher ends the start only when the token's signed identity matches the room's current start and that start is still Waiting, so repeated releases, tokens from earlier starts in reused rooms, and second tokens for running rooms do nothing.
  • If the start is released before the candidate joins, the agent skips the join and the existing no-show path returns consent before the slot drops. Until the slot drops, a new token for the released room is refused, so a reloading candidate never lands in a room nobody staffs. The page sends the request with keepalive; a closed tab still waits out the full limit.
  • When no candidate comes, the agent now leaves the room it joined, so a released start no longer leaves a lingering connection for the next start in a reused room.

Closes #539.