Skip to content

Add model selector to freebuff with per-model queues#524

Merged
jahooma merged 10 commits intomainfrom
jahooma/model-selector
Apr 20, 2026
Merged

Add model selector to freebuff with per-model queues#524
jahooma merged 10 commits intomainfrom
jahooma/model-selector

Conversation

@jahooma
Copy link
Copy Markdown
Contributor

@jahooma jahooma commented Apr 20, 2026

Summary

  • Adds a model selector to the freebuff waiting room: pick between glm-5.1 and minimax-m2.7 (1–9 hotkeys or click)
  • Per-model FIFO queues so wait times scale independently per model; switching mid-queue moves you to the back of the new queue
  • Selection persists locally and defaults to the last-used model on relaunch
  • Mid-session model switching is blocked (server returns model_locked); new /queue command ends your session and rejoins so you can switch
  • Sessions remain 1 hour regardless of model

Implementation notes

  • DB: adds model column to free-session row + per-model advisory locks for queue admission
  • Server: joinOrTakeOver switches model on a queued row (resets queued_at), throws FreeSessionModelLockedError if mid-session model differs
  • CLI: new useFreebuffModelStore (zustand, persisted), FreebuffModelSelector component in waiting room, /queue slash command (aliases: rejoin, switch)
  • Agent runtime: loadAgentDefinitions overrides model on base2-free, editor-lite, code-reviewer-lite from the selected model when IS_FREEBUFF

Test plan

  • Run bun --filter='*' run typecheck (passes locally)
  • Run bun --filter='*' test for web and cli
  • Manually: launch freebuff, switch model in waiting room, verify queue position resets
  • Manually: enter a session, try /queue — verify session ends, returns to waiting room
  • Manually: in active session, try GET with stale model header — verify 409 model_locked
  • Apply migration 0044_violet_stingray.sql in staging

🤖 Generated with Claude Code

Lets users pick between glm-5.1 and minimax-m2.7 from the waiting
room. Each model has its own FIFO queue so wait times scale
independently. Selection persists locally; switching mid-queue moves
you to the back of the new queue and switching mid-session is blocked.
Adds /queue command to end the current session and rejoin (allowing
model switch).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 20, 2026

Greptile Summary

This PR adds a per-model queue system to the freebuff waiting room, letting users choose between GLM 5.1 (z-ai/glm-5.1) and MiniMax M2.7 (minimax/minimax-m2.7). Each model gets its own FIFO queue backed by a per-model advisory lock in the admission tick, so wait times scale independently. The implementation spans DB schema (new model column + updated composite index), server-side session API (model validation, model_locked error, per-model gate in checkSessionAdmissible), CLI state (useFreebuffModelStore, persisted preference), and a new FreebuffModelSelector UI component with number-key shortcuts.

Key changes:

  • DB: 0044_violet_stingray.sql safely backfills existing rows and rebuilds the queue index to include model
  • Server: joinOrTakeOver uses a single UPSERT with inline SQL CASE expressions to handle all queue-switch semantics; admitFromQueue hashes the model ID into a unique advisory lock ID
  • CLI: switchFreebuffModel persists the choice and re-POSTs; /queue slash command (aliases rejoin, switch) ends the active session and re-queues, enabling mid-session model switching
  • Agent runtime: loadAgentDefinitions overrides the model field on base2-free, editor-lite, and code-reviewer-lite from the selected model store when running as freebuff

Three issues found:

  1. The /queue command is missing from FREEBUFF_ONLY_COMMANDS in command-registry.ts, so non-freebuff users who manually type the command see a confusing "returning to waiting room" system message.
  2. WaitingRoomScreen has no render branch for model_locked status — if returned in the edge case where a concurrent CLI was just admitted, the screen goes blank and polling halts.
  3. The advisory lock ID arithmetic could theoretically overflow; masking with | 0 would guarantee a safe 32-bit result.

Confidence Score: 4/5

Safe to merge with one targeted fix — the /queue command leaking to non-freebuff users is easily patched and the rest of the implementation is well-structured.

The core queue and session mechanics are solid: the single-UPSERT pattern for joinOrTakeOver, per-model advisory locks, migration strategy, and CLI poll loop are all correct. The one clear bug (queue missing from FREEBUFF_ONLY_COMMANDS) causes a confusing but harmless message for non-freebuff users and is a one-line fix. The model_locked UI gap is an edge case requiring two concurrent CLIs. The advisory lock arithmetic concern is theoretical with current base IDs. No data-loss or security risk present.

cli/src/commands/command-registry.ts (missing queue in FREEBUFF_ONLY_COMMANDS) and cli/src/components/waiting-room-screen.tsx (missing model_locked render branch).

Important Files Changed

Filename Overview
cli/src/commands/command-registry.ts Adds /queue command (aliases: rejoin, switch) but omits it from FREEBUFF_ONLY_COMMANDS, so non-freebuff users who type the command see a confusing "returning to waiting room" system message.
cli/src/components/waiting-room-screen.tsx Integrates FreebuffModelSelector into the queued state view; missing a render branch for model_locked (edge case where a concurrent CLI was admitted while this screen is open).
web/src/server/free-session/store.ts Core DB layer: adds model column support to joinOrTakeOver (single UPSERT), per-model queueDepth/queuePositionFor, and per-model advisory lock in admitFromQueue. Advisory lock ID arithmetic could overflow int4 in extreme cases.
web/src/server/free-session/public-api.ts Session API surface: requestSession handles FreeSessionModelLockedError and returns model_locked; checkSessionAdmissible adds session_model_mismatch gate. Well-structured with injected deps for testability.
web/src/server/free-session/admission.ts Admission tick loops over all models in parallel, each holding its own advisory lock. Logic is clean and the per-model queueDepthByModel output is observable.
cli/src/hooks/use-freebuff-session.ts Poll loop extended to send x-freebuff-model header on POST/GET; switchFreebuffModel persists model preference then re-POSTs; endAndRejoinFreebuffSession added for /queue command.
common/src/constants/freebuff-models.ts New shared module defines FREEBUFF_MODELS list, DEFAULT_FREEBUFF_MODEL_ID, and helpers (isFreebuffModelId, resolveFreebuffModel). Clean and well-documented.
cli/src/components/freebuff-model-selector.tsx Model selector UI with keyboard shortcuts (1–9), mouse hover, pending spinner, and disabled guard. Straightforward component with correct debounce via pending state.
packages/internal/src/db/migrations/0044_violet_stingray.sql Adds model column with temporary default for backfill, drops default immediately after, and rebuilds the queue index to include model as a prefix column — correct and safe migration pattern.
cli/src/utils/local-agent-registry.ts loadAgentDefinitions overrides model on three freebuff agent IDs from the persisted model store when IS_FREEBUFF. Override is dynamic (no caching), consistent with "mid-session switching is blocked" design.
cli/src/state/freebuff-model-store.ts Zustand store initialized from persisted settings; setSelectedModel validates via resolveFreebuffModel and writes to disk synchronously — clean single-responsibility module.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A([CLI Starts]) --> B[POST /freebuff/session\nwith x-freebuff-model header]
    B --> C{Server response}
    C -->|queued| D[WaitingRoomScreen\nshows position + ModelSelector]
    C -->|active| E[Chat session active]
    C -->|model_locked 409| F[model_locked state\n⚠ No UI branch in WaitingRoomScreen]
    C -->|country_blocked| G[Terminal: blocked screen]
    C -->|disabled| H[Pass-through: no gate]

    D --> I{User picks\ndifferent model}
    I -->|switchFreebuffModel| J[Persist to disk\nRe-POST to server]
    J --> C

    D --> K{Admission tick\nadmits user}
    K -->|active| E

    E --> L{User types /queue}
    L --> M[DELETE session\nReset chat history\nRe-POST]
    M --> D

    E --> N{checkSessionAdmissible\non each chat request}
    N -->|session_model_mismatch| O[409 to chat endpoint]
    N -->|ok| P[Chat request proceeds]
Loading

Comments Outside Diff (2)

  1. cli/src/commands/command-registry.ts, line 179-182 (link)

    P1 queue command missing from FREEBUFF_ONLY_COMMANDS

    The queue command is correctly listed in FREEBUFF_ONLY_COMMAND_IDS in slash-commands.ts (so it's hidden from non-freebuff users' autocomplete menu), but it is not in FREEBUFF_ONLY_COMMANDS here. This means the command is registered in COMMAND_REGISTRY for non-freebuff users.

    If a non-freebuff user manually types /queue, /rejoin, or /switch, the handler will:

    1. Add a "Ending session and returning to the waiting room…" system message to their chat — confusing for a user who is not in freebuff mode.
    2. Call endAndRejoinFreebuffSession() which returns immediately (guarded by IS_FREEBUFF), so no real harm is done.

    The fix is to add 'queue' to FREEBUFF_ONLY_COMMANDS:

  2. cli/src/components/waiting-room-screen.tsx, line 94 (link)

    P2 Missing model_locked state render in waiting room

    WaitingRoomScreen handles queued, disabled, and country_blocked states explicitly, but has no render branch for model_locked. If a model_locked response is applied to the session store while this screen is mounted (e.g., a user tries to switch models after another CLI instance on the same account was just admitted to active), nextDelayMs returns null for that status, polling halts, and the screen renders a blank body — just the logo with no status copy or actionable prompt.

    The PR description states the CLI should show a confirmation dialog ("End your active session to switch?"), but no such prompt appears in WaitingRoomScreen when session.status === 'model_locked'. Consider adding a branch that:

    • Surfaces the currentModel / requestedModel from the response
    • Offers a confirm button that calls endAndRejoinFreebuffSession()
    • Or at minimum re-polls after a short delay to recover from the stale state

Reviews (1): Last reviewed commit: "Add model selector to freebuff with per-..." | Re-trigger Greptile

Comment on lines +240 to 241
const modelLockId = FREEBUFF_ADMISSION_LOCK_ID + hashStringToInt32(model)
const lockResult = await tx.execute<{ acquired: unknown }>(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Advisory lock ID overflow risk

modelLockId is computed as:

const modelLockId = FREEBUFF_ADMISSION_LOCK_ID + hashStringToInt32(model)

hashStringToInt32 can return values up to 0x3FFFFFFF (~1.07 billion). If FREEBUFF_ADMISSION_LOCK_ID is itself close to 2^31 - 1 (2,147,483,647, the max signed int4), the sum could overflow that bound. While pg_try_advisory_xact_lock accepts a bigint, the value is produced by JavaScript number addition — if the result exceeds Number.MAX_SAFE_INTEGER (unlikely here, but possible with very large base IDs), it could lose precision.

Consider capping or masking the result to keep it within a known safe range:

const modelLockId = (FREEBUFF_ADMISSION_LOCK_ID + hashStringToInt32(model)) | 0

This forces a signed 32-bit result (via bitwise OR), matching PostgreSQL's int4 domain for the two-argument form, and avoids the risk entirely.

This is a low-probability issue with the current base ID (likely small), but worth hardening before additional models are added.

jahooma and others added 9 commits April 20, 2026 14:52
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…_locked

- Add end-session to FREEBUFF_ONLY_COMMANDS so non-freebuff users can't
  invoke it (would have shown a confusing "returning to waiting room"
  message with no underlying state to act on).
- When the waiting room receives model_locked from a switch attempt that
  raced with admission, silently revert the local model selection to the
  active session's model and re-tick. Previously polling halted and the
  screen had no render branch, leaving the UI blank.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The admission tick now iterates per registered model, so tests that
asserted admitted: 1 received 2 (one per model). Default the test
helper to a single model so the existing assertions stay crisp and
won't drift as more production models are added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Drop dead x-freebuff-model header on GET — the server only reads it on
  POST, and tick() always POSTs first so GET-before-POST never happens.
- Derive FREEBUFF_MODEL_OVERRIDABLE_AGENT_IDS from the server's
  FREE_MODE_AGENT_MODELS (agents whose allowlist includes every freebuff
  model) so adding a new model doesn't require updating two lists.
- Extract shouldReleaseSlot() — DELETE-eligibility predicate was inlined
  in two places.
- Probe Fireworks once per admission tick instead of N times (N = number
  of models). Adds a TODO for when we add a non-Fireworks model.
- Tighten model-selector key handler to /^[1-9]$/ so "1abc" isn't
  treated as 1.
- Make FREEBUFF_MODELS a literal tuple so isFreebuffModelId narrows to
  the actual id union instead of plain string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the fleet-wide worst-of collapse with a per-model map. One
probe per tick still covers every deployment (Fireworks returns them
in a single response), but each model's admission now uses its own
deployment's verdict — a degraded minimax no longer blocks glm.

Models absent from FIREWORKS_DEPLOYMENT_MAP (serverless) default to
'healthy'; TODO for when they move to dedicated deployments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Update switchFreebuffModel docstring to match the silent auto-revert
  the tick loop actually does (not the prompt-to-end-first flow it used
  to describe).
- Remove the unused `disabled` prop on FreebuffModelSelector; the only
  caller never set it, so the component's internal `pending` state is
  the only disable path.
- Refresh docs/freebuff-waiting-room.md for per-model queues: mermaid,
  schema (with the `model` column from migration 0044), admission loop
  (per-model advisory locks, getFleetHealth), tunables, POST semantics,
  model_locked response shape, and the CLI / multi-pod sections.
- Group endAndRejoinFreebuffSession with the other ../hooks/* imports
  in command-registry.ts.
Prefixes the idle session readout with the model's display name (e.g.
"GLM 5.1 · Free session · 48m left") so admitted users always see which
model is answering, without spending any vertical space.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Server returns a queueDepthByModel snapshot with every queued session
response via a single GROUP BY read, so each row of the model selector
can show a live "3 ahead" / "No wait" hint instead of the static
tagline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GLM 5.1 → "Smartest", MiniMax M2.7 → "Fastest". Single-word labels
read better next to the live "N ahead" hint than a full sentence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jahooma jahooma merged commit be87083 into main Apr 20, 2026
9 checks passed
@jahooma jahooma deleted the jahooma/model-selector branch April 20, 2026 23:23
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