Release an abandoned start's interviewer at once
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:
| Case | Test |
|---|---|
| Repeated release | dispatch::tests::a_start_is_released_once_and_only_by_its_own_identity |
| Release before the room task waits | dispatch::tests::a_release_before_the_room_task_waits_is_not_lost, with an_unreleased_start_keeps_waiting as its control |
| Join racing a release | dispatch::tests::a_join_racing_a_release_has_exactly_one_winner (200 rounds on two threads) |
| Reconnect | dispatch::tests::a_reconnect_does_not_reopen_a_joined_start |
| Fixed room reused from A to B with A's token | dispatch::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 released | dispatch::tests::a_released_guest_start_gives_its_guest_slot_back |
| Consent released | dispatch::tests::a_released_start_gives_its_consent_and_slot_back |
| Token request between a release and the slot dropping | dispatch::tests::a_released_room_is_refused_until_its_slot_drops |
| Agent leaves an empty room | livekit::tests::an_agent_nobody_came_to_leaves_the_room |
open_session wired to the slot | livekit::tests::the_candidate_wait_listens_for_its_start_being_released |
| Token identity | a_join_token_proves_its_room_and_identity in tests/token.rs |
| Route | token::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 |
| Page | Practice 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 stillWaiting, 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.